Skip to content

Commit cb0c0e8

Browse files
authored
Merge pull request #95 from kraken-tech/s-cooper18/remove-foreign-key-without-state
Allow db only remove foreign key to work as database only
2 parents cb0c8aa + 798e58c commit cb0c0e8

File tree

3 files changed

+173
-6
lines changed

3 files changed

+173
-6
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 `SaferRemoveFieldForeignKey` relied on the Foreign Key also
14+
existing in Django state, even when being performed as a database only operation.
15+
The name and model are already provided as part of the operation.
1216

1317
## [0.1.23] - 2025-11-18
1418

src/django_pg_migration_tools/operations.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, cast, overload
66

77
from django.contrib.postgres import operations as psql_operations
8+
from django.core import exceptions
89
from django.db import migrations, models
910
from django.db.backends import utils as django_backends_utils
1011
from django.db.backends.base import schema as base_schema
@@ -1154,11 +1155,11 @@ def __init__(
11541155
model: type[models.Model],
11551156
model_name: str,
11561157
column_name: str,
1157-
field: models.ForeignKey[models.Model],
1158+
field: models.ForeignKey[models.Model] | None,
11581159
unique: bool,
11591160
skip_null_check: bool = False,
11601161
) -> None:
1161-
if not field.null and not skip_null_check:
1162+
if field is not None and (not field.null and not skip_null_check):
11621163
# Validate at initialisation, rather than wasting time later.
11631164
raise ValueError("Can't safely create a FK field with null=False")
11641165

@@ -1242,6 +1243,7 @@ def add_fk_field(self) -> None:
12421243
# as they would add extra introspection queries unnecessarily.
12431244
self._maybe_create_unique_constraint()
12441245

1246+
assert self.field is not None
12451247
assert hasattr(self.field, "db_index")
12461248
if (
12471249
self.field.db_index
@@ -1289,6 +1291,7 @@ def _column_exists(self, collect_default: bool = False) -> bool:
12891291
)
12901292

12911293
def _get_remote_model(self) -> models.Model:
1294+
assert self.field is not None
12921295
if isinstance(self.field.remote_field.model, str):
12931296
app_name, model_name = self.field.remote_field.model.split(".") # type: ignore[unreachable]
12941297

@@ -1307,6 +1310,7 @@ def _get_remote_pk_field(self) -> models.Field[Any, Any]:
13071310
return pk_field
13081311

13091312
def _get_remote_to_field(self) -> models.Field[Any, Any]:
1313+
assert self.field is not None
13101314
to_field = self.field.to_fields[0]
13111315
remote_model = self._get_remote_model()
13121316

@@ -1318,6 +1322,7 @@ def _get_remote_to_field(self) -> models.Field[Any, Any]:
13181322

13191323
def _get_target_field(self) -> models.Field[Any, Any]:
13201324
# If to_field is specified, we don't want to default to using the pk.
1325+
assert self.field is not None
13211326
if self.field.to_fields and self.field.to_fields[0]:
13221327
target_field = self._get_remote_to_field()
13231328
else:
@@ -1356,6 +1361,7 @@ def _maybe_create_index(self) -> None:
13561361
# be used as indexes by Postgres.
13571362
return
13581363

1364+
assert self.field is not None
13591365
assert hasattr(self.field, "db_index")
13601366
if self.field.db_index:
13611367
SafeIndexOperationManager().safer_create_index(
@@ -1492,9 +1498,12 @@ def database_forwards(
14921498
from_state: migrations.state.ProjectState,
14931499
to_state: migrations.state.ProjectState,
14941500
) -> None:
1495-
field = from_state.apps.get_model(app_label, self.model_name)._meta.get_field(
1496-
self.name
1497-
)
1501+
try:
1502+
field = from_state.apps.get_model(
1503+
app_label, self.model_name
1504+
)._meta.get_field(self.name)
1505+
except exceptions.FieldDoesNotExist:
1506+
field = None
14981507
ForeignKeyManager(
14991508
app_label,
15001509
schema_editor,

tests/django_pg_migration_tools/test_operations.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,6 +2588,160 @@ def test_operation(self):
25882588
AND convalidated IS TRUE;
25892589
""")
25902590

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

0 commit comments

Comments
 (0)