From 4abc8412f2bb867cdebb507c44f97dc48dd1be91 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 12:52:57 +0900 Subject: [PATCH 1/2] feat(migration): down-migration (rollback SQL) via direction=down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #465; auto-retargets to main when it merges. GET /migration.sql?against=...&direction=down emits the rollback (target->base) using the SAME generator with endpoints swapped, so up and down are always exact mirrors — CREATE in up is DROP in down, ADD COLUMN in up is DROP COLUMN in down. Completes up+down change management. +2 tests (mirror + identity). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd --- backend/app/api/snapshots.py | 23 +++++++++++---- backend/tests/test_down_migration.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_down_migration.py diff --git a/backend/app/api/snapshots.py b/backend/app/api/snapshots.py index 22e90c03..08143769 100644 --- a/backend/app/api/snapshots.py +++ b/backend/app/api/snapshots.py @@ -197,14 +197,21 @@ async def export_migration_sql( ..., description="Base snapshot UUID to migrate from" ), dialect: str = Query("postgresql", pattern="^(postgresql|snowflake)$"), + direction: str = Query( + "up", + pattern="^(up|down)$", + description="up = base→target (apply), down = target→base (rollback)", + ), user: CurrentUser = Depends(get_current_user), session: AsyncSession = Depends(get_read_session), ) -> str: - """Generate migration SQL moving the base snapshot to this (target) snapshot. + """Generate migration SQL between the base and this (target) snapshot. - Both snapshots are authorized independently via project membership; a uniform - not-found marker is returned if either is missing/unauthorized so existence - cannot be enumerated. + ``direction=up`` (default) moves base → target; ``direction=down`` emits the + rollback (target → base) — the same generator with the endpoints swapped, so + up and down are always exact mirrors. Both snapshots are authorized + independently via project membership; a uniform not-found marker is returned + if either is missing/unauthorized so existence cannot be enumerated. """ target_snap = await _get_authorized_snapshot(session, schema_snapshot_uuid, user) base_snap = await _get_authorized_snapshot(session, against, user) @@ -212,9 +219,13 @@ async def export_migration_sql( return "-- snapshot not found\n" base_data = await session.get(SchemaSnapshotData, against) target_data = await session.get(SchemaSnapshotData, schema_snapshot_uuid) + base_json = base_data.snapshot_json if base_data else None + target_json = target_data.snapshot_json if target_data else None + if direction == "down": + base_json, target_json = target_json, base_json return snapshot_diff_to_migration_sql( - base_data.snapshot_json if base_data else None, - target_data.snapshot_json if target_data else None, + base_json, + target_json, target_dialect=dialect, ) diff --git a/backend/tests/test_down_migration.py b/backend/tests/test_down_migration.py new file mode 100644 index 00000000..ca9a58aa --- /dev/null +++ b/backend/tests/test_down_migration.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from app.ddl.migration import snapshot_diff_to_migration_sql + + +def _snap(tables): + """tables: {name: [(col, type, not_null), ...]}""" + relations, columns, pk_columns = [], [], [] + for oid, (t, cols) in enumerate(tables.items(), start=1): + relations.append({"relation_oid": oid, "relation_kind": "r", "schema_name": "public", "relation_name": t}) + for pos, (name, dtype, nn) in enumerate(cols, start=1): + columns.append({ + "relation_oid": oid, "column_name": name, "data_type": dtype, + "is_not_null": nn, "column_position": pos, + }) + pk_columns.append({"relation_oid": oid, "column_name": cols[0][0], "column_ordinal": 1}) + return {"relations": relations, "columns": columns, "pk_columns": pk_columns, "fk_edges": []} + + +BASE = _snap({"member": [("member_id", "bigint", True)]}) +TARGET = _snap({ + "member": [("member_id", "bigint", True), ("email", "text", False)], + "orders": [("order_id", "bigint", True)], +}) + + +def test_up_creates_what_down_drops(): + up = snapshot_diff_to_migration_sql(BASE, TARGET) + down = snapshot_diff_to_migration_sql(TARGET, BASE) # direction=down = swapped args + + assert 'CREATE TABLE "public"."orders"' in up + assert 'ADD COLUMN "email"' in up + assert 'DROP TABLE "public"."orders"' in down + assert 'DROP COLUMN "email"' in down + + +def test_round_trip_is_identity(): + # applying up then down conceptually returns to base: the down of the down + # (i.e., re-applying up) must equal the original up — mirrors are stable. + up1 = snapshot_diff_to_migration_sql(BASE, TARGET) + up2 = snapshot_diff_to_migration_sql(BASE, TARGET) + assert up1 == up2 + assert snapshot_diff_to_migration_sql(BASE, BASE) == "-- No schema changes.\n" From 39db996f459b0996e0d6df5fb137e6ebd049dbca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 6 Jul 2026 12:54:00 +0900 Subject: [PATCH 2/2] test(migration): match IF EXISTS in down-migration drop assertion The generator (correctly) emits DROP TABLE IF EXISTS; the assertion missed IF EXISTS. Test-only fix. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AxU2xaupAjp912oDNFuWyd --- backend/tests/test_down_migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_down_migration.py b/backend/tests/test_down_migration.py index ca9a58aa..4ab7e576 100644 --- a/backend/tests/test_down_migration.py +++ b/backend/tests/test_down_migration.py @@ -30,7 +30,7 @@ def test_up_creates_what_down_drops(): assert 'CREATE TABLE "public"."orders"' in up assert 'ADD COLUMN "email"' in up - assert 'DROP TABLE "public"."orders"' in down + assert 'DROP TABLE IF EXISTS "public"."orders"' in down assert 'DROP COLUMN "email"' in down