Skip to content

Commit cb0c8aa

Browse files
authored
Merge pull request #94 from dan-wilton/foreignkeys/bugfix-safer-add-field-foreign-key-explicit-to-field
Fix: Fix `to_field` being ignored for `SaferAddFieldForeignKey` migrations
2 parents 5c2cbaf + ec336d5 commit cb0c8aa

File tree

5 files changed

+116
-6
lines changed

5 files changed

+116
-6
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
1010

1111
_No notable unreleased changes_
1212

13+
## [0.1.23] - 2025-11-18
14+
15+
### Fixed
16+
17+
- Fixed a bug where `SaferAddFieldForeignKey` ignored the `ForeignKey` `to_field`
18+
parameter, resulting in an incorrect column type and incorrect primary key reference.
19+
1320
## [0.1.22] - 2025-08-07
1421

1522
### Fixed
@@ -228,7 +235,8 @@ _No notable unreleased changes_
228235
- `SaferAddIndexConcurrently` migration operation to create new Postgres
229236
indexes in a safer, idempotent way.
230237

231-
[Unreleased]: https://github.com/octoenergy/django-migration-helpers/compare/v0.1.22...HEAD
238+
[Unreleased]: https://github.com/octoenergy/django-migration-helpers/compare/v0.1.23...HEAD
239+
[0.1.23]: https://github.com/octoenergy/django-migration-helpers/compare/v0.1.22...v0.1.23
232240
[0.1.22]: https://github.com/octoenergy/django-migration-helpers/compare/v0.1.21...v0.1.22
233241
[0.1.21]: https://github.com/octoenergy/django-migration-helpers/compare/v0.1.20...v0.1.21
234242
[0.1.20]: https://github.com/kraken-tech/django-pg-migration-tools/compare/v0.1.19...v0.1.20

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ where = ["src"]
2121

2222
[project]
2323
name = "django_pg_migration_tools"
24-
version = "0.1.22"
24+
version = "0.1.23"
2525
description = "Tools for making Django migrations safer and more scalable."
2626
license.file = "LICENSE"
2727
readme = "README.md"
@@ -175,7 +175,7 @@ exclude_also = [
175175

176176
[tool.bumpversion]
177177
# Do not manually edit the version, use `make version_{type}` instead.
178-
current_version = "0.1.22"
178+
current_version = "0.1.23"
179179

180180
# Relabel the Unreleased section of the changelog and add a new unreleased section as a reminder to
181181
# add to it.

src/django_pg_migration_tools/operations.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1306,8 +1306,26 @@ def _get_remote_pk_field(self) -> models.Field[Any, Any]:
13061306
assert isinstance(pk_field, models.Field)
13071307
return pk_field
13081308

1309+
def _get_remote_to_field(self) -> models.Field[Any, Any]:
1310+
to_field = self.field.to_fields[0]
1311+
remote_model = self._get_remote_model()
1312+
1313+
remote_field = next(
1314+
field for field in remote_model._meta.get_fields() if field.name == to_field
1315+
)
1316+
assert isinstance(remote_field, models.Field)
1317+
return remote_field
1318+
1319+
def _get_target_field(self) -> models.Field[Any, Any]:
1320+
# If to_field is specified, we don't want to default to using the pk.
1321+
if self.field.to_fields and self.field.to_fields[0]:
1322+
target_field = self._get_remote_to_field()
1323+
else:
1324+
target_field = self._get_remote_pk_field()
1325+
return target_field
1326+
13091327
def _get_column_type(self) -> str:
1310-
remote_field = self._get_remote_pk_field()
1328+
remote_field = self._get_target_field()
13111329
column_type: str | None = remote_field.db_type(self.schema_editor.connection)
13121330
assert column_type is not None
13131331
return column_type
@@ -1384,8 +1402,8 @@ def _is_constraint_valid(self) -> bool:
13841402

13851403
def _alter_table_add_not_valid_fk(self) -> None:
13861404
remote_model = self._get_remote_model()
1387-
remote_pk_field = self._get_remote_pk_field()
1388-
referred_column_name = remote_pk_field.db_column or remote_pk_field.name
1405+
remote_target_field = self._get_target_field()
1406+
referred_column_name = remote_target_field.db_column or remote_target_field.name
13891407
self.schema_editor.execute(
13901408
psycopg_sql.SQL(ConstraintQueries.ALTER_TABLE_ADD_NOT_VALID_FK)
13911409
.format(

tests/django_pg_migration_tools/test_operations.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
NullIntFieldModel,
2929
UniqueConditionCharModel,
3030
UniqueExpressionCharModel,
31+
UUIDFieldModel,
3132
get_check_constraint,
3233
)
3334

@@ -3789,6 +3790,77 @@ def test_operation_when_related_model_has_explicit_pk_field(self):
37893790
AND attname = 'other_int_model_field_id';
37903791
""")
37913792

3793+
@pytest.mark.django_db(transaction=True)
3794+
def test_operation_when_has_explicit_non_pk_to_field(self):
3795+
project_state = ProjectState()
3796+
project_state.add_model(ModelState.from_model(IntModel))
3797+
project_state.add_model(ModelState.from_model(UUIDFieldModel))
3798+
new_state = project_state.clone()
3799+
3800+
# Relate the IntModel -> UUIDFieldModel by UUIDFieldModel.uuid_field relationship.
3801+
operation = operations.SaferAddFieldForeignKey(
3802+
model_name="intmodel",
3803+
name="uuid_model_uuid_field",
3804+
field=models.ForeignKey(
3805+
"example_app.UUIDFieldModel",
3806+
null=True,
3807+
on_delete=models.CASCADE,
3808+
db_index=False,
3809+
to_field="uuid_field", # Do not default to the Primary Key field.
3810+
),
3811+
)
3812+
3813+
operation.state_forwards(self.app_label, new_state)
3814+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
3815+
with utils.CaptureQueriesContext(connection) as queries:
3816+
operation.database_forwards(
3817+
self.app_label, editor, from_state=project_state, to_state=new_state
3818+
)
3819+
3820+
assert len(queries) == 4
3821+
assert queries[0]["sql"] == dedent("""
3822+
SELECT 1
3823+
FROM pg_catalog.pg_attribute
3824+
WHERE
3825+
attrelid = 'example_app_intmodel'::regclass
3826+
AND attname = 'uuid_model_uuid_field_id';
3827+
""")
3828+
assert queries[1]["sql"] == dedent("""
3829+
ALTER TABLE "example_app_intmodel"
3830+
ADD COLUMN IF NOT EXISTS "uuid_model_uuid_field_id"
3831+
uuid NULL;
3832+
""")
3833+
assert queries[2]["sql"] == dedent("""
3834+
ALTER TABLE "example_app_intmodel"
3835+
ADD CONSTRAINT "example_app_intmodel_uuid_model_uuid_field_id_fk" FOREIGN KEY ("uuid_model_uuid_field_id")
3836+
REFERENCES "example_app_uuidfieldmodel" ("uuid_field")
3837+
DEFERRABLE INITIALLY DEFERRED
3838+
NOT VALID;
3839+
""")
3840+
assert queries[3]["sql"] == dedent("""
3841+
ALTER TABLE "example_app_intmodel"
3842+
VALIDATE CONSTRAINT "example_app_intmodel_uuid_model_uuid_field_id_fk";
3843+
""")
3844+
3845+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
3846+
with utils.CaptureQueriesContext(connection) as reverse_queries:
3847+
operation.database_backwards(
3848+
self.app_label, editor, from_state=new_state, to_state=project_state
3849+
)
3850+
3851+
assert len(reverse_queries) == 2
3852+
assert reverse_queries[0]["sql"] == dedent("""
3853+
SELECT 1
3854+
FROM pg_catalog.pg_attribute
3855+
WHERE
3856+
attrelid = 'example_app_intmodel'::regclass
3857+
AND attname = 'uuid_model_uuid_field_id';
3858+
""")
3859+
assert reverse_queries[1]["sql"] == dedent("""
3860+
ALTER TABLE "example_app_intmodel"
3861+
DROP COLUMN "uuid_model_uuid_field_id";
3862+
""")
3863+
37923864

37933865
class TestSaferAddCheckConstraint:
37943866
app_label = "example_app"

tests/example_app/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ class CharIDModel(models.Model):
9595
id = models.CharField(max_length=42, primary_key=True)
9696

9797

98+
class UUIDFieldModel(models.Model):
99+
uuid_field = models.UUIDField()
100+
101+
class Meta:
102+
constraints = (
103+
models.UniqueConstraint(
104+
fields=["uuid_field"],
105+
name="unique_uuid_field",
106+
),
107+
)
108+
109+
98110
class ModelWithCheckConstraint(models.Model):
99111
class Meta:
100112
constraints = (

0 commit comments

Comments
 (0)