diff --git a/CHANGELOG.rst b/CHANGELOG.rst index be14099cd..0addb8af9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,7 @@ Fixed - ``MigrationRecorder`` no longer emits tortoise's own ``pk`` field ``DeprecationWarning`` when applying migrations; it now builds its bookkeeping model with ``primary_key=True``. (#2203) - ``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) - 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) +- 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) 1.1.7 ----- diff --git a/tests/migrations/test_schema_editor_sql.py b/tests/migrations/test_schema_editor_sql.py index 67a9dac31..f66c442e6 100644 --- a/tests/migrations/test_schema_editor_sql.py +++ b/tests/migrations/test_schema_editor_sql.py @@ -251,6 +251,122 @@ class Meta: assert "DEFAULT 'active'" in sql +@pytest.mark.asyncio +async def test_create_model_skips_fk_constraint_when_db_constraint_false() -> None: + """CreateModel must not emit a FOREIGN KEY constraint for ``db_constraint=False`` FKs. + + Regression test for #2223: migrations ignored ``db_constraint=False`` and always + emitted the FK reference, unlike the runtime schema generator. + """ + + class Parent(Model): + id = fields.IntField(primary_key=True) + + class Meta: + table = "parent" + app = "models" + + class Child(Model): + id = fields.IntField(primary_key=True) + parent: fields.ForeignKeyRelation[Parent] = fields.ForeignKeyField( + "models.Parent", db_constraint=False, related_name="children" + ) + + class Meta: + table = "child" + app = "models" + + init_apps(Parent, Child) + + client = FakeClient("sql") + editor = TestSchemaEditor(client) + + await editor.create_model(Child) + + assert len(client.executed) == 1 + sql = client.executed[0] + assert 'CREATE TABLE "child"' in sql + # The FK column itself is still created ... + assert '"parent_id" INT' in sql + # ... but without any physical FK constraint. + assert "REFERENCES" not in sql + assert "FOREIGN KEY" not in sql + + +@pytest.mark.asyncio +async def test_add_field_skips_fk_constraint_when_db_constraint_false() -> None: + """add_field must not emit a FOREIGN KEY constraint for ``db_constraint=False`` FKs.""" + + class Parent(Model): + id = fields.IntField(primary_key=True) + + class Meta: + table = "parent" + app = "models" + + class Child(Model): + id = fields.IntField(primary_key=True) + parent: fields.ForeignKeyRelation[Parent] = fields.ForeignKeyField( + "models.Parent", db_constraint=False, related_name="children" + ) + + class Meta: + table = "child" + app = "models" + + init_apps(Parent, Child) + + client = FakeClient("sql") + editor = TestSchemaEditor(client) + + await editor.add_field(Child, "parent") + + assert len(client.executed) == 1 + sql = client.executed[0] + assert 'ALTER TABLE "child" ADD COLUMN' in sql + assert '"parent_id" INT' in sql + assert "REFERENCES" not in sql + assert "FOREIGN KEY" not in sql + + +@pytest.mark.asyncio +async def test_create_model_skips_m2m_constraint_when_db_constraint_false() -> None: + """M2M through table must not emit FOREIGN KEY constraints for ``db_constraint=False``.""" + + class Parent(Model): + id = fields.IntField(primary_key=True) + + class Meta: + table = "parent" + app = "models" + + class Child(Model): + id = fields.IntField(primary_key=True) + tags: fields.ManyToManyRelation[Parent] = fields.ManyToManyField( + "models.Parent", + db_constraint=False, + related_name="tagged", + through="child_parent", + ) + + class Meta: + table = "child" + app = "models" + + init_apps(Parent, Child) + + client = FakeClient("sql") + editor = TestSchemaEditor(client) + + await editor.create_model(Child) + + m2m_sql = next(sql for sql in client.executed if 'CREATE TABLE "child_parent"' in sql) + assert '"child_id" INT' in m2m_sql + assert '"parent_id" INT' in m2m_sql + assert "REFERENCES" not in m2m_sql + assert "FOREIGN KEY" not in m2m_sql + + @pytest.mark.asyncio async def test_create_model_includes_db_default_on_fk() -> None: """CreateModel should include DEFAULT clause for FK columns with db_default.""" diff --git a/tortoise/migrations/schema_editor/base.py b/tortoise/migrations/schema_editor/base.py index 4dda5ec91..46492fd60 100644 --- a/tortoise/migrations/schema_editor/base.py +++ b/tortoise/migrations/schema_editor/base.py @@ -31,6 +31,12 @@ class BaseSchemaEditor(SchemaQuotingMixin): ' ("{forward_field}") ON DELETE CASCADE\n' "){extra}{comment};" ) + M2M_TABLE_NO_CONSTRAINT_TEMPLATE = ( + "CREATE TABLE {table_name} (\n" + ' "{backward_key}" {backward_type} NOT NULL,\n' + ' "{forward_key}" {forward_type} NOT NULL\n' + "){extra}{comment};" + ) RENAME_TABLE_TEMPLATE = "ALTER TABLE {old_table} RENAME TO {new_table}" DELETE_TABLE_TEMPLATE = "DROP TABLE {table} CASCADE" ADD_FIELD_TEMPLATE = "ALTER TABLE {table} ADD COLUMN {definition}" @@ -232,7 +238,14 @@ def _get_m2m_table_definition( if not related_model: return None m2m_schema = model._meta.schema - m2m_create_string = self.M2M_TABLE_TEMPLATE.format( + # Respect db_constraint=False: build the through table without FK constraints, + # mirroring the runtime schema generator (see base/schema_generator.py). + template = ( + self.M2M_TABLE_TEMPLATE + if field.db_constraint + else self.M2M_TABLE_NO_CONSTRAINT_TEMPLATE + ) + m2m_create_string = template.format( table_name=self._qualify_table_name(field.through, m2m_schema), backward_table=self._qualify_table_name(model._meta.db_table, model._meta.schema), forward_table=self._qualify_table_name( @@ -290,21 +303,25 @@ def _get_fk_field_definition(self, model: type[Model], key_field_name: str) -> s unique=key_field.unique, is_pk=key_field.pk, comment="", - ) + self._get_fk_reference_string( - constraint_name=self._generate_fk_name( - model._meta.db_table, - db_field, - related_model._meta.db_table, - to_field_name, - ), - db_field=db_field, - table=self._qualify_table_name( - related_model._meta.db_table, related_model._meta.schema - ), - field=to_field_name, - on_delete=fk_field.on_delete, - comment=comment, ) + # Respect db_constraint=False: emit the plain column without a FK constraint, + # mirroring the runtime schema generator (see base/schema_generator.py). + if fk_field.db_constraint: + field_creation_string += self._get_fk_reference_string( + constraint_name=self._generate_fk_name( + model._meta.db_table, + db_field, + related_model._meta.db_table, + to_field_name, + ), + db_field=db_field, + table=self._qualify_table_name( + related_model._meta.db_table, related_model._meta.schema + ), + field=to_field_name, + on_delete=fk_field.on_delete, + comment=comment, + ) return field_creation_string def _get_model_sql_data(self, model: type[Model]) -> ModelSqlData: