fix: invalidate empty-table proof after writes and table renames
This commit is contained in:
1 parent
c7a4376d35
commit
66f39b1c48
7 files changed
+285
-29
No files matched your search
+5
-1
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
Bug:
|
Bug:
|
||||||
- Do not report a required column added to a table unconditionally created earlier in the same migration.
|
- Do not report a required column added to a table unconditionally created earlier in the same migration.
|
||||||
Raw SQL, data migrations, and required columns added to existing tables remain linted.
|
SQL inserts and data operations, including nested `SeparateDatabaseAndState` operations,
|
||||||
|
invalidate that proof. Raw SQL, data migrations, and required columns added to existing tables
|
||||||
|
remain linted; nested data operations receive the same checks as top-level operations. Custom
|
||||||
|
database operations and table renames invalidate the proof unless they are known declarative
|
||||||
|
schema operations.
|
||||||
|
|
||||||
## 6.0.0
|
## 6.0.0
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -4,9 +4,12 @@
|
|||||||
|
|
||||||
**Decision.** The `NOT_NULL` SQL check accepts `ALTER TABLE ... ADD COLUMN ... NOT NULL`
|
**Decision.** The `NOT_NULL` SQL check accepts `ALTER TABLE ... ADD COLUMN ... NOT NULL`
|
||||||
only when the same migration unconditionally created that exact table. `CREATE TABLE IF
|
only when the same migration unconditionally created that exact table. `CREATE TABLE IF
|
||||||
NOT EXISTS` does not prove an empty table. The migration remains
|
NOT EXISTS`, SQL inserts, and data operations, including nested
|
||||||
fully linted, so `RunSQL`, `RunPython`, and operations on existing tables retain their
|
`SeparateDatabaseAndState` operations, do not prove an empty table. The migration remains fully
|
||||||
checks.
|
linted, so top-level and nested `RunSQL` and `RunPython`, and operations on existing tables retain
|
||||||
|
their checks. Custom database operations are not trusted as declarative schema operations and
|
||||||
|
therefore invalidate the proof. Table renames also invalidate it because a name can be reused by a
|
||||||
|
populated table.
|
||||||
|
|
||||||
**Rejected.** Ignoring all initial migrations or naming them as exemptions. Either option
|
**Rejected.** Ignoring all initial migrations or naming them as exemptions. Either option
|
||||||
skips unsafe operations that the linter must inspect.
|
skips unsafe operations that the linter must inspect.
|
||||||
@@ -13,8 +13,8 @@ from typing import Callable, Dict, Iterable
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connections
|
from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connections, migrations
|
||||||
from django.db.migrations import Migration, RunPython, RunSQL
|
from django.db.migrations import Migration, RunPython, RunSQL, SeparateDatabaseAndState
|
||||||
from django.db.migrations.operations.base import Operation
|
from django.db.migrations.operations.base import Operation
|
||||||
|
|
||||||
from .cache import Cache
|
from .cache import Cache
|
||||||
@@ -31,6 +31,33 @@ from .utils import clean_bytes_to_str, get_migration_abspath, split_migration_pa
|
|||||||
logger = logging.getLogger("django_migration_linter")
|
logger = logging.getLogger("django_migration_linter")
|
||||||
|
|
||||||
|
|
||||||
|
_DECLARATIVE_OPERATION_NAMES = (
|
||||||
|
"AddConstraint",
|
||||||
|
"AddField",
|
||||||
|
"AddIndex",
|
||||||
|
"AlterConstraint",
|
||||||
|
"AlterField",
|
||||||
|
"AlterIndexTogether",
|
||||||
|
"AlterModelManagers",
|
||||||
|
"AlterModelOptions",
|
||||||
|
"AlterModelTableComment",
|
||||||
|
"AlterOrderWithRespectTo",
|
||||||
|
"AlterUniqueTogether",
|
||||||
|
"CreateModel",
|
||||||
|
"DeleteModel",
|
||||||
|
"RemoveConstraint",
|
||||||
|
"RemoveField",
|
||||||
|
"RemoveIndex",
|
||||||
|
"RenameField",
|
||||||
|
"RenameIndex",
|
||||||
|
)
|
||||||
|
_DECLARATIVE_OPERATION_TYPES = tuple(
|
||||||
|
getattr(migrations, name)
|
||||||
|
for name in _DECLARATIVE_OPERATION_NAMES
|
||||||
|
if hasattr(migrations, name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@unique
|
@unique
|
||||||
class MessageType(Enum):
|
class MessageType(Enum):
|
||||||
OK = "ok"
|
OK = "ok"
|
||||||
@@ -182,6 +209,7 @@ class MigrationLinter:
|
|||||||
self.sql_analyser_class,
|
self.sql_analyser_class,
|
||||||
sql_statements,
|
sql_statements,
|
||||||
self.exclude_migration_tests,
|
self.exclude_migration_tests,
|
||||||
|
has_unknown_data_operations=self.has_unknown_data_operations(operations),
|
||||||
)
|
)
|
||||||
|
|
||||||
err, ignored_data, warnings_data = self.analyse_data_migration(migration)
|
err, ignored_data, warnings_data = self.analyse_data_migration(migration)
|
||||||
@@ -509,6 +537,30 @@ class MigrationLinter:
|
|||||||
or (self.ignore_initial_migrations and is_initial)
|
or (self.ignore_initial_migrations and is_initial)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iter_database_operations(
|
||||||
|
operations: Iterable[Operation],
|
||||||
|
) -> Iterable[Operation]:
|
||||||
|
"""Yield operations that execute database changes, including wrapped ones."""
|
||||||
|
for operation in operations:
|
||||||
|
nested_operations = getattr(operation, "database_operations", None)
|
||||||
|
if type(operation) is SeparateDatabaseAndState:
|
||||||
|
yield from MigrationLinter.iter_database_operations(nested_operations)
|
||||||
|
else:
|
||||||
|
yield operation
|
||||||
|
if nested_operations is not None:
|
||||||
|
yield from MigrationLinter.iter_database_operations(
|
||||||
|
nested_operations
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def has_unknown_data_operations(operations: Iterable[Operation]) -> bool:
|
||||||
|
"""Return whether an operation can populate a newly created table."""
|
||||||
|
return any(
|
||||||
|
type(operation) not in _DECLARATIVE_OPERATION_TYPES
|
||||||
|
for operation in MigrationLinter.iter_database_operations(operations)
|
||||||
|
)
|
||||||
|
|
||||||
def analyse_data_migration(
|
def analyse_data_migration(
|
||||||
self, migration: Migration
|
self, migration: Migration
|
||||||
) -> tuple[list[Issue], list[Issue], list[Issue]]:
|
) -> tuple[list[Issue], list[Issue], list[Issue]]:
|
||||||
@@ -516,7 +568,7 @@ class MigrationLinter:
|
|||||||
ignored = []
|
ignored = []
|
||||||
warnings = []
|
warnings = []
|
||||||
|
|
||||||
for operation in migration.operations:
|
for operation in self.iter_database_operations(migration.operations):
|
||||||
if isinstance(operation, RunPython):
|
if isinstance(operation, RunPython):
|
||||||
op_errors, op_ignored, op_warnings = self.lint_runpython(operation)
|
op_errors, op_ignored, op_warnings = self.lint_runpython(operation)
|
||||||
elif isinstance(operation, RunSQL):
|
elif isinstance(operation, RunSQL):
|
||||||
@@ -727,6 +779,7 @@ class MigrationLinter:
|
|||||||
self.sql_analyser_class,
|
self.sql_analyser_class,
|
||||||
sql_statements,
|
sql_statements,
|
||||||
self.exclude_migration_tests,
|
self.exclude_migration_tests,
|
||||||
|
has_unknown_data_operations=True,
|
||||||
)
|
)
|
||||||
if sql_errors:
|
if sql_errors:
|
||||||
error += sql_errors
|
error += sql_errors
|
||||||
@@ -757,6 +810,7 @@ class MigrationLinter:
|
|||||||
self.sql_analyser_class,
|
self.sql_analyser_class,
|
||||||
sql_statements,
|
sql_statements,
|
||||||
self.exclude_migration_tests,
|
self.exclude_migration_tests,
|
||||||
|
has_unknown_data_operations=True,
|
||||||
)
|
)
|
||||||
if sql_errors:
|
if sql_errors:
|
||||||
error += sql_errors
|
error += sql_errors
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ def analyse_sql_statements(
|
|||||||
sql_analyser_class: Type[BaseAnalyser],
|
sql_analyser_class: Type[BaseAnalyser],
|
||||||
sql_statements: list[str],
|
sql_statements: list[str],
|
||||||
exclude_migration_tests: Iterable[str] | None = None,
|
exclude_migration_tests: Iterable[str] | None = None,
|
||||||
|
has_unknown_data_operations: bool = False,
|
||||||
) -> tuple[list[Issue], list[Issue], list[Issue]]:
|
) -> tuple[list[Issue], list[Issue], list[Issue]]:
|
||||||
sql_analyser = sql_analyser_class(exclude_migration_tests)
|
sql_analyser = sql_analyser_class(
|
||||||
|
exclude_migration_tests,
|
||||||
|
has_unknown_data_operations=has_unknown_data_operations,
|
||||||
|
)
|
||||||
sql_analyser.analyse(sql_statements)
|
sql_analyser.analyse(sql_statements)
|
||||||
return sql_analyser.errors, sql_analyser.ignored, sql_analyser.warnings
|
return sql_analyser.errors, sql_analyser.ignored, sql_analyser.warnings
|
||||||
@@ -39,9 +39,16 @@ _ADD_COLUMN = re.compile(
|
|||||||
rf"^ALTER TABLE (?:ONLY )?(?P<table>{_TABLE_NAME}) ADD (?:COLUMN )?",
|
rf"^ALTER TABLE (?:ONLY )?(?P<table>{_TABLE_NAME}) ADD (?:COLUMN )?",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
_INSERT_INTO = re.compile(rf"^INSERT INTO (?P<table>{_TABLE_NAME})", re.IGNORECASE)
|
||||||
|
_TABLE_RENAME = re.compile(
|
||||||
|
rf"^(?:ALTER TABLE (?:ONLY )?{_TABLE_NAME} RENAME TO|RENAME TABLE {_TABLE_NAME} TO)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def has_not_null_column(sql_statements: list[str], **kwargs) -> bool:
|
def has_not_null_column(
|
||||||
|
sql_statements: list[str], has_unknown_data_operations: bool = False, **kwargs
|
||||||
|
) -> bool:
|
||||||
"""Detect a required column added to a table that already exists."""
|
"""Detect a required column added to a table that already exists."""
|
||||||
not_null_column = False
|
not_null_column = False
|
||||||
has_default_value = False
|
has_default_value = False
|
||||||
@@ -49,9 +56,15 @@ def has_not_null_column(sql_statements: list[str], **kwargs) -> bool:
|
|||||||
|
|
||||||
for sql in sql_statements:
|
for sql in sql_statements:
|
||||||
created_table = _CREATE_TABLE.match(sql)
|
created_table = _CREATE_TABLE.match(sql)
|
||||||
if created_table:
|
if created_table and not has_unknown_data_operations:
|
||||||
created_tables.add(created_table.group("table"))
|
created_tables.add(created_table.group("table"))
|
||||||
|
|
||||||
|
inserted_table = _INSERT_INTO.match(sql)
|
||||||
|
if inserted_table:
|
||||||
|
created_tables.discard(inserted_table.group("table"))
|
||||||
|
if _TABLE_RENAME.match(sql):
|
||||||
|
created_tables.clear()
|
||||||
|
|
||||||
adds_column = _ADD_COLUMN.match(sql)
|
adds_column = _ADD_COLUMN.match(sql)
|
||||||
adds_required_column = re.search("(?<!DROP )(?<!IS )NOT NULL", sql)
|
adds_required_column = re.search("(?<!DROP )(?<!IS )NOT NULL", sql)
|
||||||
adds_to_new_table = (
|
adds_to_new_table = (
|
||||||
@@ -190,8 +203,13 @@ class BaseAnalyser:
|
|||||||
|
|
||||||
migration_checks: list[Check] = []
|
migration_checks: list[Check] = []
|
||||||
|
|
||||||
def __init__(self, exclude_migration_tests: Iterable[str] | None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
exclude_migration_tests: Iterable[str] | None,
|
||||||
|
has_unknown_data_operations: bool = False,
|
||||||
|
):
|
||||||
self.exclude_migration_tests: Iterable[str] = exclude_migration_tests or []
|
self.exclude_migration_tests: Iterable[str] = exclude_migration_tests or []
|
||||||
|
self.has_unknown_data_operations = has_unknown_data_operations
|
||||||
self.errors: list[Issue] = []
|
self.errors: list[Issue] = []
|
||||||
self.warnings: list[Issue] = []
|
self.warnings: list[Issue] = []
|
||||||
self.ignored: list[Issue] = []
|
self.ignored: list[Issue] = []
|
||||||
@@ -216,7 +234,11 @@ class BaseAnalyser:
|
|||||||
return (c for c in self.migration_checks if c.mode == CheckMode.TRANSACTION)
|
return (c for c in self.migration_checks if c.mode == CheckMode.TRANSACTION)
|
||||||
|
|
||||||
def _check_sql(self, check: Check, sql: list[str] | str) -> None:
|
def _check_sql(self, check: Check, sql: list[str] | str) -> None:
|
||||||
if check.fn(sql, errors=self.errors):
|
if check.fn(
|
||||||
|
sql,
|
||||||
|
errors=self.errors,
|
||||||
|
has_unknown_data_operations=self.has_unknown_data_operations,
|
||||||
|
):
|
||||||
if check.code in self.exclude_migration_tests:
|
if check.code in self.exclude_migration_tests:
|
||||||
action = "IGNORED"
|
action = "IGNORED"
|
||||||
list_to_add = self.ignored
|
list_to_add = self.ignored
|
||||||
|
|||||||
+159
-16
@@ -4,12 +4,15 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.db import ProgrammingError
|
from django.db import ProgrammingError, migrations, models
|
||||||
from django.db import migrations
|
|
||||||
from django.db.migrations import Migration
|
from django.db.migrations import Migration
|
||||||
|
from django.db.migrations.operations.base import Operation
|
||||||
|
|
||||||
from django_migration_linter import MigrationLinter
|
from django_migration_linter import MigrationLinter
|
||||||
from django_migration_linter.sql_analyser import get_sql_analyser_class
|
from django_migration_linter.sql_analyser import (
|
||||||
|
analyse_sql_statements,
|
||||||
|
get_sql_analyser_class,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LinterFunctionsTestCase(unittest.TestCase):
|
class LinterFunctionsTestCase(unittest.TestCase):
|
||||||
@@ -79,18 +82,7 @@ class LinterFunctionsTestCase(unittest.TestCase):
|
|||||||
self.assertFalse(linter.should_ignore_migration("app_correct", "0003_bar"))
|
self.assertFalse(linter.should_ignore_migration("app_correct", "0003_bar"))
|
||||||
|
|
||||||
def test_gather_all_migrations(self):
|
def test_gather_all_migrations(self):
|
||||||
linter = MigrationLinter.__new__(MigrationLinter)
|
linter = MigrationLinter()
|
||||||
linter.exclude_migration_tests = []
|
|
||||||
linter.sql_analyser_class = get_sql_analyser_class("sqlite")
|
|
||||||
linter.include_apps = None
|
|
||||||
linter.exclude_apps = None
|
|
||||||
linter.ignore_name_contains = None
|
|
||||||
linter.include_name_contains = None
|
|
||||||
linter.ignore_name = []
|
|
||||||
linter.include_name = []
|
|
||||||
linter.only_applied_migrations = False
|
|
||||||
linter.only_unapplied_migrations = False
|
|
||||||
linter.ignore_initial_migrations = False
|
|
||||||
migrations = linter._gather_all_migrations()
|
migrations = linter._gather_all_migrations()
|
||||||
self.assertGreater(len(list(migrations)), 1)
|
self.assertGreater(len(list(migrations)), 1)
|
||||||
|
|
||||||
@@ -197,9 +189,33 @@ class LinterFunctionsTestCase(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_initial_migration_still_lints_runsql_and_runpython(self):
|
def test_initial_migration_still_lints_runsql_and_runpython(self):
|
||||||
|
class CteInsertOperation(Operation):
|
||||||
|
reduces_to_sql = True
|
||||||
|
reversible = True
|
||||||
|
|
||||||
|
def state_forwards(self, app_label, state):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def database_forwards(self, app_label, schema_editor, from_state, to_state):
|
||||||
|
schema_editor.execute(
|
||||||
|
'WITH inserted AS (SELECT 1) INSERT INTO "archive_capture" ("id") SELECT * FROM inserted;'
|
||||||
|
)
|
||||||
|
|
||||||
|
def database_backwards(
|
||||||
|
self, app_label, schema_editor, from_state, to_state
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
return "Insert an archive capture through a CTE"
|
||||||
|
|
||||||
def forward(apps, schema_editor):
|
def forward(apps, schema_editor):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def unsafe_forward(apps, schema_editor):
|
||||||
|
Legacy = None
|
||||||
|
Legacy.objects.all()
|
||||||
|
|
||||||
migration = Migration("0001_initial", "app_initial_operations")
|
migration = Migration("0001_initial", "app_initial_operations")
|
||||||
migration.initial = True
|
migration.initial = True
|
||||||
migration.operations = [
|
migration.operations = [
|
||||||
@@ -208,7 +224,7 @@ class LinterFunctionsTestCase(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
linter = MigrationLinter.__new__(MigrationLinter)
|
linter = MigrationLinter.__new__(MigrationLinter)
|
||||||
linter.exclude_migration_tests = []
|
linter.exclude_migration_tests = []
|
||||||
linter.sql_analyser_class = get_sql_analyser_class("sqlite")
|
linter.sql_analyser_class = get_sql_analyser_class("postgresql")
|
||||||
linter.include_apps = None
|
linter.include_apps = None
|
||||||
linter.exclude_apps = None
|
linter.exclude_apps = None
|
||||||
linter.ignore_name_contains = None
|
linter.ignore_name_contains = None
|
||||||
@@ -226,8 +242,135 @@ class LinterFunctionsTestCase(unittest.TestCase):
|
|||||||
migration.app_label, migration.name, is_initial=migration.initial
|
migration.app_label, migration.name, is_initial=migration.initial
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.assertTrue(linter.has_unknown_data_operations(migration.operations))
|
||||||
|
declarative_migration = Migration("0001_initial", "app_declarative_schema")
|
||||||
|
declarative_migration.initial = True
|
||||||
|
declarative_migration.operations = [
|
||||||
|
migrations.CreateModel(name="ArchiveCapture", fields=[]),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="archivecapture",
|
||||||
|
name="source_id",
|
||||||
|
field=models.IntegerField(),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="archivecapture",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("source_id",), name="archive_source"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.assertFalse(
|
||||||
|
linter.has_unknown_data_operations(declarative_migration.operations)
|
||||||
|
)
|
||||||
|
renamed_migration = Migration("0001_initial", "app_renamed_schema")
|
||||||
|
renamed_migration.initial = True
|
||||||
|
renamed_migration.operations = [
|
||||||
|
migrations.CreateModel(name="ArchiveCapture", fields=[]),
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="archivecapture", new_name="archivedcapture"
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="archivecapture",
|
||||||
|
name="source_id",
|
||||||
|
field=models.IntegerField(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.assertTrue(
|
||||||
|
linter.has_unknown_data_operations(renamed_migration.operations)
|
||||||
|
)
|
||||||
self.assertEqual(["DROP_TABLE"], [issue.code for issue in errors])
|
self.assertEqual(["DROP_TABLE"], [issue.code for issue in errors])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["RUNSQL_REVERSIBLE", "RUNPYTHON_REVERSIBLE"],
|
["RUNSQL_REVERSIBLE", "RUNPYTHON_REVERSIBLE"],
|
||||||
[issue.code for issue in warnings],
|
[issue.code for issue in warnings],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nested_migration = Migration("0001_nested_initial", "app_nested_operations")
|
||||||
|
nested_migration.initial = True
|
||||||
|
nested_migration.operations = [
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[
|
||||||
|
migrations.RunSQL("DROP TABLE legacy_data;"),
|
||||||
|
migrations.RunPython(unsafe_forward),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
errors, _, warnings = linter.analyse_data_migration(nested_migration)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["DROP_TABLE", "RUNPYTHON_MODEL_IMPORT"],
|
||||||
|
[issue.code for issue in errors],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"RUNSQL_REVERSIBLE",
|
||||||
|
"RUNPYTHON_REVERSIBLE",
|
||||||
|
"RUNPYTHON_MODEL_VARIABLE_NAME",
|
||||||
|
],
|
||||||
|
[issue.code for issue in warnings],
|
||||||
|
)
|
||||||
|
|
||||||
|
migration = Migration("0001_initial", "app_initial_schema_and_data")
|
||||||
|
migration.initial = True
|
||||||
|
migration.operations = [
|
||||||
|
migrations.CreateModel(name="ArchiveCapture", fields=[]),
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[migrations.RunPython(forward)]
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="archivecapture",
|
||||||
|
name="source_id",
|
||||||
|
field=models.IntegerField(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.assertTrue(linter.has_unknown_data_operations(migration.operations))
|
||||||
|
linter.django_path = None
|
||||||
|
linter.no_cache = True
|
||||||
|
linter.all_warnings_as_errors = False
|
||||||
|
linter.warnings_as_errors_tests = None
|
||||||
|
linter.no_output = True
|
||||||
|
linter.quiet = []
|
||||||
|
linter.reset_counters()
|
||||||
|
linter.get_migration_hash = lambda *args: "initial-schema-and-data"
|
||||||
|
linter.get_sql = lambda *args: [
|
||||||
|
'CREATE TABLE "archive_capture" ("id" uuid NOT NULL PRIMARY KEY);',
|
||||||
|
'ALTER TABLE "archive_capture" ADD COLUMN "source_id" uuid NOT NULL REFERENCES "archive_source" ("id");',
|
||||||
|
]
|
||||||
|
linter.analyse_data_migration = lambda *args: ([], [], [])
|
||||||
|
|
||||||
|
linter.lint_migration(migration)
|
||||||
|
|
||||||
|
self.assertTrue(linter.has_errors)
|
||||||
|
|
||||||
|
migration.operations[1] = migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[CteInsertOperation()]
|
||||||
|
)
|
||||||
|
linter.get_sql = lambda *args: [
|
||||||
|
'CREATE TABLE "archive_capture" ("id" uuid NOT NULL PRIMARY KEY);',
|
||||||
|
'WITH inserted AS (SELECT 1) INSERT INTO "archive_capture" ("id") SELECT * FROM inserted;',
|
||||||
|
'ALTER TABLE "archive_capture" ADD COLUMN "source_id" uuid NOT NULL REFERENCES "archive_source" ("id");',
|
||||||
|
]
|
||||||
|
linter.reset_counters()
|
||||||
|
|
||||||
|
self.assertTrue(linter.has_unknown_data_operations(migration.operations))
|
||||||
|
errors, _, _ = analyse_sql_statements(
|
||||||
|
linter.sql_analyser_class,
|
||||||
|
linter.get_sql(),
|
||||||
|
has_unknown_data_operations=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(["NOT_NULL"], [issue.code for issue in errors])
|
||||||
|
|
||||||
|
linter.lint_migration(migration)
|
||||||
|
|
||||||
|
self.assertTrue(linter.has_errors)
|
||||||
|
|
||||||
|
migration.operations[1] = migrations.SeparateDatabaseAndState(
|
||||||
|
database_operations=[
|
||||||
|
migrations.RunSQL("INSERT INTO archive_capture VALUES (1);")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
linter.reset_counters()
|
||||||
|
|
||||||
|
linter.lint_migration(migration)
|
||||||
|
|
||||||
|
self.assertTrue(linter.has_errors)
|
||||||
@@ -11,12 +11,13 @@ from django_migration_linter.sql_analyser import (
|
|||||||
class SqlAnalyserTestCase(unittest.TestCase):
|
class SqlAnalyserTestCase(unittest.TestCase):
|
||||||
database_vendor = "default"
|
database_vendor = "default"
|
||||||
|
|
||||||
def analyse_sql(self, sql):
|
def analyse_sql(self, sql, **kwargs):
|
||||||
if isinstance(sql, str):
|
if isinstance(sql, str):
|
||||||
sql = sql.splitlines()
|
sql = sql.splitlines()
|
||||||
return analyse_sql_statements(
|
return analyse_sql_statements(
|
||||||
get_sql_analyser_class(self.database_vendor),
|
get_sql_analyser_class(self.database_vendor),
|
||||||
sql_statements=sql,
|
sql_statements=sql,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertValidSql(self, sql, allow_warnings=False):
|
def assertValidSql(self, sql, allow_warnings=False):
|
||||||
@@ -249,6 +250,31 @@ class PostgresqlAnalyserTestCase(SqlAnalyserTestCase):
|
|||||||
]
|
]
|
||||||
self.assertValidSql(sql)
|
self.assertValidSql(sql)
|
||||||
|
|
||||||
|
def test_add_required_foreign_key_after_insert_into_new_table(self):
|
||||||
|
sql = [
|
||||||
|
'CREATE TABLE "archive_capture" ("id" uuid NOT NULL PRIMARY KEY);',
|
||||||
|
'INSERT INTO "archive_capture" ("id") VALUES (gen_random_uuid());',
|
||||||
|
'ALTER TABLE "archive_capture" ADD COLUMN "source_id" uuid NOT NULL REFERENCES "archive_source" ("id") DEFERRABLE INITIALLY DEFERRED;',
|
||||||
|
]
|
||||||
|
self.assertBackwardIncompatibleSql(sql, "NOT_NULL")
|
||||||
|
|
||||||
|
def test_add_required_foreign_key_after_unknown_data_operation(self):
|
||||||
|
sql = [
|
||||||
|
'CREATE TABLE "archive_capture" ("id" uuid NOT NULL PRIMARY KEY);',
|
||||||
|
'ALTER TABLE "archive_capture" ADD COLUMN "source_id" uuid NOT NULL REFERENCES "archive_source" ("id") DEFERRABLE INITIALLY DEFERRED;',
|
||||||
|
]
|
||||||
|
errors, _, _ = self.analyse_sql(sql, has_unknown_data_operations=True)
|
||||||
|
self.assertTrue(any(error.code == "NOT_NULL" for error in errors))
|
||||||
|
|
||||||
|
def test_add_required_foreign_key_after_table_name_reuse(self):
|
||||||
|
sql = [
|
||||||
|
'CREATE TABLE "foo" ("id" uuid NOT NULL PRIMARY KEY);',
|
||||||
|
'ALTER TABLE "foo" RENAME TO "baz";',
|
||||||
|
'ALTER TABLE "bar" RENAME TO "foo";',
|
||||||
|
'ALTER TABLE "foo" ADD COLUMN "source_id" uuid NOT NULL REFERENCES "archive_source" ("id");',
|
||||||
|
]
|
||||||
|
self.assertBackwardIncompatibleSql(sql, "NOT_NULL")
|
||||||
|
|
||||||
def test_alter_column(self):
|
def test_alter_column(self):
|
||||||
sql = 'ALTER TABLE "app_alter_column_a" ALTER COLUMN "field" TYPE varchar(10) USING "field"::varchar(10);'
|
sql = 'ALTER TABLE "app_alter_column_a" ALTER COLUMN "field" TYPE varchar(10) USING "field"::varchar(10);'
|
||||||
self.assertBackwardIncompatibleSql(sql)
|
self.assertBackwardIncompatibleSql(sql)
|
||||||
|
|||||||
Reference in new issue
Block a user