From 52d14cef3cb5278af6e197ecb33c430a4eb6b2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pave=C5=82=20Ty=C5=9Blacki?= Date: Wed, 5 Jun 2024 17:21:04 +0100 Subject: [PATCH] dropping constraints and indexes before drop table or column --- CHANGES.md | 2 + .../backends/postgres/schema.py | 145 +++++++++++++- .../__init__.py | 0 .../migrations/0001_initial.py | 142 +++++++++++++ ...move_testtablemain_drop_col_u1_and_more.py | 52 +++++ .../0003_remove_testtablemain_field_i4.py | 20 ++ .../0004_remove_testtablemain_field_i3.py | 20 ++ .../0005_remove_testtablemain_field_i2.py | 20 ++ .../0006_remove_testtablemain_field_i1.py | 20 ++ .../0007_remove_testtablemain_field_u4.py | 20 ++ .../0008_remove_testtablemain_field_u3.py | 20 ++ .../0009_remove_testtablemain_field_u2.py | 20 ++ .../0010_remove_testtablemain_field_u1.py | 20 ++ .../0011_remove_testtablemain_main_id.py | 20 ++ .../0012_remove_testtablemain_parent.py | 17 ++ .../migrations/__init__.py | 0 .../models.py | 44 +++++ .../__init__.py | 0 .../migrations/0001_initial.py | 143 ++++++++++++++ .../0002_remove_testtablechild_main.py | 20 ++ .../migrations/0003_delete_testtablemain.py | 16 ++ .../migrations/__init__.py | 0 .../models.py | 44 +++++ tests/integration/test_migrations.py | 151 ++++++++++++++ tests/settings_make_migrations.py | 2 + tests/unit/test_schema.py | 186 +++++++++++++++++- 26 files changed, 1139 insertions(+), 5 deletions(-) create mode 100644 tests/apps/good_flow_drop_column_with_constraints/__init__.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0001_initial.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0002_remove_testtablemain_drop_col_u1_and_more.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0003_remove_testtablemain_field_i4.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0004_remove_testtablemain_field_i3.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0005_remove_testtablemain_field_i2.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0006_remove_testtablemain_field_i1.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0007_remove_testtablemain_field_u4.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0008_remove_testtablemain_field_u3.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0009_remove_testtablemain_field_u2.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0010_remove_testtablemain_field_u1.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0011_remove_testtablemain_main_id.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/0012_remove_testtablemain_parent.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/migrations/__init__.py create mode 100644 tests/apps/good_flow_drop_column_with_constraints/models.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/__init__.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/migrations/0001_initial.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/migrations/0002_remove_testtablechild_main.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/migrations/0003_delete_testtablemain.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/migrations/__init__.py create mode 100644 tests/apps/good_flow_drop_table_with_constraints/models.py diff --git a/CHANGES.md b/CHANGES.md index 4d15e60..67225b7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,9 @@ - changed `ADD COLUMN DEFAULT NULL` to safe operation for code default - changed `ADD COLUMN DEFAULT NOT NULL` to safe operation for `db_default` in django 5.0+ - added `ZERO_DOWNTIME_MIGRATIONS_KEEP_DEFAULT` settings and changed `ADD COLUMN DEFAULT NOT NULL` with this settings to safe operation for django<5.0 + - added `ZERO_DOWNTIME_MIGRATIONS_DROP_CONSTRAINS_BEFORE_CASCADE` settings and added dropping constraints and indexes before drop column or table - fixed sqlmigrate in idempotent mode + - fixed creation unique constraint with include parameter - updated unsafe migrations links to documentation - updated patched code to latest django version - updated test image to ubuntu 24.04 diff --git a/django_zero_downtime_migrations/backends/postgres/schema.py b/django_zero_downtime_migrations/backends/postgres/schema.py index 71ff5e7..8410134 100644 --- a/django_zero_downtime_migrations/backends/postgres/schema.py +++ b/django_zero_downtime_migrations/backends/postgres/schema.py @@ -5,7 +5,7 @@ import django from django.conf import settings from django.contrib.postgres.constraints import ExclusionConstraint -from django.db.backends.ddl_references import Statement +from django.db.backends.ddl_references import Statement, Table from django.db.backends.postgresql.schema import ( DatabaseSchemaEditor as PostgresDatabaseSchemaEditor ) @@ -417,7 +417,8 @@ class DatabaseSchemaEditorMixin: if django.VERSION[:2] >= (5, 0): sql_create_unique_index = MultiStatementSQL( PGShareUpdateExclusive( - "CREATE UNIQUE INDEX CONCURRENTLY %(name)s ON %(table)s (%(columns)s)%(condition)s%(nulls_distinct)s", + "CREATE UNIQUE INDEX CONCURRENTLY %(name)s ON %(table)s " + "(%(columns)s)%(include)s%(nulls_distinct)s%(condition)s", idempotent_condition=Condition(_sql_index_exists, False), disable_statement_timeout=True, ), @@ -465,6 +466,54 @@ class DatabaseSchemaEditorMixin: ), ) + _sql_get_table_constraints_introspection = """ + SELECT + c.conname, + c.contype, + c.conrelid::regclass::text, + c.confrelid::regclass::text, + array( + SELECT attname + FROM unnest(c.conkey) WITH ORDINALITY cols(colid, arridx) + JOIN pg_attribute AS ca ON cols.colid = ca.attnum + WHERE ca.attrelid = c.conrelid + ORDER BY cols.arridx + ), + array( + SELECT attname + FROM unnest(c.confkey) WITH ORDINALITY cols(colid, arridx) + JOIN pg_attribute AS ca ON cols.colid = ca.attnum + WHERE ca.attrelid = c.confrelid + ORDER BY cols.arridx + ) + FROM pg_constraint AS c + WHERE c.conrelid::regclass::text = %s + OR c.confrelid::regclass::text = %s + ORDER BY c.conrelid::regclass::text, c.conname + """ + _sql_get_index_introspection = """ + SELECT + i.indexrelid::regclass::text, + i.indrelid::regclass::text, + array( + SELECT a.attname + FROM ( + SELECT unnest(i.indkey) + UNION + SELECT unnest(regexp_matches(i.indexprs::text, ':varattno (\d+)', 'g'))::int + UNION + SELECT unnest(regexp_matches(i.indpred::text, ':varattno (\d+)', 'g'))::int + ) cols(varattno) + INNER JOIN pg_attribute AS a ON cols.varattno = a.attnum + WHERE a.attrelid = i.indrelid + ) + FROM pg_index i + LEFT JOIN pg_constraint c ON i.indexrelid = c.conindid + WHERE indrelid::regclass::text = %s + AND c.conindid IS NULL + ORDER BY i.indrelid::regclass::text, i.indexrelid::regclass::text + """ + _varchar_type_regexp = re.compile(r'^varchar\((?P\d+)\)$') _numeric_type_regexp = re.compile(r'^numeric\((?P\d+), *(?P\d+)\)$') @@ -508,6 +557,8 @@ def __init__(self, connection, collect_sql=False, atomic=True): DeprecationWarning, ) self.KEEP_DEFAULT = False + self.DROP_CONSTRAINS_BEFORE_CASCADE = getattr( + settings, "ZERO_DOWNTIME_MIGRATIONS_DROP_CONSTRAINS_BEFORE_CASCADE", True) def execute(self, sql, params=()): if sql is DUMMY_SQL: @@ -599,13 +650,55 @@ def _flush_deferred_sql(self): self.execute(sql) self.deferred_sql.clear() + def _get_constraints(self, cursor, model): + cursor.execute(self._sql_get_table_constraints_introspection, [model._meta.db_table, model._meta.db_table]) + for constraint, kind, table, table_ref, columns, columns_ref in cursor.fetchall(): + yield constraint, kind, table, table_ref, columns, columns_ref + + def _get_indexes(self, cursor, model): + cursor.execute(self._sql_get_index_introspection, [model._meta.db_table]) + for index, table, columns in cursor.fetchall(): + yield index, table, columns + + def _drop_collect_sql_introspection_related_duplicates(self, drop_constraint_queries): + # django internals use introspection to find related constraints + # that can duplicated explicit constraints dropping in this method + # so just clean up collected sql for this duplicates + if self.collect_sql: + handled_queries = set() + drops = set() + for i in range(len(self.collected_sql)): + for j in range(len(drop_constraint_queries)): + if all( + self.collected_sql[i + k] == drop_constraint_queries[j + k] + for k in range(len(drop_constraint_queries[j])) + ): + if j not in handled_queries: + drops |= {i + k for k in range(len(drop_constraint_queries[j]))} + handled_queries.add(j) + self.collected_sql = [query for i, query in enumerate(self.collected_sql) if i not in drops] + def create_model(self, model): super().create_model(model) self._flush_deferred_sql() def delete_model(self, model): + drop_constraint_queries = [] + if self.DROP_CONSTRAINS_BEFORE_CASCADE: + with self.connection.cursor() as cursor: + for constraint, kind, table, table_ref, columns, columns_ref in self._get_constraints(cursor, model): + if kind == "f": + last_collected_sql = len(self.collected_sql) if self.collect_sql else None + self.execute(Statement( + self.sql_delete_fk, + table=Table(table, self.quote_name), + name=self.quote_name(constraint), + )) + if self.collect_sql: + drop_constraint_queries.append(self.collected_sql[last_collected_sql:]) super().delete_model(model) self._flush_deferred_sql() + self._drop_collect_sql_introspection_related_duplicates(drop_constraint_queries) def alter_index_together(self, model, old_index_together, new_index_together): super().alter_index_together(model, old_index_together, new_index_together) @@ -641,8 +734,56 @@ def add_field(self, model, field): self._flush_deferred_sql() def remove_field(self, model, field): + drop_constraint_queries = [] + if self.DROP_CONSTRAINS_BEFORE_CASCADE: + with self.connection.cursor() as cursor: + # as foreign key can have index as dependent object it important to drop all foreign keys first + for constraint, kind, table, table_ref, columns, columns_ref in self._get_constraints(cursor, model): + # drop foreign key for current model columns + if kind == "f" and table == model._meta.db_table and field.column in columns: + last_collected_sql = len(self.collected_sql) if self.collect_sql else None + self.execute(Statement( + self.sql_delete_fk, + table=Table(table, self.quote_name), + name=self.quote_name(constraint), + )) + if self.collect_sql: + drop_constraint_queries.append(self.collected_sql[last_collected_sql:]) + # drop foreign key for target model columns, i.e. backrefs + if kind == "f" and table_ref == model._meta.db_table and field.column in columns_ref: + last_collected_sql = len(self.collected_sql) if self.collect_sql else None + self.execute(Statement( + self.sql_delete_fk, + table=Table(table, self.quote_name), + name=self.quote_name(constraint), + )) + if self.collect_sql: + drop_constraint_queries.append(self.collected_sql[last_collected_sql:]) + for constraint, kind, table, table_ref, columns, columns_ref in self._get_constraints(cursor, model): + # drop unique constraints for current model columns + if kind == "u" and table == model._meta.db_table and field.column in columns: + last_collected_sql = len(self.collected_sql) if self.collect_sql else None + self.execute(Statement( + self.sql_delete_unique, + table=Table(table, self.quote_name), + name=self.quote_name(constraint), + )) + if self.collect_sql: + drop_constraint_queries.append(self.collected_sql[last_collected_sql:]) + for index, table, columns in self._get_indexes(cursor, model): + # drop indexes for current model columns + if table == model._meta.db_table and field.column in columns: + last_collected_sql = len(self.collected_sql) if self.collect_sql else None + self.execute(Statement( + self.sql_delete_index_concurrently, + table=Table(table, self.quote_name), + name=self.quote_name(index), + )) + if self.collect_sql: + drop_constraint_queries.append(self.collected_sql[last_collected_sql:]) super().remove_field(model, field) self._flush_deferred_sql() + self._drop_collect_sql_introspection_related_duplicates(drop_constraint_queries) def alter_field(self, model, old_field, new_field, strict=False): super().alter_field(model, old_field, new_field, strict) diff --git a/tests/apps/good_flow_drop_column_with_constraints/__init__.py b/tests/apps/good_flow_drop_column_with_constraints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0001_initial.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0001_initial.py new file mode 100644 index 0000000..d552918 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0001_initial.py @@ -0,0 +1,142 @@ +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.functions.math + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TestTableParent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "db_table": "drop_col_test_table_parent", + }, + ), + migrations.CreateModel( + name="TestTableMain", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("main_id", models.IntegerField(null=True, unique=True)), + ("field_u1", models.IntegerField(null=True)), + ("field_u2", models.IntegerField(null=True)), + ("field_u3", models.IntegerField(null=True)), + ("field_u4", models.IntegerField(null=True)), + ("field_i1", models.IntegerField(null=True)), + ("field_i2", models.IntegerField(null=True)), + ("field_i3", models.IntegerField(null=True)), + ("field_i4", models.IntegerField(null=True)), + ( + "parent", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="good_flow_drop_column_with_constraints.testtableparent", + ), + ), + ], + options={ + "db_table": "drop_col_test_table_main", + }, + ), + migrations.CreateModel( + name="TestTableChild", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "main", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="good_flow_drop_column_with_constraints.testtablemain", + to_field="main_id", + ), + ), + ], + options={ + "db_table": "drop_col_test_table_child", + }, + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), models.F("field_i1"), name="drop_col_i1" + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + django.db.models.functions.math.Abs("field_i2"), name="drop_col_i2" + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), + condition=models.Q(("field_i3__gt", 0)), + name="drop_col_i3", + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), include=("field_i4",), name="drop_col_i4" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), models.F("field_u1"), name="drop_col_u1" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + django.db.models.functions.math.Abs("field_u2"), name="drop_col_u2" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), + condition=models.Q(("field_u3__gt", 0)), + name="drop_col_u3", + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), include=("field_u4",), name="drop_col_u4" + ), + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0002_remove_testtablemain_drop_col_u1_and_more.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0002_remove_testtablemain_drop_col_u1_and_more.py new file mode 100644 index 0000000..c4a0616 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0002_remove_testtablemain_drop_col_u1_and_more.py @@ -0,0 +1,52 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("good_flow_drop_column_with_constraints", "0001_initial"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RemoveConstraint( + model_name="testtablemain", + name="drop_col_u1", + ), + migrations.RemoveConstraint( + model_name="testtablemain", + name="drop_col_u2", + ), + migrations.RemoveConstraint( + model_name="testtablemain", + name="drop_col_u3", + ), + migrations.RemoveConstraint( + model_name="testtablemain", + name="drop_col_u4", + ), + migrations.RemoveIndex( + model_name="testtablemain", + name="drop_col_i1", + ), + migrations.RemoveIndex( + model_name="testtablemain", + name="drop_col_i2", + ), + migrations.RemoveIndex( + model_name="testtablemain", + name="drop_col_i3", + ), + migrations.RemoveIndex( + model_name="testtablemain", + name="drop_col_i4", + ), + migrations.RemoveField( + model_name="testtablechild", + name="main", + ), + ], + ) + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0003_remove_testtablemain_field_i4.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0003_remove_testtablemain_field_i4.py new file mode 100644 index 0000000..3d56958 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0003_remove_testtablemain_field_i4.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0002_remove_testtablemain_drop_col_u1_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_i4", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0004_remove_testtablemain_field_i3.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0004_remove_testtablemain_field_i3.py new file mode 100644 index 0000000..5ffd824 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0004_remove_testtablemain_field_i3.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0003_remove_testtablemain_field_i4", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_i3", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0005_remove_testtablemain_field_i2.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0005_remove_testtablemain_field_i2.py new file mode 100644 index 0000000..469522e --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0005_remove_testtablemain_field_i2.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0004_remove_testtablemain_field_i3", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_i2", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0006_remove_testtablemain_field_i1.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0006_remove_testtablemain_field_i1.py new file mode 100644 index 0000000..028cc38 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0006_remove_testtablemain_field_i1.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0005_remove_testtablemain_field_i2", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_i1", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0007_remove_testtablemain_field_u4.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0007_remove_testtablemain_field_u4.py new file mode 100644 index 0000000..7aa0f9d --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0007_remove_testtablemain_field_u4.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0006_remove_testtablemain_field_i1", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_u4", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0008_remove_testtablemain_field_u3.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0008_remove_testtablemain_field_u3.py new file mode 100644 index 0000000..58076fd --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0008_remove_testtablemain_field_u3.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0007_remove_testtablemain_field_u4", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_u3", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0009_remove_testtablemain_field_u2.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0009_remove_testtablemain_field_u2.py new file mode 100644 index 0000000..81ed1dc --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0009_remove_testtablemain_field_u2.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0008_remove_testtablemain_field_u3", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_u2", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0010_remove_testtablemain_field_u1.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0010_remove_testtablemain_field_u1.py new file mode 100644 index 0000000..0b1d210 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0010_remove_testtablemain_field_u1.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0009_remove_testtablemain_field_u2", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="field_u1", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0011_remove_testtablemain_main_id.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0011_remove_testtablemain_main_id.py new file mode 100644 index 0000000..1a475a2 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0011_remove_testtablemain_main_id.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ( + "good_flow_drop_column_with_constraints", + "0010_remove_testtablemain_field_u1", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="main_id", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/0012_remove_testtablemain_parent.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/0012_remove_testtablemain_parent.py new file mode 100644 index 0000000..2bdfd55 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/migrations/0012_remove_testtablemain_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ("good_flow_drop_column_with_constraints", "0011_remove_testtablemain_main_id"), + ] + + operations = [ + migrations.RemoveField( + model_name="testtablemain", + name="parent", + ), + ] diff --git a/tests/apps/good_flow_drop_column_with_constraints/migrations/__init__.py b/tests/apps/good_flow_drop_column_with_constraints/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apps/good_flow_drop_column_with_constraints/models.py b/tests/apps/good_flow_drop_column_with_constraints/models.py new file mode 100644 index 0000000..0636ff6 --- /dev/null +++ b/tests/apps/good_flow_drop_column_with_constraints/models.py @@ -0,0 +1,44 @@ +from django.db import models +from django.db.models.functions import Abs + + +class TestTableParent(models.Model): + + class Meta: + db_table = 'drop_col_test_table_parent' + + +class TestTableMain(models.Model): + # parent = models.OneToOneField(TestTableParent, null=True, on_delete=models.CASCADE) + # main_id = models.IntegerField(null=True, unique=True) + # field_u1 = models.IntegerField(null=True) + # field_u2 = models.IntegerField(null=True) + # field_u3 = models.IntegerField(null=True) + # field_u4 = models.IntegerField(null=True) + # field_i1 = models.IntegerField(null=True) + # field_i2 = models.IntegerField(null=True) + # field_i3 = models.IntegerField(null=True) + # field_i4 = models.IntegerField(null=True) + + class Meta: + db_table = 'drop_col_test_table_main' + # constraints = [ + # models.UniqueConstraint("parent", "field_u1", name="drop_col_u1"), + # models.UniqueConstraint(Abs("field_u2"), name="drop_col_u2"), + # models.UniqueConstraint("parent", name="drop_col_u3", condition=models.Q(field_u3__gt=0)), + # models.UniqueConstraint("parent", name="drop_col_u4", include=["field_u4"]), + # + # ] + # indexes = [ + # models.Index("parent", "field_i1", name="drop_col_i1"), + # models.Index(Abs("field_i2"), name="drop_col_i2"), + # models.Index("parent", name="drop_col_i3", condition=models.Q(field_i3__gt=0)), + # models.Index("parent", name="drop_col_i4", include=["field_i4"]), + # ] + + +class TestTableChild(models.Model): + # main = models.ForeignKey(TestTableMain, to_field="main_id", null=True, on_delete=models.CASCADE) + + class Meta: + db_table = 'drop_col_test_table_child' diff --git a/tests/apps/good_flow_drop_table_with_constraints/__init__.py b/tests/apps/good_flow_drop_table_with_constraints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apps/good_flow_drop_table_with_constraints/migrations/0001_initial.py b/tests/apps/good_flow_drop_table_with_constraints/migrations/0001_initial.py new file mode 100644 index 0000000..545fa68 --- /dev/null +++ b/tests/apps/good_flow_drop_table_with_constraints/migrations/0001_initial.py @@ -0,0 +1,143 @@ +import django.db.models.deletion +import django.db.models.functions.math +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="TestTableMain", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("main_id", models.IntegerField(null=True, unique=True)), + ("field_u1", models.IntegerField(null=True)), + ("field_u2", models.IntegerField(null=True)), + ("field_u3", models.IntegerField(null=True)), + ("field_u4", models.IntegerField(null=True)), + ("field_i1", models.IntegerField(null=True)), + ("field_i2", models.IntegerField(null=True)), + ("field_i3", models.IntegerField(null=True)), + ("field_i4", models.IntegerField(null=True)), + ], + options={ + "db_table": "drop_tbl_test_table_main", + }, + ), + migrations.CreateModel( + name="TestTableParent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "db_table": "drop_tbl_test_table_parent", + }, + ), + migrations.CreateModel( + name="TestTableChild", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "main", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="good_flow_drop_table_with_constraints.testtablemain", + to_field="main_id", + ), + ), + ], + options={ + "db_table": "drop_tbl_test_table_child", + }, + ), + migrations.AddField( + model_name="testtablemain", + name="parent", + field=models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="good_flow_drop_table_with_constraints.testtableparent", + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), models.F("field_i1"), name="drop_tbl_i1" + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + django.db.models.functions.math.Abs("field_i2"), name="drop_tbl_i2" + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), + condition=models.Q(("field_i3__gt", 0)), + name="drop_tbl_i3", + ), + ), + migrations.AddIndex( + model_name="testtablemain", + index=models.Index( + models.F("parent"), include=("field_i4",), name="drop_tbl_i4" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), models.F("field_u1"), name="drop_tbl_u1" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + django.db.models.functions.math.Abs("field_u2"), name="drop_tbl_u2" + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), + condition=models.Q(("field_u3__gt", 0)), + name="drop_tbl_u3", + ), + ), + migrations.AddConstraint( + model_name="testtablemain", + constraint=models.UniqueConstraint( + models.F("parent"), include=("field_u4",), name="drop_tbl_u4" + ), + ), + ] diff --git a/tests/apps/good_flow_drop_table_with_constraints/migrations/0002_remove_testtablechild_main.py b/tests/apps/good_flow_drop_table_with_constraints/migrations/0002_remove_testtablechild_main.py new file mode 100644 index 0000000..f52664d --- /dev/null +++ b/tests/apps/good_flow_drop_table_with_constraints/migrations/0002_remove_testtablechild_main.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("good_flow_drop_table_with_constraints", "0001_initial"), + ] + + operations = [ + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RemoveField( + model_name="testtablechild", + name="main", + ), + ], + ), + ] diff --git a/tests/apps/good_flow_drop_table_with_constraints/migrations/0003_delete_testtablemain.py b/tests/apps/good_flow_drop_table_with_constraints/migrations/0003_delete_testtablemain.py new file mode 100644 index 0000000..fe894a1 --- /dev/null +++ b/tests/apps/good_flow_drop_table_with_constraints/migrations/0003_delete_testtablemain.py @@ -0,0 +1,16 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ("good_flow_drop_table_with_constraints", "0002_remove_testtablechild_main"), + ] + + operations = [ + migrations.DeleteModel( + name="TestTableMain", + ), + ] diff --git a/tests/apps/good_flow_drop_table_with_constraints/migrations/__init__.py b/tests/apps/good_flow_drop_table_with_constraints/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/apps/good_flow_drop_table_with_constraints/models.py b/tests/apps/good_flow_drop_table_with_constraints/models.py new file mode 100644 index 0000000..2db9109 --- /dev/null +++ b/tests/apps/good_flow_drop_table_with_constraints/models.py @@ -0,0 +1,44 @@ +from django.db import models +from django.db.models.functions import Abs + + +class TestTableParent(models.Model): + + class Meta: + db_table = 'drop_tbl_test_table_parent' + + +# class TestTableMain(models.Model): +# parent = models.OneToOneField(TestTableParent, null=True, on_delete=models.CASCADE) +# main_id = models.IntegerField(null=True, unique=True) +# field_u1 = models.IntegerField(null=True) +# field_u2 = models.IntegerField(null=True) +# field_u3 = models.IntegerField(null=True) +# field_u4 = models.IntegerField(null=True) +# field_i1 = models.IntegerField(null=True) +# field_i2 = models.IntegerField(null=True) +# field_i3 = models.IntegerField(null=True) +# field_i4 = models.IntegerField(null=True) +# +# class Meta: +# db_table = 'drop_tbl_test_table_main' +# constraints = [ +# models.UniqueConstraint("parent", "field_u1", name="drop_tbl_u1"), +# models.UniqueConstraint(Abs("field_u2"), name="drop_tbl_u2"), +# models.UniqueConstraint("parent", name="drop_tbl_u3", condition=models.Q(field_u3__gt=0)), +# models.UniqueConstraint("parent", name="drop_tbl_u4", include=["field_u4"]), +# +# ] +# indexes = [ +# models.Index("parent", "field_i1", name="drop_tbl_i1"), +# models.Index(Abs("field_i2"), name="drop_tbl_i2"), +# models.Index("parent", name="drop_tbl_i3", condition=models.Q(field_i3__gt=0)), +# models.Index("parent", name="drop_tbl_i4", include=["field_i4"]), +# ] + + +class TestTableChild(models.Model): + # main = models.ForeignKey(TestTableMain, to_field="main_id", null=True, on_delete=models.CASCADE) + + class Meta: + db_table = 'drop_tbl_test_table_child' diff --git a/tests/integration/test_migrations.py b/tests/integration/test_migrations.py index 9650232..4aa69f8 100644 --- a/tests/integration/test_migrations.py +++ b/tests/integration/test_migrations.py @@ -63,6 +63,157 @@ def test_good_flow_create_and_drop_index_concurrently(): call_command("migrate", "good_flow_app_concurrently", "zero") +@pytest.mark.django_db(transaction=True) +@modify_settings(INSTALLED_APPS={"append": "tests.apps.good_flow_drop_table_with_constraints"}) +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_good_flow_drop_table_with_constraints(): + with override_settings(ZERO_DOWNTIME_MIGRATIONS_DROP_CONSTRAINS_BEFORE_CASCADE=False): + call_command("migrate", "good_flow_drop_table_with_constraints") + drop_tbl_test_table_parent_schema = pg_dump("drop_tbl_test_table_parent") + drop_tbl_test_table_child_schema = pg_dump("drop_tbl_test_table_child") + call_command("migrate", "good_flow_drop_table_with_constraints", "zero") + + _drop_child_foreign_key_constraint_sql = one_line_sql(""" + SET CONSTRAINTS "drop_tbl_test_table__main_id_8a4874b6_fk_drop_tbl_" IMMEDIATE; + ALTER TABLE "drop_tbl_test_table_child" + DROP CONSTRAINT "drop_tbl_test_table__main_id_8a4874b6_fk_drop_tbl_"; + """) + _drop_main_foreign_key_constraint_sql = one_line_sql(""" + SET CONSTRAINTS "drop_tbl_test_table__parent_id_5c6ff8d9_fk_drop_tbl_" IMMEDIATE; + ALTER TABLE "drop_tbl_test_table_main" + DROP CONSTRAINT "drop_tbl_test_table__parent_id_5c6ff8d9_fk_drop_tbl_"; + """) + _drop_table_sql = one_line_sql(""" + DROP TABLE "drop_tbl_test_table_main" CASCADE; + """) + + call_command("migrate", "good_flow_drop_table_with_constraints", "0002") + migration_sql = call_command("sqlmigrate", "good_flow_drop_table_with_constraints", "0003") + assert split_sql_queries(migration_sql) == [ + _drop_child_foreign_key_constraint_sql, + _drop_main_foreign_key_constraint_sql, + _drop_table_sql, + ] + call_command("migrate", "good_flow_drop_table_with_constraints") + assert pg_dump("drop_tbl_test_table_parent") == drop_tbl_test_table_parent_schema + assert pg_dump("drop_tbl_test_table_child") == drop_tbl_test_table_child_schema + + +@pytest.mark.django_db(transaction=True) +@modify_settings(INSTALLED_APPS={"append": "tests.apps.good_flow_drop_column_with_constraints"}) +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_good_flow_drop_column_with_constraints(): + with override_settings(ZERO_DOWNTIME_MIGRATIONS_DROP_CONSTRAINS_BEFORE_CASCADE=False): + call_command("migrate", "good_flow_drop_column_with_constraints") + drop_col_test_table_parent_schema = pg_dump("drop_col_test_table_parent") + drop_col_test_table_main_schema = pg_dump("drop_col_test_table_main") + drop_col_test_table_child_schema = pg_dump("drop_col_test_table_child") + call_command("migrate", "good_flow_drop_column_with_constraints", "zero") + + call_command("migrate", "good_flow_drop_column_with_constraints", "0002") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0003") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_i4";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_i4" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0003") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0004") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_i3";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_i3" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0004") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0005") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_i2";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_i2" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0005") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0006") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_i1";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_i1" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0006") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0007") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_u4";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_u4" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0007") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0008") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_u3";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_u3" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0008") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0009") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_u2";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_u2" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0009") + + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0010") + assert split_sql_queries(migration_sql) == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "drop_col_u1";', + 'ALTER TABLE "drop_col_test_table_main" DROP COLUMN "field_u1" CASCADE;', + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0010") + + _drop_main_id_child_foreign_key_constraint_sql = one_line_sql(""" + SET CONSTRAINTS "drop_col_test_table__main_id_9da91a1c_fk_drop_col_" IMMEDIATE; + ALTER TABLE "drop_col_test_table_child" + DROP CONSTRAINT "drop_col_test_table__main_id_9da91a1c_fk_drop_col_"; + """) + _drop_main_id_field_unique_constraint = one_line_sql(""" + ALTER TABLE "drop_col_test_table_main" + DROP CONSTRAINT "drop_col_test_table_main_main_id_key"; + """) + _drop_main_id_column_sql = one_line_sql(""" + ALTER TABLE "drop_col_test_table_main" DROP COLUMN "main_id" CASCADE; + """) + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0011") + assert split_sql_queries(migration_sql) == [ + _drop_main_id_child_foreign_key_constraint_sql, + _drop_main_id_field_unique_constraint, + _drop_main_id_column_sql, + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0011") + + _drop_parent_id_main_foreign_key_constraint_sql = one_line_sql(""" + SET CONSTRAINTS "drop_col_test_table__parent_id_55b0b5e6_fk_drop_col_" IMMEDIATE; + ALTER TABLE "drop_col_test_table_main" + DROP CONSTRAINT "drop_col_test_table__parent_id_55b0b5e6_fk_drop_col_"; + """) + _drop_parent_id_field_unique_constraint = one_line_sql(""" + ALTER TABLE "drop_col_test_table_main" + DROP CONSTRAINT "drop_col_test_table_main_parent_id_key"; + """) + _drop_parent_id_column_sql = one_line_sql(""" + ALTER TABLE "drop_col_test_table_main" DROP COLUMN "parent_id" CASCADE; + """) + migration_sql = call_command("sqlmigrate", "good_flow_drop_column_with_constraints", "0012") + assert split_sql_queries(migration_sql) == [ + _drop_parent_id_main_foreign_key_constraint_sql, + _drop_parent_id_field_unique_constraint, + _drop_parent_id_column_sql, + ] + call_command("migrate", "good_flow_drop_column_with_constraints", "0012") + + call_command("migrate", "good_flow_drop_column_with_constraints") + assert pg_dump("drop_col_test_table_parent") == drop_col_test_table_parent_schema + assert pg_dump("drop_col_test_table_main") == drop_col_test_table_main_schema + assert pg_dump("drop_col_test_table_child") == drop_col_test_table_child_schema + + @skip_for_default_django_backend @pytest.mark.django_db(transaction=True) @modify_settings(INSTALLED_APPS={"append": "tests.apps.bad_rollback_flow_drop_column_with_notnull_app"}) diff --git a/tests/settings_make_migrations.py b/tests/settings_make_migrations.py index 27bfbd2..6434c33 100644 --- a/tests/settings_make_migrations.py +++ b/tests/settings_make_migrations.py @@ -4,6 +4,8 @@ 'tests.apps.good_flow_alter_table_with_same_db_table', 'tests.apps.good_flow_app', 'tests.apps.good_flow_app_concurrently', + 'tests.apps.good_flow_drop_table_with_constraints', + 'tests.apps.good_flow_drop_column_with_constraints', 'tests.apps.bad_rollback_flow_drop_column_with_notnull_default_app', 'tests.apps.bad_rollback_flow_drop_column_with_notnull_app', 'tests.apps.bad_rollback_flow_change_char_type_that_safe_app', diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py index 448c28e..9df5b10 100644 --- a/tests/unit/test_schema.py +++ b/tests/unit/test_schema.py @@ -135,6 +135,46 @@ def test_drop_model__ok(): ] +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_drop_model__drop_foreign_key__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').return_value = [ + ('tests_model_model2_id_fk', 'f', 'tests_model', 'tests_model2', ['model2_id'], ['id']) + ] + with cmp_schema_editor() as editor: + editor.delete_model(Model) + assert editor.collected_sql == timeouts( + 'SET CONSTRAINTS "tests_model_model2_id_fk" IMMEDIATE; ' + 'ALTER TABLE "tests_model" DROP CONSTRAINT "tests_model_model2_id_fk";', + ) + [ + 'DROP TABLE "tests_model" CASCADE;' + ] + assert editor.django_sql == [ + 'DROP TABLE "tests_model" CASCADE;', + ] + + +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_drop_model__drop_foreign_key_backref__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').return_value = [ + ('tests_model2_model_id_fk', 'f', 'tests_model2', 'tests_model', ['model_id'], ['id']) + ] + with cmp_schema_editor() as editor: + editor.delete_model(Model) + assert editor.collected_sql == timeouts( + 'SET CONSTRAINTS "tests_model2_model_id_fk" IMMEDIATE; ' + 'ALTER TABLE "tests_model2" DROP CONSTRAINT "tests_model2_model_id_fk";', + ) + [ + 'DROP TABLE "tests_model" CASCADE;' + ] + assert editor.django_sql == [ + 'DROP TABLE "tests_model" CASCADE;', + ] + + @pytest.mark.django_db def test_rename_model__warning(): with cmp_schema_editor() as editor: @@ -1164,6 +1204,146 @@ def test_remove_field__ok(): ] +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_remove_field_with_foreign_key__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').side_effect = [ + [ + ('tests_model_field_fk', 'f', 'tests_model', 'tests_model2', ['field'], ['id']), + ('tests_model_field2_fk', 'f', 'tests_model', 'tests_model2', ['field2'], ['id']), + ], + [ + ('tests_model_field2_fk', 'f', 'tests_model', 'tests_model2', ['field2'], ['id']), + ], + [], + ] + with cmp_schema_editor() as editor: + field = models.CharField(max_length=40) + field.set_attributes_from_name('field') + editor.remove_field(Model, field) + assert editor.collected_sql == timeouts( + 'SET CONSTRAINTS "tests_model_field_fk" IMMEDIATE; ' + 'ALTER TABLE "tests_model" DROP CONSTRAINT "tests_model_field_fk";' + ) + timeouts( + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;' + ) + assert editor.django_sql == [ + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ] + + +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_remove_field_with_foreign_key_backref__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').side_effect = [ + [ + ('tests_model2_model_field_fk', 'f', 'tests_model2', 'tests_model', ['model_field'], ['field']), + ('tests_model2_model_field2_fk', 'f', 'tests_model2', 'tests_model', ['model_field2'], ['field2']), + ], + [ + ('tests_model2_model_field2_fk', 'f', 'tests_model2', 'tests_model', ['model_field2'], ['field2']), + ], + [], + ] + with cmp_schema_editor() as editor: + field = models.CharField(max_length=40) + field.set_attributes_from_name('field') + editor.remove_field(Model, field) + assert editor.collected_sql == timeouts( + 'SET CONSTRAINTS "tests_model2_model_field_fk" IMMEDIATE; ' + 'ALTER TABLE "tests_model2" DROP CONSTRAINT "tests_model2_model_field_fk";' + ) + timeouts( + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;' + ) + assert editor.django_sql == [ + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ] + + +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_remove_field_with_unique_constraint__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').side_effect = [ + [ + ('tests_model_field2_field_uniq', 'u', 'tests_model', None, ['field2', 'field'], []), + ('tests_model_field2_field3_uniq', 'u', 'tests_model', None, ['field2', 'field3'], []), + ], + [ + ('tests_model_field2_field_uniq', 'u', 'tests_model', None, ['field2', 'field'], []), + ('tests_model_field2_field3_uniq', 'u', 'tests_model', None, ['field2', 'field3'], []), + ], + [], + ] + with cmp_schema_editor() as editor: + field = models.CharField(max_length=40) + field.set_attributes_from_name('field') + editor.remove_field(Model, field) + assert editor.collected_sql == timeouts( + 'ALTER TABLE "tests_model" DROP CONSTRAINT "tests_model_field2_field_uniq";', + ) + timeouts( + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ) + assert editor.django_sql == [ + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ] + + +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) +def test_remove_field_with_index__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').side_effect = [ + [], + [], + [ + ('tests_model_field2_field_idx', 'tests_model', ['field2', 'field']), + ('tests_model_field2_field3_idx', 'tests_model', ['field2', 'field3']), + ], + ] + with cmp_schema_editor() as editor: + field = models.CharField(max_length=40) + field.set_attributes_from_name('field') + editor.remove_field(Model, field) + assert editor.collected_sql == [ + 'DROP INDEX CONCURRENTLY IF EXISTS "tests_model_field2_field_idx";', + ] + timeouts( + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ) + assert editor.django_sql == [ + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ] + + +@pytest.mark.django_db +@override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True, + ZERO_DOWNTIME_MIGRATIONS_FLEXIBLE_STATEMENT_TIMEOUT=True) +def test_remove_field_with_index__with_flexible_timeout__ok(cursor, mocker): + mocker.patch.object(cursor, 'execute') + mocker.patch.object(cursor, 'fetchall').side_effect = [ + [], + [], + [ + ('tests_model_field2_field_idx', 'tests_model', ['field2', 'field']), + ('tests_model_field2_field3_idx', 'tests_model', ['field2', 'field3']), + ], + ] + with cmp_schema_editor() as editor: + field = models.CharField(max_length=40) + field.set_attributes_from_name('field') + editor.remove_field(Model, field) + assert editor.collected_sql == flexible_statement_timeout( + 'DROP INDEX CONCURRENTLY IF EXISTS "tests_model_field2_field_idx";', + ) + timeouts( + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ) + assert editor.django_sql == [ + 'ALTER TABLE "tests_model" DROP COLUMN "field" CASCADE;', + ] + + @pytest.mark.django_db @override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) def test_alter_field_add_constraint_check__ok(): @@ -1285,7 +1465,7 @@ def test_alter_filed_add_constraint_foreign_key__with_flexible_timeout__ok(): @override_settings(ZERO_DOWNTIME_MIGRATIONS_RAISE_FOR_UNSAFE=True) def test_alter_field_drop_constraint_foreign_key__ok(mocker): mocker.patch.object(connection.introspection, 'get_constraints').return_value = { - 'tests_model_field_0a53d95f_pk': { + 'tests_model_field_0a53d95f_fk': { 'columns': ['field_id'], 'primary_key': False, 'unique': False, @@ -1304,8 +1484,8 @@ def test_alter_field_drop_constraint_foreign_key__ok(mocker): editor.alter_field(Model, old_field, new_field) assert editor.collected_sql == timeouts(editor.django_sql) assert editor.django_sql == [ - 'SET CONSTRAINTS "tests_model_field_0a53d95f_pk" IMMEDIATE; ' - 'ALTER TABLE "tests_model" DROP CONSTRAINT "tests_model_field_0a53d95f_pk";', + 'SET CONSTRAINTS "tests_model_field_0a53d95f_fk" IMMEDIATE; ' + 'ALTER TABLE "tests_model" DROP CONSTRAINT "tests_model_field_0a53d95f_fk";', ]