Skip to content

Commit 53d9596

Browse files
committed
Honor db_constraint=False in migration schema editor
The migration schema editor always emitted the FOREIGN KEY reference for ForeignKeyField/ManyToManyField regardless of db_constraint, unlike the runtime schema generator. Guard FK emission in _get_fk_field_definition on fk_field.db_constraint, and add a no-constraint M2M through-table template, mirroring schema_generator. The plain column (and through table) are still emitted; only the FK constraint is dropped when db_constraint=False.
1 parent bc4f971 commit 53d9596

3 files changed

Lines changed: 149 additions & 15 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Fixed
2525
- ``MigrationRecorder`` no longer emits tortoise's own ``pk`` field ``DeprecationWarning`` when applying migrations; it now builds its bookkeeping model with ``primary_key=True``. (#2203)
2626
- ``QuerySet.count()`` now matches the limited query result for the LIMIT/OFFSET edge cases: it returns ``0`` (instead of a negative number) when ``offset()`` exceeds the total row count, and ``0`` (instead of the total) for ``limit(0)``. (#2208)
2727
- Field declarations on models now resolve to their concrete type (e.g. ``CharField[str]``) in Pyright/Pylance instead of ``Field[Unknown]``; the ``Field.__new__`` type-check stub now returns ``Self``. (#2216)
28+
- Migrations now honor ``db_constraint=False`` on ``ForeignKeyField``/``ManyToManyField``: the generated DDL emits the plain column (and through table) without a ``FOREIGN KEY`` constraint, matching the runtime schema generator. Previously the migration schema editor always emitted the FK reference regardless of the flag. (#2223)
2829

2930
1.1.7
3031
-----

tests/migrations/test_schema_editor_sql.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,122 @@ class Meta:
251251
assert "DEFAULT 'active'" in sql
252252

253253

254+
@pytest.mark.asyncio
255+
async def test_create_model_skips_fk_constraint_when_db_constraint_false() -> None:
256+
"""CreateModel must not emit a FOREIGN KEY constraint for ``db_constraint=False`` FKs.
257+
258+
Regression test for #2223: migrations ignored ``db_constraint=False`` and always
259+
emitted the FK reference, unlike the runtime schema generator.
260+
"""
261+
262+
class Parent(Model):
263+
id = fields.IntField(primary_key=True)
264+
265+
class Meta:
266+
table = "parent"
267+
app = "models"
268+
269+
class Child(Model):
270+
id = fields.IntField(primary_key=True)
271+
parent: fields.ForeignKeyRelation[Parent] = fields.ForeignKeyField(
272+
"models.Parent", db_constraint=False, related_name="children"
273+
)
274+
275+
class Meta:
276+
table = "child"
277+
app = "models"
278+
279+
init_apps(Parent, Child)
280+
281+
client = FakeClient("sql")
282+
editor = TestSchemaEditor(client)
283+
284+
await editor.create_model(Child)
285+
286+
assert len(client.executed) == 1
287+
sql = client.executed[0]
288+
assert 'CREATE TABLE "child"' in sql
289+
# The FK column itself is still created ...
290+
assert '"parent_id" INT' in sql
291+
# ... but without any physical FK constraint.
292+
assert "REFERENCES" not in sql
293+
assert "FOREIGN KEY" not in sql
294+
295+
296+
@pytest.mark.asyncio
297+
async def test_add_field_skips_fk_constraint_when_db_constraint_false() -> None:
298+
"""add_field must not emit a FOREIGN KEY constraint for ``db_constraint=False`` FKs."""
299+
300+
class Parent(Model):
301+
id = fields.IntField(primary_key=True)
302+
303+
class Meta:
304+
table = "parent"
305+
app = "models"
306+
307+
class Child(Model):
308+
id = fields.IntField(primary_key=True)
309+
parent: fields.ForeignKeyRelation[Parent] = fields.ForeignKeyField(
310+
"models.Parent", db_constraint=False, related_name="children"
311+
)
312+
313+
class Meta:
314+
table = "child"
315+
app = "models"
316+
317+
init_apps(Parent, Child)
318+
319+
client = FakeClient("sql")
320+
editor = TestSchemaEditor(client)
321+
322+
await editor.add_field(Child, "parent")
323+
324+
assert len(client.executed) == 1
325+
sql = client.executed[0]
326+
assert 'ALTER TABLE "child" ADD COLUMN' in sql
327+
assert '"parent_id" INT' in sql
328+
assert "REFERENCES" not in sql
329+
assert "FOREIGN KEY" not in sql
330+
331+
332+
@pytest.mark.asyncio
333+
async def test_create_model_skips_m2m_constraint_when_db_constraint_false() -> None:
334+
"""M2M through table must not emit FOREIGN KEY constraints for ``db_constraint=False``."""
335+
336+
class Parent(Model):
337+
id = fields.IntField(primary_key=True)
338+
339+
class Meta:
340+
table = "parent"
341+
app = "models"
342+
343+
class Child(Model):
344+
id = fields.IntField(primary_key=True)
345+
tags: fields.ManyToManyRelation[Parent] = fields.ManyToManyField(
346+
"models.Parent",
347+
db_constraint=False,
348+
related_name="tagged",
349+
through="child_parent",
350+
)
351+
352+
class Meta:
353+
table = "child"
354+
app = "models"
355+
356+
init_apps(Parent, Child)
357+
358+
client = FakeClient("sql")
359+
editor = TestSchemaEditor(client)
360+
361+
await editor.create_model(Child)
362+
363+
m2m_sql = next(sql for sql in client.executed if 'CREATE TABLE "child_parent"' in sql)
364+
assert '"child_id" INT' in m2m_sql
365+
assert '"parent_id" INT' in m2m_sql
366+
assert "REFERENCES" not in m2m_sql
367+
assert "FOREIGN KEY" not in m2m_sql
368+
369+
254370
@pytest.mark.asyncio
255371
async def test_create_model_includes_db_default_on_fk() -> None:
256372
"""CreateModel should include DEFAULT clause for FK columns with db_default."""

tortoise/migrations/schema_editor/base.py

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ class BaseSchemaEditor(SchemaQuotingMixin):
3131
' ("{forward_field}") ON DELETE CASCADE\n'
3232
"){extra}{comment};"
3333
)
34+
M2M_TABLE_NO_CONSTRAINT_TEMPLATE = (
35+
"CREATE TABLE {table_name} (\n"
36+
' "{backward_key}" {backward_type} NOT NULL,\n'
37+
' "{forward_key}" {forward_type} NOT NULL\n'
38+
"){extra}{comment};"
39+
)
3440
RENAME_TABLE_TEMPLATE = "ALTER TABLE {old_table} RENAME TO {new_table}"
3541
DELETE_TABLE_TEMPLATE = "DROP TABLE {table} CASCADE"
3642
ADD_FIELD_TEMPLATE = "ALTER TABLE {table} ADD COLUMN {definition}"
@@ -232,7 +238,14 @@ def _get_m2m_table_definition(
232238
if not related_model:
233239
return None
234240
m2m_schema = model._meta.schema
235-
m2m_create_string = self.M2M_TABLE_TEMPLATE.format(
241+
# Respect db_constraint=False: build the through table without FK constraints,
242+
# mirroring the runtime schema generator (see base/schema_generator.py).
243+
template = (
244+
self.M2M_TABLE_TEMPLATE
245+
if field.db_constraint
246+
else self.M2M_TABLE_NO_CONSTRAINT_TEMPLATE
247+
)
248+
m2m_create_string = template.format(
236249
table_name=self._qualify_table_name(field.through, m2m_schema),
237250
backward_table=self._qualify_table_name(model._meta.db_table, model._meta.schema),
238251
forward_table=self._qualify_table_name(
@@ -290,21 +303,25 @@ def _get_fk_field_definition(self, model: type[Model], key_field_name: str) -> s
290303
unique=key_field.unique,
291304
is_pk=key_field.pk,
292305
comment="",
293-
) + self._get_fk_reference_string(
294-
constraint_name=self._generate_fk_name(
295-
model._meta.db_table,
296-
db_field,
297-
related_model._meta.db_table,
298-
to_field_name,
299-
),
300-
db_field=db_field,
301-
table=self._qualify_table_name(
302-
related_model._meta.db_table, related_model._meta.schema
303-
),
304-
field=to_field_name,
305-
on_delete=fk_field.on_delete,
306-
comment=comment,
307306
)
307+
# Respect db_constraint=False: emit the plain column without a FK constraint,
308+
# mirroring the runtime schema generator (see base/schema_generator.py).
309+
if fk_field.db_constraint:
310+
field_creation_string += self._get_fk_reference_string(
311+
constraint_name=self._generate_fk_name(
312+
model._meta.db_table,
313+
db_field,
314+
related_model._meta.db_table,
315+
to_field_name,
316+
),
317+
db_field=db_field,
318+
table=self._qualify_table_name(
319+
related_model._meta.db_table, related_model._meta.schema
320+
),
321+
field=to_field_name,
322+
on_delete=fk_field.on_delete,
323+
comment=comment,
324+
)
308325
return field_creation_string
309326

310327
def _get_model_sql_data(self, model: type[Model]) -> ModelSqlData:

0 commit comments

Comments
 (0)