Skip to content

Commit 17c27fd

Browse files
committed
Fix SaferRemoveFieldForeignKey for non null columns
Prior to this change, if the FK field being removed had a null=False set up, this operation would fail: ValueError: Can't safely create a FK field with null=False While this error is relevant when the FK manager is *creating* a new column, it is not relevant when deleting one. This commit changes that by skipping the nullability check when running from SaferRemoveFieldForeignKey. A note was dropped on the documentation to alert that the field will be created with null=True when reversing the operation, since the alternative wouldn't be safe.
1 parent fc0978c commit 17c27fd

File tree

5 files changed

+145
-2
lines changed

5 files changed

+145
-2
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
88

99
## [Unreleased]
1010

11-
_No notable unreleased changes_
11+
### Fixed
12+
13+
- Fixed a bug where using `SaferRemoveFieldForeignKey` on a field that had
14+
null=False was raising an error. This shouldn't be the case as the
15+
nullability of the field is not important when removing the FK field.
1216

1317
## [0.1.21] - 2025-07-07
1418

docs/guides/migration_guides.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -830,6 +830,8 @@ fixes those problems by:
830830
- Having a custom backward operation that will add the foreign key back
831831
without blocking any reads/writes. This is achieved through the same
832832
strategy of :ref:`SaferAddFieldForeignKey <safer_add_field_foreign_key>`.
833+
Note that if the original column was non-nullable, the reversal will create
834+
the field as nullable to avoid outages.
833835

834836
.. _guide_how_to_use_safer_remove_field_foreign_key:
835837

src/django_pg_migration_tools/operations.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,8 +1156,9 @@ def __init__(
11561156
column_name: str,
11571157
field: models.ForeignKey[models.Model],
11581158
unique: bool,
1159+
skip_null_check: bool = False,
11591160
) -> None:
1160-
if not field.null:
1161+
if not field.null and not skip_null_check:
11611162
# Validate at initialisation, rather than wasting time later.
11621163
raise ValueError("Can't safely create a FK field with null=False")
11631164

@@ -1486,6 +1487,7 @@ def database_forwards(
14861487
column_name=self.name,
14871488
field=field,
14881489
unique=False,
1490+
skip_null_check=True,
14891491
).drop_fk_field()
14901492

14911493
def database_backwards(
@@ -1508,6 +1510,7 @@ def database_backwards(
15081510
column_name=self.name,
15091511
field=field,
15101512
unique=False,
1513+
skip_null_check=True,
15111514
).add_fk_field()
15121515

15131516
def describe(self) -> str:

tests/django_pg_migration_tools/test_operations.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
IntModelWithExplicitPK,
2323
ModelWithCheckConstraint,
2424
ModelWithForeignKey,
25+
ModelWithNotNullForeignKey,
2526
NotNullIntFieldModel,
2627
NullFKFieldModel,
2728
NullIntFieldModel,
@@ -2586,6 +2587,135 @@ def test_operation(self):
25862587
AND convalidated IS TRUE;
25872588
""")
25882589

2590+
@pytest.mark.django_db(transaction=True)
2591+
def test_when_column_not_null(self):
2592+
with connection.cursor() as cursor:
2593+
# Set the lock_timeout to check it has been returned to
2594+
# its original value once the fk index creation is completed by
2595+
# the reverse operation.
2596+
cursor.execute(_SET_LOCK_TIMEOUT)
2597+
2598+
project_state = ProjectState()
2599+
project_state.add_model(ModelState.from_model(IntModel))
2600+
project_state.add_model(ModelState.from_model(ModelWithNotNullForeignKey))
2601+
new_state = project_state.clone()
2602+
operation = operations.SaferRemoveFieldForeignKey(
2603+
model_name="modelwithnotnullforeignkey",
2604+
name="fk",
2605+
)
2606+
2607+
assert operation.describe() == (
2608+
"Remove field fk from modelwithnotnullforeignkey. Note: Using "
2609+
"django_pg_migration_tools SaferRemoveFieldForeignKey operation."
2610+
)
2611+
2612+
operation.state_forwards(self.app_label, new_state)
2613+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
2614+
with utils.CaptureQueriesContext(connection) as queries:
2615+
operation.database_forwards(
2616+
self.app_label, editor, from_state=project_state, to_state=new_state
2617+
)
2618+
2619+
assert len(queries) == 2
2620+
2621+
assert queries[0]["sql"] == dedent("""
2622+
SELECT 1
2623+
FROM pg_catalog.pg_attribute
2624+
WHERE
2625+
attrelid = 'example_app_modelwithnotnullforeignkey'::regclass
2626+
AND attname = 'fk_id';
2627+
""")
2628+
assert queries[1]["sql"] == dedent("""
2629+
ALTER TABLE "example_app_modelwithnotnullforeignkey"
2630+
DROP COLUMN "fk_id";
2631+
""")
2632+
2633+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
2634+
with utils.CaptureQueriesContext(connection) as reverse_queries:
2635+
operation.database_backwards(
2636+
self.app_label, editor, from_state=new_state, to_state=project_state
2637+
)
2638+
2639+
assert len(reverse_queries) == 9
2640+
2641+
assert reverse_queries[0]["sql"] == dedent("""
2642+
SELECT 1
2643+
FROM pg_catalog.pg_attribute
2644+
WHERE
2645+
attrelid = 'example_app_modelwithnotnullforeignkey'::regclass
2646+
AND attname = 'fk_id';
2647+
""")
2648+
assert reverse_queries[1]["sql"] == dedent("""
2649+
ALTER TABLE "example_app_modelwithnotnullforeignkey"
2650+
ADD COLUMN IF NOT EXISTS "fk_id"
2651+
integer NULL;
2652+
""")
2653+
assert reverse_queries[2]["sql"] == "SHOW lock_timeout;"
2654+
assert reverse_queries[3]["sql"] == "SET lock_timeout = '0';"
2655+
assert reverse_queries[4]["sql"] == dedent("""
2656+
SELECT relname
2657+
FROM pg_class, pg_index
2658+
WHERE (
2659+
pg_index.indisvalid = false
2660+
AND pg_index.indexrelid = pg_class.oid
2661+
AND relname = 'modelwithnotnullforeignkey_fk_id_idx'
2662+
);
2663+
""")
2664+
assert (
2665+
reverse_queries[5]["sql"]
2666+
== 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "modelwithnotnullforeignkey_fk_id_idx" ON "example_app_modelwithnotnullforeignkey" ("fk_id");'
2667+
)
2668+
assert reverse_queries[6]["sql"] == "SET lock_timeout = '1s';"
2669+
assert reverse_queries[7]["sql"] == dedent("""
2670+
ALTER TABLE "example_app_modelwithnotnullforeignkey"
2671+
ADD CONSTRAINT "example_app_modelwithnotnullforeignkey_fk_id_fk" FOREIGN KEY ("fk_id")
2672+
REFERENCES "example_app_intmodel" ("id")
2673+
DEFERRABLE INITIALLY DEFERRED
2674+
NOT VALID;
2675+
""")
2676+
assert reverse_queries[8]["sql"] == dedent("""
2677+
ALTER TABLE "example_app_modelwithnotnullforeignkey"
2678+
VALIDATE CONSTRAINT "example_app_modelwithnotnullforeignkey_fk_id_fk";
2679+
""")
2680+
2681+
# Reversing again does nothing apart from checking that the FK is
2682+
# already there and the index/constraint are all good to go.
2683+
# This proves the OP is idempotent.
2684+
with connection.schema_editor(atomic=False, collect_sql=False) as editor:
2685+
with utils.CaptureQueriesContext(connection) as second_reverse_queries:
2686+
operation.database_backwards(
2687+
self.app_label, editor, from_state=new_state, to_state=project_state
2688+
)
2689+
assert len(second_reverse_queries) == 4
2690+
assert second_reverse_queries[0]["sql"] == dedent("""
2691+
SELECT 1
2692+
FROM pg_catalog.pg_attribute
2693+
WHERE
2694+
attrelid = 'example_app_modelwithnotnullforeignkey'::regclass
2695+
AND attname = 'fk_id';
2696+
""")
2697+
assert second_reverse_queries[1]["sql"] == dedent("""
2698+
SELECT 1
2699+
FROM pg_class, pg_index
2700+
WHERE (
2701+
pg_index.indisvalid = true
2702+
AND pg_index.indexrelid = pg_class.oid
2703+
AND relname = 'modelwithnotnullforeignkey_fk_id_idx'
2704+
);
2705+
""")
2706+
assert second_reverse_queries[2]["sql"] == dedent("""
2707+
SELECT conname
2708+
FROM pg_catalog.pg_constraint
2709+
WHERE conname = 'example_app_modelwithnotnullforeignkey_fk_id_fk';
2710+
""")
2711+
assert second_reverse_queries[3]["sql"] == dedent("""
2712+
SELECT 1
2713+
FROM pg_catalog.pg_constraint
2714+
WHERE
2715+
conname = 'example_app_modelwithnotnullforeignkey_fk_id_fk'
2716+
AND convalidated IS TRUE;
2717+
""")
2718+
25892719
@pytest.mark.django_db(transaction=True)
25902720
def test_when_column_already_deleted(self):
25912721
with connection.cursor() as cursor:

tests/example_app/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class ModelWithForeignKey(models.Model):
3131
fk = models.ForeignKey(IntModel, null=True, on_delete=models.CASCADE)
3232

3333

34+
class ModelWithNotNullForeignKey(models.Model):
35+
fk = models.ForeignKey(IntModel, null=False, on_delete=models.CASCADE)
36+
37+
3438
class CharModel(models.Model):
3539
char_field = models.CharField(default="char")
3640

0 commit comments

Comments
 (0)