Skip to content

Commit f644eb9

Browse files
committed
Refs #60 -- Adjust pre-RemoveField(db_default) for ForeignKey with callable defaults.
Just like pre-AddField the get_default() call have to be adjusted to account for unbounded fields. Thanks to @alitycast for the report.
1 parent 6614c7d commit f644eb9

3 files changed

Lines changed: 51 additions & 20 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
- Address crash of system checks when an installed app has an empty
77
`migrations` directory. (#62)
8+
- Address a ``makemigration`` crash when removing a ``ForeignKey`` with a
9+
``default`` on Django 5.2+. (#60)
810

911
1.2.1
1012
=====

syzygy/operations.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ def describe(self):
122122
return "Set field %s of %s NULLable" % (self.name, self.model_name)
123123

124124

125+
def _get_field_default(field):
126+
if isinstance(field, models.ForeignKey):
127+
# XXX: Replicate ForeignKey.get_default() logic in a way that
128+
# doesn't require the field references to be pre-emptively
129+
# resolved. This will need to be fixed upstream if we ever
130+
# implement model state schema alterations.
131+
# See https://code.djangoproject.com/ticket/29898
132+
field_default = super(models.ForeignKey, field).get_default()
133+
if (
134+
isinstance(field_default, models.Model)
135+
and field_default._meta.label_lower == field.related_model.lower()
136+
):
137+
target_field = field.to_fields[0] or "pk"
138+
field_default = getattr(field_default, target_field)
139+
else:
140+
field_default = field.get_default()
141+
return field_default
142+
143+
125144
if field_db_default_supported:
126145

127146
def get_pre_remove_field_operation(model_name, name, field):
@@ -131,7 +150,7 @@ def get_pre_remove_field_operation(model_name, name, field):
131150
)
132151
field = field.clone()
133152
if field.has_default():
134-
field.db_default = field.get_default()
153+
field.db_default = _get_field_default(field)
135154
fragment = f"set_db_default_{model_name.lower()}_{name}"
136155
description = f"Set database DEFAULT of field {name} on {model_name}"
137156
else:
@@ -202,22 +221,7 @@ def get_pre_add_field_operation(model_name, name, field, preserve_default=True):
202221
"Fields with a db_default don't require a pre-deployment operation."
203222
)
204223
field = field.clone()
205-
if isinstance(field, models.ForeignKey):
206-
# XXX: Replicate ForeignKey.get_default() logic in a way that
207-
# doesn't require the field references to be pre-emptively
208-
# resolved. This will need to be fixed upstream if we ever
209-
# implement model state schema alterations.
210-
# See https://code.djangoproject.com/ticket/29898
211-
field_default = super(models.ForeignKey, field).get_default()
212-
if (
213-
isinstance(field_default, models.Model)
214-
and field_default._meta.label_lower == field.related_model.lower()
215-
):
216-
target_field = field.to_fields[0] or "pk"
217-
field_default = getattr(field_default, target_field)
218-
else:
219-
field_default = field.get_default()
220-
field.db_default = field_default
224+
field.db_default = _get_field_default(field)
221225
operation = operations.AddField(model_name, name, field, preserve_default)
222226
return operation
223227

tests/test_autodetector.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ def test_db_default_field_addition(self):
153153
self.assertEqual(len(changes), 1)
154154
self.assertEqual(get_migration_stage(changes[0]), Stage.PRE_DEPLOY)
155155

156-
def _test_field_removal(self, field):
156+
def _test_field_removal(self, field, expected_db_default=None):
157157
from_model = ModelState("tests", "Model", [("field", field)])
158158
to_model = ModelState("tests", "Model", [])
159159
changes = self.get_changes([from_model], [to_model])["tests"]
@@ -165,7 +165,9 @@ def _test_field_removal(self, field):
165165
if field_db_default_supported:
166166
self.assertIsInstance(pre_operation, migrations.AlterField)
167167
if field.has_default():
168-
self.assertEqual(pre_operation.field.db_default, 42)
168+
self.assertEqual(
169+
pre_operation.field.db_default, expected_db_default or 42
170+
)
169171
else:
170172
self.assertIs(pre_operation.field.null, True)
171173
else:
@@ -182,10 +184,33 @@ def test_field_removal(self):
182184
models.IntegerField(),
183185
models.IntegerField(default=42),
184186
models.IntegerField(null=True, default=42),
187+
models.IntegerField(null=True, default=lambda: 42),
188+
# Foreign keys with callable defaults should have their associated
189+
# db_default generated with care.
190+
(models.ForeignKey("tests.Model", models.CASCADE, default=42), 42),
191+
(
192+
models.ForeignKey(
193+
"tests.Bar", models.CASCADE, default=lambda: Bar(id=42)
194+
),
195+
42,
196+
),
197+
(
198+
models.ForeignKey(
199+
"tests.bar",
200+
models.CASCADE,
201+
to_field="name",
202+
default=lambda: Bar(id=123, name="bar"),
203+
),
204+
"bar",
205+
),
185206
]
186207
for field in fields:
208+
if isinstance(field, tuple):
209+
field, expected_db_default = field
210+
else:
211+
expected_db_default = None
187212
with self.subTest(field=field):
188-
self._test_field_removal(field)
213+
self._test_field_removal(field, expected_db_default)
189214

190215
def test_many_to_many_removal(self):
191216
from_model = ModelState(

0 commit comments

Comments
 (0)