@@ -196,6 +196,65 @@ def test_upgrade_scenario_basic_to_current(self) -> None:
196196 assert "password_reset_token" in added_table_names
197197 assert "email_verification_token" in added_table_names
198198
199+ def test_fk_ref_columns_resolved_in_diff_context (self ) -> None :
200+ """FK referenced columns are accessible from add_table diffs."""
201+ engine = create_engine ("sqlite:///:memory:" )
202+
203+ # Create only the user table (referenced by FK)
204+ old_metadata = sa .MetaData ()
205+ sa .Table (
206+ "user" ,
207+ old_metadata ,
208+ sa .Column ("id" , sa .Integer (), primary_key = True ),
209+ sa .Column ("email" , sa .String (), nullable = False ),
210+ )
211+ old_metadata .create_all (engine )
212+
213+ # New metadata adds a table with FK to user
214+ new_metadata = sa .MetaData ()
215+ sa .Table (
216+ "user" ,
217+ new_metadata ,
218+ sa .Column ("id" , sa .Integer (), primary_key = True ),
219+ sa .Column ("email" , sa .String (), nullable = False ),
220+ )
221+ sa .Table (
222+ "password_reset_token" ,
223+ new_metadata ,
224+ sa .Column ("id" , sa .Integer (), primary_key = True ),
225+ sa .Column (
226+ "user_id" ,
227+ sa .Integer (),
228+ sa .ForeignKey ("user.id" ),
229+ nullable = False ,
230+ ),
231+ sa .Column ("token" , sa .String (), nullable = False ),
232+ )
233+
234+ with engine .connect () as conn :
235+ migration_ctx = MigrationContext .configure (conn )
236+ diffs = compare_metadata (migration_ctx , new_metadata )
237+
238+ # Get the add_table diff
239+ add_table_diffs = [d for d in diffs if d [0 ] == "add_table" ]
240+ assert len (add_table_diffs ) == 1
241+
242+ table = add_table_diffs [0 ][1 ]
243+ assert table .name == "password_reset_token"
244+
245+ # Verify FK ref columns are resolvable (the bug was empty ref_cols)
246+ fks = list (table .foreign_key_constraints )
247+ assert len (fks ) == 1
248+ fk = fks [0 ]
249+
250+ # Try element-based extraction (our fix)
251+ ref_cols = [
252+ el .column .name
253+ for el in fk .elements
254+ if hasattr (el , "column" ) and el .column is not None
255+ ]
256+ assert ref_cols == ["id" ], f"FK ref_cols should be ['id'], got { ref_cols } "
257+
199258
200259class TestMigrateFixMigrationRendering :
201260 """Test that add_table diffs render correct migration content."""
@@ -210,7 +269,7 @@ def _sa_type_str(col_type: object) -> str:
210269 upgrade_lines : list [str ] = []
211270 downgrade_lines : list [str ] = []
212271
213- cols = []
272+ items = []
214273 for col in table .columns :
215274 col_str = f"sa.Column('{ col .name } ', { _sa_type_str (col .type )} "
216275 col_str += f", nullable={ col .nullable } "
@@ -219,23 +278,28 @@ def _sa_type_str(col_type: object) -> str:
219278 if col .server_default is not None :
220279 col_str += f", server_default=sa.text('{ col .server_default .arg } ')" # type: ignore[union-attr]
221280 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-
281+ items .append (f" { col_str } ," )
282+ # Inline FK constraints
228283 for fk in table .foreign_key_constraints :
229284 local_cols = [c .name for c in fk .columns ]
230285 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')"
286+ # Match production: prefer fk.elements, fall back to PK
287+ ref_cols = [
288+ el .column .name
289+ for el in fk .elements
290+ if hasattr (el , "column" ) and el .column is not None
291+ ]
292+ if not ref_cols :
293+ ref_cols = [c .name for c in fk .referred_table .primary_key .columns ]
294+ ref_col_strs = [f"'{ ref_table } .{ rc } '" for rc in ref_cols ]
295+ local_col_strs = [f"'{ lc } '" for lc in local_cols ]
296+ items .append (
297+ f" sa.ForeignKeyConstraint([{ ', ' .join (local_col_strs )} ], [{ ', ' .join (ref_col_strs )} ]),"
238298 )
299+ items_block = "\n " .join (items )
300+ upgrade_lines .append (
301+ f" op.create_table(\n '{ table .name } ',\n { items_block } \n )"
302+ )
239303
240304 for idx in table .indexes :
241305 idx_cols = [c .name for c in idx .columns ]
@@ -278,8 +342,8 @@ def test_renders_create_table(self) -> None:
278342 # Downgrade drops the table
279343 assert "op.drop_table('password_reset_token')" in downgrade [- 1 ]
280344
281- def test_renders_foreign_keys (self ) -> None :
282- """add_table diff with FKs renders op.create_foreign_key ."""
345+ def test_renders_foreign_keys_inline (self ) -> None :
346+ """add_table diff with FKs renders inline sa.ForeignKeyConstraint ."""
283347 metadata = sa .MetaData ()
284348 sa .Table (
285349 "user" ,
@@ -302,18 +366,15 @@ def test_renders_foreign_keys(self) -> None:
302366 token_table = metadata .tables ["password_reset_token" ]
303367 upgrade , downgrade = self ._render_add_table_upgrade (token_table )
304368
305- # Should have create_foreign_key
369+ # FK should be inline in create_table, not separate
370+ create_line = upgrade [0 ]
371+ assert "ForeignKeyConstraint" in create_line
372+ assert "'user_id'" in create_line
373+ assert "'user.id'" in create_line
374+
375+ # No separate create_foreign_key calls
306376 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 ]
377+ assert len (fk_lines ) == 0
317378
318379 def test_renders_indexes (self ) -> None :
319380 """add_table diff with indexes renders op.create_index."""
0 commit comments