Skip to content

Commit b18b1cd

Browse files
authored
Merge pull request #601 from lbedner/v0.6.8-rc4
v0.6.8-rc4
2 parents 2a4668d + 8cb25ce commit b18b1cd

10 files changed

Lines changed: 221 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Each generated project includes:
3232

3333
## Installation
3434

35-
**Current Version**: 0.6.8rc3
35+
**Current Version**: 0.6.8rc4
3636

3737
```bash
3838
pip install aegis-stack

aegis/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Aegis Stack CLI - Component generation and project management tools.
33
"""
44

5-
__version__ = "0.6.8rc3"
5+
__version__ = "0.6.8rc4"

aegis/templates/copier-aegis-project/{{ project_slug }}/Makefile.jinja

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,11 +189,7 @@ migrate-history: ## Show migration history
189189
@docker compose exec webserver uv run alembic -c alembic/alembic.ini history --verbose
190190

191191
migrate-fix: ## Auto-fix schema mismatches after upgrade (safe: only adds, never drops)
192-
@echo "Checking for schema mismatches..."
193192
@docker compose exec webserver uv run python -m app.cli.migrate_fix
194-
@echo "Applying fix migration..."
195-
@docker compose exec webserver uv run alembic -c alembic/alembic.ini upgrade head
196-
@echo "Schema fix complete"
197193

198194
migrate-reset: ## Reset database (WARNING: destructive)
199195
@echo "WARNING: This will destroy all data in the database!"

aegis/templates/copier-aegis-project/{{ project_slug }}/app/cli/migrate_fix.py.jinja

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,9 @@ def _get_additive_diffs() -> list[tuple]:
7777
additive_diffs.append(diff)
7878

7979
elif op_type == "add_table":
80-
# Missing tables need a full migration — too complex for auto-fix
8180
table = diff[1]
8281
if table.name in managed_tables:
83-
print(
84-
t("migrate.table_missing_warning", table=table.name)
85-
)
82+
additive_diffs.append(diff)
8683

8784
# Skip: add_index, remove_table, remove_column, remove_index,
8885
# modify_type, modify_nullable, modify_default, etc.
@@ -122,7 +119,45 @@ def _generate_fix_migration(diffs: list[tuple]) -> Path | None:
122119
for diff in diffs:
123120
op_type = diff[0]
124121

125-
if op_type == "add_column":
122+
if op_type == "add_table":
123+
table = diff[1]
124+
# Build CREATE TABLE with columns
125+
cols = []
126+
for col in table.columns:
127+
col_str = f"sa.Column('{col.name}', {_sa_type_str(col.type)}"
128+
col_str += f", nullable={col.nullable}"
129+
if col.primary_key:
130+
col_str += ", primary_key=True"
131+
if col.server_default is not None:
132+
col_str += f", server_default=sa.text('{col.server_default.arg}')"
133+
col_str += ")"
134+
cols.append(f" {col_str},")
135+
cols_block = "\n".join(cols)
136+
upgrade_lines.append(
137+
f" op.create_table(\n '{table.name}',\n{cols_block}\n )"
138+
)
139+
# Add foreign keys
140+
for fk in table.foreign_key_constraints:
141+
local_cols = [c.name for c in fk.columns]
142+
ref_table = fk.referred_table.name
143+
ref_cols = [c.name for c in fk.referred_table.primary_key.columns]
144+
fk_name = fk.name or f"fk_{table.name}_{'_'.join(local_cols)}_{ref_table}"
145+
upgrade_lines.append(
146+
f" op.create_foreign_key('{fk_name}', '{table.name}', '{ref_table}', {local_cols}, {ref_cols})"
147+
)
148+
downgrade_lines.append(
149+
f" op.drop_constraint('{fk_name}', '{table.name}', type_='foreignkey')"
150+
)
151+
# Add indexes
152+
for idx in table.indexes:
153+
idx_cols = [c.name for c in idx.columns]
154+
unique_str = ", unique=True" if idx.unique else ""
155+
upgrade_lines.append(
156+
f" op.create_index('{idx.name}', '{table.name}', {idx_cols}{unique_str})"
157+
)
158+
downgrade_lines.append(f" op.drop_table('{table.name}')")
159+
160+
elif op_type == "add_column":
126161
table_name = diff[2]
127162
column = diff[3]
128163
col_str = f"sa.Column('{column.name}', {_sa_type_str(column.type)}"
@@ -212,10 +247,23 @@ def main() -> None:
212247
for diff in diffs:
213248
if diff[0] == "add_column":
214249
print(f" + {t('migrate.add_column')} {diff[2]}.{diff[3].name}")
250+
elif diff[0] == "add_table":
251+
print(f" + {t('migrate.add_table')} {diff[1].name}")
215252

216253
migration_path = _generate_fix_migration(diffs)
217254
if migration_path:
218255
print(f"\n{t('migrate.generated_migration', name=migration_path.name)}")
256+
257+
# Prompt to apply
258+
apply = input(f"\n{t('migrate.apply_now')} [Y/n] ").strip().lower()
259+
if apply in ("", "y", "yes"):
260+
from alembic import command as alembic_command
261+
262+
alembic_cfg = Config("alembic/alembic.ini")
263+
alembic_command.upgrade(alembic_cfg, "head")
264+
print(t("migrate.applied_migration"))
265+
else:
266+
print(t("migrate.apply_later"))
219267
else:
220268
print(f"\n{t('migrate.no_fixable_differences')}")
221269
sys.exit(0)

aegis/templates/copier-aegis-project/{{ project_slug }}/app/i18n/locales/en.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,11 @@
473473
"migrate.schema_up_to_date": "Schema is up to date. No fix needed.",
474474
"migrate.found_differences": "Found {count} schema differences:",
475475
"migrate.add_column": "ADD COLUMN",
476+
"migrate.add_table": "ADD TABLE",
476477
"migrate.generated_migration": "Generated fix migration: {name}",
478+
"migrate.apply_now": "Apply migration now?",
479+
"migrate.applied_migration": "Migration applied successfully.",
480+
"migrate.apply_later": "Migration saved. Run 'make migrate' to apply later.",
477481
"migrate.no_fixable_differences": "No fixable differences found.",
478482
# ── AI ───────────────────────────────────────────────────────────
479483
"ai.header_inline": "Illiana: ",

aegis/templates/copier-aegis-project/{{ project_slug }}/app/i18n/locales/zh.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,11 @@
472472
"migrate.schema_up_to_date": "数据库结构已是最新,无需修复。",
473473
"migrate.found_differences": "发现 {count} 处结构差异:",
474474
"migrate.add_column": "新增列",
475+
"migrate.add_table": "新增表",
475476
"migrate.generated_migration": "已生成修复迁移:{name}",
477+
"migrate.apply_now": "是否立即执行迁移?",
478+
"migrate.applied_migration": "迁移已成功执行",
479+
"migrate.apply_later": "迁移已保存。可稍后运行 'make migrate' 执行",
476480
"migrate.no_fixable_differences": "没有可自动修复的差异。",
477481
# ── AI ───────────────────────────────────────────────────────────
478482
"ai.header_inline": "Illiana:",

copier.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# - Update support
77

88
_min_copier_version: "9.0.0"
9-
_version: "0.6.8rc3"
9+
_version: "0.6.8rc4"
1010

1111
# IMPORTANT: Template content is in subdirectory
1212
# This allows the template to be recognized as git-tracked (aegis-stack repo root has .git)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "aegis-stack"
3-
version = "0.6.8rc3"
3+
version = "0.6.8rc4"
44
description = "A production-ready FastAPI platform with modular components and a built-in control plane. Try: uvx aegis-stack init my-project"
55
readme = "README.md"
66
requires-python = ">=3.11,<3.15"

tests/core/test_migrate_fix.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,158 @@ def test_upgrade_scenario_basic_to_current(self) -> None:
195195
# Missing tables
196196
assert "password_reset_token" in added_table_names
197197
assert "email_verification_token" in added_table_names
198+
199+
200+
class TestMigrateFixMigrationRendering:
201+
"""Test that add_table diffs render correct migration content."""
202+
203+
def _render_add_table_upgrade(self, table: sa.Table) -> tuple[list[str], list[str]]:
204+
"""Simulate the migration rendering logic from migrate_fix.py."""
205+
206+
def _sa_type_str(col_type: object) -> str:
207+
type_name = type(col_type).__name__
208+
return f"sa.{type_name}()"
209+
210+
upgrade_lines: list[str] = []
211+
downgrade_lines: list[str] = []
212+
213+
cols = []
214+
for col in table.columns:
215+
col_str = f"sa.Column('{col.name}', {_sa_type_str(col.type)}"
216+
col_str += f", nullable={col.nullable}"
217+
if col.primary_key:
218+
col_str += ", primary_key=True"
219+
if col.server_default is not None:
220+
col_str += f", server_default=sa.text('{col.server_default.arg}')" # type: ignore[union-attr]
221+
col_str += ")"
222+
cols.append(f" {col_str},")
223+
cols_block = "\n".join(cols)
224+
upgrade_lines.append(
225+
f" op.create_table(\n '{table.name}',\n{cols_block}\n )"
226+
)
227+
228+
for fk in table.foreign_key_constraints:
229+
local_cols = [c.name for c in fk.columns]
230+
ref_table = fk.referred_table.name
231+
ref_cols = [c.name for c in fk.referred_table.primary_key.columns]
232+
fk_name = fk.name or f"fk_{table.name}_{'_'.join(local_cols)}_{ref_table}"
233+
upgrade_lines.append(
234+
f" op.create_foreign_key('{fk_name}', '{table.name}', '{ref_table}', {local_cols}, {ref_cols})"
235+
)
236+
downgrade_lines.append(
237+
f" op.drop_constraint('{fk_name}', '{table.name}', type_='foreignkey')"
238+
)
239+
240+
for idx in table.indexes:
241+
idx_cols = [c.name for c in idx.columns]
242+
unique_str = ", unique=True" if idx.unique else ""
243+
upgrade_lines.append(
244+
f" op.create_index('{idx.name}', '{table.name}', {idx_cols}{unique_str})"
245+
)
246+
247+
downgrade_lines.append(f" op.drop_table('{table.name}')")
248+
249+
return upgrade_lines, downgrade_lines
250+
251+
def test_renders_create_table(self) -> None:
252+
"""add_table diff renders op.create_table with all columns."""
253+
metadata = sa.MetaData()
254+
table = sa.Table(
255+
"password_reset_token",
256+
metadata,
257+
sa.Column("id", sa.Integer(), primary_key=True),
258+
sa.Column("user_id", sa.Integer(), nullable=False),
259+
sa.Column("token", sa.String(), nullable=False),
260+
sa.Column(
261+
"used", sa.Boolean(), nullable=False, server_default=sa.text("0")
262+
),
263+
)
264+
265+
upgrade, downgrade = self._render_add_table_upgrade(table)
266+
267+
# Should have create_table
268+
create_line = upgrade[0]
269+
assert "op.create_table(" in create_line
270+
assert "'password_reset_token'" in create_line
271+
assert "'id'" in create_line
272+
assert "'user_id'" in create_line
273+
assert "'token'" in create_line
274+
assert "'used'" in create_line
275+
assert "primary_key=True" in create_line
276+
assert "server_default=sa.text('0')" in create_line
277+
278+
# Downgrade drops the table
279+
assert "op.drop_table('password_reset_token')" in downgrade[-1]
280+
281+
def test_renders_foreign_keys(self) -> None:
282+
"""add_table diff with FKs renders op.create_foreign_key."""
283+
metadata = sa.MetaData()
284+
sa.Table(
285+
"user",
286+
metadata,
287+
sa.Column("id", sa.Integer(), primary_key=True),
288+
)
289+
sa.Table(
290+
"password_reset_token",
291+
metadata,
292+
sa.Column("id", sa.Integer(), primary_key=True),
293+
sa.Column(
294+
"user_id",
295+
sa.Integer(),
296+
sa.ForeignKey("user.id"),
297+
nullable=False,
298+
),
299+
sa.Column("token", sa.String(), nullable=False),
300+
)
301+
302+
token_table = metadata.tables["password_reset_token"]
303+
upgrade, downgrade = self._render_add_table_upgrade(token_table)
304+
305+
# Should have create_foreign_key
306+
fk_lines = [line for line in upgrade if "create_foreign_key" in line]
307+
assert len(fk_lines) == 1
308+
assert "'password_reset_token'" in fk_lines[0]
309+
assert "'user'" in fk_lines[0]
310+
assert "['user_id']" in fk_lines[0]
311+
assert "['id']" in fk_lines[0]
312+
313+
# Downgrade should drop the constraint
314+
drop_fk_lines = [line for line in downgrade if "drop_constraint" in line]
315+
assert len(drop_fk_lines) == 1
316+
assert "type_='foreignkey'" in drop_fk_lines[0]
317+
318+
def test_renders_indexes(self) -> None:
319+
"""add_table diff with indexes renders op.create_index."""
320+
metadata = sa.MetaData()
321+
table = sa.Table(
322+
"org_invite",
323+
metadata,
324+
sa.Column("id", sa.Integer(), primary_key=True),
325+
sa.Column("email", sa.String(), nullable=False),
326+
sa.Column("token", sa.String(), nullable=False),
327+
)
328+
sa.Index("ix_org_invite_token", table.c.token, unique=True)
329+
330+
upgrade, _ = self._render_add_table_upgrade(table)
331+
332+
idx_lines = [line for line in upgrade if "create_index" in line]
333+
assert len(idx_lines) == 1
334+
assert "'ix_org_invite_token'" in idx_lines[0]
335+
assert "'org_invite'" in idx_lines[0]
336+
assert "unique=True" in idx_lines[0]
337+
338+
def test_empty_table_renders(self) -> None:
339+
"""Table with only a PK column still renders correctly."""
340+
metadata = sa.MetaData()
341+
table = sa.Table(
342+
"simple",
343+
metadata,
344+
sa.Column("id", sa.Integer(), primary_key=True),
345+
)
346+
347+
upgrade, downgrade = self._render_add_table_upgrade(table)
348+
349+
assert "op.create_table(" in upgrade[0]
350+
assert "'simple'" in upgrade[0]
351+
assert len(upgrade) == 1 # No FKs or indexes
352+
assert "op.drop_table('simple')" in downgrade[-1]

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)