@@ -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+
2449def 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