Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
116 changes: 116 additions & 0 deletions tests/migrations/test_schema_editor_sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
47 changes: 32 additions & 15 deletions tortoise/migrations/schema_editor/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
Loading