Skip to content

Commit 7287a49

Browse files
Override drop-migrations with main and stashed work
1 parent 387d223 commit 7287a49

File tree

3 files changed

+210
-65
lines changed

3 files changed

+210
-65
lines changed

backend/app/routes/migration_routes.py

Lines changed: 77 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
from collections import defaultdict
12
from uuid import UUID
23

34
from fastapi import APIRouter, Depends, HTTPException
5+
from supabase._async.client import AsyncClient
46

57
from app.core.dependencies import get_current_admin
6-
from app.schemas.classification_schemas import Classification
8+
from app.core.supabase import get_async_supabase
9+
from app.schemas.classification_schemas import Classification, ExtractedFile
710
from app.schemas.migration_schemas import Migration, MigrationCreate
811
from app.schemas.relationship_schemas import Relationship
912
from app.services.classification_service import (
@@ -18,7 +21,7 @@
1821
RelationshipService,
1922
get_relationship_service,
2023
)
21-
from app.utils.migrations import create_migrations
24+
from app.utils.migrations import _table_name_for_classification, create_migrations
2225

2326
router = APIRouter(prefix="/migrations", tags=["Migrations"])
2427

@@ -56,7 +59,6 @@ async def generate_migrations(
5659
Then insert the new migrations into the `migrations` table and return them.
5760
"""
5861
try:
59-
# 1) Load current state from DB
6062
classifications: list[
6163
Classification
6264
] = await classification_service.get_classifications(tenant_id)
@@ -72,19 +74,15 @@ async def generate_migrations(
7274
status_code=404, detail="No classifications found for tenant"
7375
)
7476

75-
# 2) Compute *new* migrations (pure function)
76-
# IMPORTANT: this should return list[MigrationCreate]
7777
new_migration_creates: list[MigrationCreate] = create_migrations(
7878
classifications=classifications,
7979
relationships=relationships,
8080
initial_migrations=existing_migrations,
8181
)
8282

8383
if not new_migration_creates:
84-
# Nothing new to add
8584
return []
8685

87-
# 3) Insert into DB and return the created migrations
8886
created: list[Migration] = []
8987
for m in new_migration_creates:
9088
new_id = await migration_service.create_migration(m)
@@ -122,6 +120,78 @@ async def execute_migrations(
122120
raise HTTPException(status_code=500, detail=str(e)) from e
123121

124122

123+
@router.post("/load_data/{tenant_id}")
124+
async def load_data_for_tenant(
125+
tenant_id: UUID,
126+
classification_service: ClassificationService = Depends(get_classification_service),
127+
supabase: AsyncClient = Depends(get_async_supabase),
128+
admin=Depends(get_current_admin),
129+
) -> dict:
130+
"""
131+
Full data sync for a tenant:
132+
133+
- Fetch all extracted files + their classifications
134+
- Group by classification
135+
- For each classification:
136+
* derive table name (same as migrations)
137+
* DELETE existing rows for that tenant
138+
* INSERT rows for each file in that classification
139+
"""
140+
try:
141+
extracted_files: list[
142+
ExtractedFile
143+
] = await classification_service.get_extracted_files(tenant_id)
144+
145+
if not extracted_files:
146+
return {
147+
"status": "ok",
148+
"tables_updated": [],
149+
"message": "No extracted files found",
150+
}
151+
152+
files_by_class_id: dict[UUID, list[ExtractedFile]] = defaultdict(list)
153+
154+
for ef in extracted_files:
155+
if ef.classification is None:
156+
continue
157+
files_by_class_id[ef.classification.classification_id].append(ef)
158+
159+
updated_tables: list[str] = []
160+
161+
for class_files in files_by_class_id.values():
162+
classification = class_files[0].classification
163+
table_name = _table_name_for_classification(classification)
164+
165+
await (
166+
supabase.table(table_name)
167+
.delete()
168+
.eq("tenant_id", str(tenant_id))
169+
.execute()
170+
)
171+
172+
rows = [
173+
{
174+
"id": str(f.extracted_file_id),
175+
"tenant_id": str(tenant_id),
176+
"data": f.extracted_data,
177+
}
178+
for f in class_files
179+
]
180+
181+
if rows:
182+
await supabase.table(table_name).insert(rows).execute()
183+
184+
updated_tables.append(table_name)
185+
186+
return {
187+
"status": "ok",
188+
"tables_updated": updated_tables,
189+
"message": "Data synced from extracted_files into generated tables",
190+
}
191+
except Exception as e:
192+
raise HTTPException(status_code=500, detail=str(e)) from e
193+
194+
125195
@router.get("/connection-url/{tenant_id}")
126196
async def get_tenant_connection_url(
127197
tenant_id: UUID,
@@ -130,15 +200,6 @@ async def get_tenant_connection_url(
130200
) -> dict:
131201
"""
132202
Get a PostgreSQL connection URL for a specific tenant.
133-
134-
This URL is scoped to only show the tenant's generated tables.
135-
136-
Query params:
137-
include_public: If true, also include public schema (for shared tables)
138-
139-
Example:
140-
GET /migrations/connection-url/{tenant_id}
141-
GET /migrations/connection-url/{tenant_id}?include_public=true
142203
"""
143204
from app.utils.tenant_connection import get_schema_name, get_tenant_connection_url
144205

backend/app/utils/migrations.py

Lines changed: 132 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,31 @@ def _get_schema_name(tenant_id) -> str:
2121
return f"tenant_{str(tenant_id).replace('-', '_')}"
2222

2323

24+
def _get_created_tables(migrations: list[Migration]) -> set[str]:
25+
"""
26+
Get all table names that have been created by migrations.
27+
Returns: set of table names
28+
"""
29+
created_tables = set()
30+
for m in migrations:
31+
if m.name.startswith("create_table_"):
32+
table_name = m.name.replace("create_table_", "")
33+
created_tables.add(table_name)
34+
return created_tables
35+
36+
37+
def _get_dropped_tables(migrations: list[Migration]) -> set[str]:
38+
"""
39+
Get table names that have already been dropped.
40+
"""
41+
dropped = set()
42+
for m in migrations:
43+
if m.name.startswith("drop_table_"):
44+
table_name = m.name.replace("drop_table_", "")
45+
dropped.add(table_name)
46+
return dropped
47+
48+
2449
def create_migrations(
2550
classifications: list[Classification],
2651
relationships: list[Relationship],
@@ -30,16 +55,20 @@ def create_migrations(
3055
PURE FUNCTION.
3156
3257
Given:
33-
- classifications: what tables we conceptually want
58+
- classifications: what tables we conceptually want NOW
3459
- relationships: how those tables relate (1-1, 1-many, many-many)
3560
- initial_migrations: migrations that already exist in DB
3661
3762
Returns:
3863
- list[MigrationCreate] = new migrations to append on top
3964
40-
NOW WITH SCHEMA-PER-TENANT:
41-
- First migration creates the tenant schema
42-
- All tables are created within that schema
65+
This function handles:
66+
1. CREATE SCHEMA for the tenant
67+
2. CREATE TABLE for new classifications
68+
3. DROP TABLE for removed classifications
69+
4. Relationship migrations
70+
71+
All SQL is schema-qualified for tenant isolation.
4372
"""
4473
if not classifications:
4574
return []
@@ -52,11 +81,16 @@ def create_migrations(
5281

5382
new_migrations: list[MigrationCreate] = []
5483

55-
# All classifications belong to the same tenant
56-
tenant_id = classifications[0].tenant_id
57-
schema_name = _get_schema_name(tenant_id)
84+
# Get tenant info and schema name
85+
tenant_id = classifications[0].tenant_id if classifications else None
86+
if not tenant_id:
87+
# If no classifications exist, try to get tenant_id from migrations
88+
if initial_migrations:
89+
tenant_id = initial_migrations[0].tenant_id
5890

59-
# ===== STEP 1: CREATE SCHEMA =====
91+
schema_name = _get_schema_name(tenant_id) if tenant_id else "public"
92+
93+
# ===== STEP 0: CREATE SCHEMA =====
6094
schema_migration_name = f"create_schema_{schema_name}"
6195

6296
if schema_migration_name not in existing_names:
@@ -71,7 +105,45 @@ def create_migrations(
71105
existing_names.add(schema_migration_name)
72106
next_seq += 1
73107

108+
# ===== STEP 1: Handle DROP migrations for removed classifications =====
109+
# Get current state of tables from migrations
110+
created_tables = _get_created_tables(initial_migrations)
111+
dropped_tables = _get_dropped_tables(initial_migrations)
112+
active_tables = created_tables - dropped_tables
113+
114+
# Build current classification table names
115+
current_classification_tables = {
116+
_table_name_for_classification(c) for c in classifications
117+
}
118+
119+
# Tables that were created but no longer in classifications = should be dropped
120+
tables_to_drop = active_tables - current_classification_tables
121+
122+
for table_name in sorted(tables_to_drop):
123+
# Remove schema prefix if present (helper functions might include it)
124+
clean_table_name = table_name.split('.')[-1] if '.' in table_name else table_name
125+
mig_name = f"drop_table_{schema_name}_{clean_table_name}"
126+
127+
if mig_name in existing_names:
128+
continue
129+
130+
# Schema-qualified DROP with CASCADE
131+
sql = f"DROP TABLE IF EXISTS {schema_name}.{clean_table_name} CASCADE;"
132+
133+
if tenant_id:
134+
new_migrations.append(
135+
MigrationCreate(
136+
tenant_id=tenant_id,
137+
name=mig_name,
138+
sql=sql,
139+
sequence=next_seq,
140+
)
141+
)
142+
existing_names.add(mig_name)
143+
next_seq += 1
144+
74145
# ===== STEP 2: CREATE TABLES (in tenant schema) =====
146+
75147
for c in classifications:
76148
table_name = _table_name_for_classification(c)
77149
qualified_table_name = f"{schema_name}.{table_name}"
@@ -80,14 +152,15 @@ def create_migrations(
80152
if mig_name in existing_names:
81153
continue
82154

155+
# Schema-qualified CREATE
83156
sql = f"""
84-
CREATE TABLE IF NOT EXISTS {qualified_table_name} (
85-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
86-
tenant_id UUID NOT NULL,
87-
data JSONB NOT NULL,
88-
created_at TIMESTAMPTZ DEFAULT NOW()
157+
CREATE TABLE IF NOT EXISTS {qualified_table_name} (
158+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
159+
tenant_id UUID NOT NULL,
160+
data JSONB NOT NULL,
161+
created_at TIMESTAMPTZ DEFAULT NOW()
89162
);
90-
""".strip()
163+
""".strip()
91164

92165
new_migrations.append(
93166
MigrationCreate(
@@ -105,54 +178,65 @@ def create_migrations(
105178
from_table = _table_name_for_classification(rel.from_classification)
106179
to_table = _table_name_for_classification(rel.to_classification)
107180

181+
# Skip relationships where either table doesn't exist anymore
182+
if (
183+
from_table not in current_classification_tables
184+
or to_table not in current_classification_tables
185+
):
186+
continue
187+
108188
qualified_from = f"{schema_name}.{from_table}"
109189
qualified_to = f"{schema_name}.{to_table}"
110190

111191
# Support both Enum and plain string for rel.type
112-
rel_type = getattr(rel.type, "value", rel.type)
192+
raw_type = getattr(rel.type, "value", rel.type)
193+
rel_type_norm = str(raw_type).upper().replace("-", "_")
113194

114-
mig_name = f"rel_{rel_type.lower()}_{schema_name}_{from_table}_{to_table}"
195+
mig_name = f"rel_{rel_type_norm.lower()}_{schema_name}_{from_table}_{to_table}"
115196

116197
if mig_name in existing_names:
117198
continue
118199

119-
if rel_type == "ONE_TO_MANY":
200+
if rel_type_norm == "ONE_TO_MANY":
201+
# Schema-qualified ALTER TABLE for one-to-many
120202
sql = f"""
121-
ALTER TABLE {qualified_from}
122-
ADD COLUMN IF NOT EXISTS {to_table}_id UUID,
123-
ADD CONSTRAINT fk_{schema_name}_{from_table}_{to_table}
124-
FOREIGN KEY ({to_table}_id)
125-
REFERENCES {qualified_to}(id);
126-
""".strip()
127-
128-
elif rel_type == "ONE_TO_ONE":
203+
ALTER TABLE {qualified_from}
204+
ADD COLUMN IF NOT EXISTS {to_table}_id UUID,
205+
ADD CONSTRAINT fk_{schema_name}_{from_table}_{to_table}
206+
FOREIGN KEY ({to_table}_id)
207+
REFERENCES {qualified_to}(id);
208+
""".strip()
209+
210+
elif rel_type_norm == "ONE_TO_ONE":
211+
# Schema-qualified ALTER TABLE for one-to-one
129212
sql = f"""
130-
ALTER TABLE {qualified_from}
131-
ADD COLUMN IF NOT EXISTS {to_table}_id UUID UNIQUE,
132-
ADD CONSTRAINT fk_{schema_name}_{from_table}_{to_table}
133-
FOREIGN KEY ({to_table}_id)
134-
REFERENCES {qualified_to}(id);
135-
""".strip()
136-
137-
elif rel_type == "MANY_TO_MANY":
213+
ALTER TABLE {qualified_from}
214+
ADD COLUMN IF NOT EXISTS {to_table}_id UUID UNIQUE,
215+
ADD CONSTRAINT fk_{schema_name}_{from_table}_{to_table}
216+
FOREIGN KEY ({to_table}_id)
217+
REFERENCES {qualified_to}(id);
218+
""".strip()
219+
220+
elif rel_type_norm == "MANY_TO_MANY":
221+
# Schema-qualified CREATE TABLE for join table
138222
join_table = f"{from_table}_{to_table}_join"
139223
qualified_join = f"{schema_name}.{join_table}"
140224

141225
sql = f"""
142-
CREATE TABLE IF NOT EXISTS {qualified_join} (
143-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
144-
{from_table}_id UUID NOT NULL,
145-
{to_table}_id UUID NOT NULL,
146-
CONSTRAINT fk_{schema_name}_{join_table}_{from_table}
147-
FOREIGN KEY ({from_table}_id)
148-
REFERENCES {qualified_from}(id),
149-
CONSTRAINT fk_{schema_name}_{join_table}_{to_table}
150-
FOREIGN KEY ({to_table}_id)
151-
REFERENCES {qualified_to}(id),
152-
CONSTRAINT uniq_{schema_name}_{join_table}
153-
UNIQUE ({from_table}_id, {to_table}_id)
154-
);
155-
""".strip()
226+
CREATE TABLE IF NOT EXISTS {qualified_join} (
227+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
228+
{from_table}_id UUID NOT NULL,
229+
{to_table}_id UUID NOT NULL,
230+
CONSTRAINT fk_{schema_name}_{join_table}_{from_table}
231+
FOREIGN KEY ({from_table}_id)
232+
REFERENCES {qualified_from}(id),
233+
CONSTRAINT fk_{schema_name}_{join_table}_{to_table}
234+
FOREIGN KEY ({to_table}_id)
235+
REFERENCES {qualified_to}(id),
236+
CONSTRAINT uniq_{schema_name}_{join_table}
237+
UNIQUE ({from_table}_id, {to_table}_id)
238+
);
239+
""".strip()
156240
else:
157241
sql = f"-- TODO: implement SQL for relationship {mig_name}"
158242

@@ -167,4 +251,4 @@ def create_migrations(
167251
existing_names.add(mig_name)
168252
next_seq += 1
169253

170-
return new_migrations
254+
return new_migrations

0 commit comments

Comments
 (0)