Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
c1c81eb
Test #499: remove @expectedFailure from rename+alter Meta.indexes tests
robberwick Mar 5, 2026
f19cff5
Fix #499: restore Meta.indexes when RenameField and AlterField in sam…
robberwick Mar 5, 2026
00ec61d
CHORE: fields_orders is an iterable of tuples
Aug 27, 2026
daa32ea
Test #499: cover stale Meta.indexes rebuilding
Sep 7, 2026
aa29f82
Fix #499: reconcile stale Meta indexes
Sep 7, 2026
b03cb8e
Merge branch 'dev' into rename-alter-meta-indexes
bewithgaurav Sep 8, 2026
d7cad43
Test #499: cover metadata index restoration
Sep 8, 2026
9cc58a5
Test #499: cover expression condition renames
Sep 8, 2026
d94f235
Fix #499: reconcile stale Meta indexes
Sep 8, 2026
ec45ac7
Merge branch 'dev' into rename-alter-meta-indexes
bewithgaurav Sep 8, 2026
59f9b69
Fix #499: reconcile reused stale index field names
Sep 8, 2026
3fe3943
Fix #499: preserve filtered index literals on rename
Sep 8, 2026
23aea2b
Test #499: document deferred filtered index rename limitation
Sep 8, 2026
d52e26d
Test #499: cover filtered index multiple renames
Sep 9, 2026
039c171
Test #499: isolate deferred filtered index regression
Sep 9, 2026
eea286b
Fix #499: compose filtered index rename replacements
Sep 9, 2026
2d19bc9
Chore: Add docstrings to DatabaseSchemaEditor helpers
Sep 9, 2026
851bdb2
Test #499: cover tuple RHS filtered index dependency
Sep 9, 2026
5361368
Fix #499: track tuple RHS filtered index expressions
Sep 9, 2026
cb86c1f
Test #499: cover bracketed filtered index literal
Sep 9, 2026
c5cadde
Fix #499: track filtered index renames structurally
Sep 9, 2026
bfb4e89
Fix: retain filtered Meta indexes after renames
Sep 10, 2026
ed81517
Fix: support unbound schema alter fields
Sep 10, 2026
e05e449
Fix: track deferred unique index columns
Sep 10, 2026
ff34916
Test #499: reproduce restart-boundary Meta.indexes state
Sep 10, 2026
3ede797
Fix #499: persist Meta.indexes through field renames
Sep 10, 2026
75b4622
Test #499: cover deferred filtered Meta.indexes rename
Sep 10, 2026
126626a
Fix #499: defer filtered Meta.index condition rendering
Sep 10, 2026
52eb4d0
Fix #499: retain Django 3.2 compatibility
Sep 11, 2026
713de64
Test #499: cover Django 3.2 predicate-only index rename
Sep 11, 2026
bd22a59
Fix #499: reconcile Django 3.2 index rename state
Sep 11, 2026
9affcaf
Test #499: cover deferred conditional unique rename
Sep 11, 2026
31805bb
Fix #499: retain deferred unique predicates structurally
Sep 11, 2026
2cfb824
Test #499: cover Meta.index lost after combined rename and alter
Sep 11, 2026
e51cf6f
Fix #499: restore Meta.indexes dropped by a combined rename and alter
Sep 11, 2026
9408b6c
Test #499: cover stale Meta.constraint after rename
Sep 11, 2026
0459576
Fix #499: sync Meta.constraints on field rename
Sep 11, 2026
190dec3
Test #499: cover optimizer-folded rename losing Meta.index
Sep 11, 2026
970dd7e
Fix #499: rewrite Meta.indexes/constraints when the optimizer folds a…
Sep 11, 2026
215cbfe
Test #499: cover tuple-wrapped and transformed F() rename
Sep 11, 2026
042d2ef
Fix #499: rewrite F() references in list/tuple RHS values and transforms
Sep 11, 2026
a899e93
Test #499: cover unbound replacement field in filtered-index restore
Sep 11, 2026
5ae691a
Fix #499: use the meta_model fallback for unbound replacement fields
Sep 11, 2026
28ec09e
Test #499: cover deferred Meta.index restore-vs-create collision
Sep 11, 2026
611ba83
Fix #499: track actually-dropped index names to avoid deferred-create…
Sep 11, 2026
32b231e
Test #499: cover pk-alias condition crashing an unrelated alter
Sep 11, 2026
df40a16
Fix #499: resolve the pk alias before index-condition field lookups
Sep 11, 2026
3554176
Test #499: strengthen transformed-F assertion and cover Django 3.2 co…
Sep 11, 2026
65e3ee1
Test #499: cover Meta.index lost after AutoField db_column rename
Sep 11, 2026
dc8f200
Fix #499: track AutoField-path dropped indexes and restore regardless…
Sep 11, 2026
85bf743
Test #499: cover covering UniqueConstraint losing INCLUDE semantics o…
Sep 11, 2026
0674cd4
Fix #499: give structured constraint restoration exclusive control ov…
Sep 11, 2026
0466cd9
Test #499: cover OR-condition guard bypass via CreateModel
Sep 11, 2026
da92af6
Fix #499: apply OR-condition guard to CreateModel deferred unique con…
Sep 11, 2026
3ebaee2
Test #499: pin include field db_column resolution for covering constr…
Sep 11, 2026
fd48d22
test: reproduce nested-OR and negated-Q unique constraint guard gaps
Sep 12, 2026
8cf1672
fix: recursively validate unique constraint conditions for nested OR/…
Sep 12, 2026
1e79fa7
test: reproduce lost db_index/unique_together/UniqueConstraint after …
Sep 12, 2026
3b663ab
fix: restore db_index/unique_together/UniqueConstraint after AutoFiel…
Sep 12, 2026
569378f
test: reproduce stale ForeignKey attname in renamed Meta.index/constr…
Sep 14, 2026
0ea1feb
fix: rewrite ForeignKey attname references in renamed Meta.index/cons…
Sep 14, 2026
70a837a
test: reproduce unresolved pk-alias in renamed FK-backed primary key …
Sep 14, 2026
70d37a4
fix: resolve pk alias in renamed-field condition membership checks
Sep 14, 2026
0662f6c
test: cover pk-alias condition on a plain Meta.Index (not just Unique…
Sep 14, 2026
9f050de
perf: fetch Meta.index catalog metadata in one query, not one per index
Sep 14, 2026
6d03096
Merge branch 'dev' into rename-alter-meta-indexes
bewithgaurav Sep 16, 2026
2021eb0
Merge branch 'dev' into rename-alter-meta-indexes
bewithgaurav Sep 24, 2026
d3bee44
Test #584: expose deferred unique-together collision
Sep 24, 2026
3b2868b
Fix #584: deduplicate deferred unique-together restore
Sep 24, 2026
72fdc0b
Test #619: expose conditional constraint alter failures
Sep 24, 2026
38dbaab
Fix #619: restore conditional constraints after alters
Sep 24, 2026
1be6cd5
Test #584: run deferred unique coverage on supported Django
Sep 24, 2026
9650e2d
Docs #584: document structured migration fixes
Sep 24, 2026
a631911
Test #584: expose stale positional expressions after renames
Sep 25, 2026
93127f3
Fix #584: rewrite positional expressions after renames
Sep 25, 2026
69a1dbc
Docs #584: scope changelog entry to migrations actually fixed
Sep 25, 2026
96f5d94
Test #584: cover renamed field in deferred unique_together condition
Sep 25, 2026
43b5ef0
Merge branch 'dev' into rename-alter-meta-indexes
bewithgaurav Sep 25, 2026
b49b6bb
CHORE: Cover squashmigrations in a fresh CLI process
bewithgaurav Sep 25, 2026
5256b9f
CHORE: Reuse the test database for squashmigrations
bewithgaurav Sep 25, 2026
8f6ce7e
Test #584: cover custom Index/UniqueConstraint subclasses with non-st…
Sep 25, 2026
84b8c9c
Fix #584: clone Meta index/constraint attributes instead of reconstru…
Sep 25, 2026
0308637
FIX: Preserve custom conditional constraint hooks
bewithgaurav Sep 25, 2026
a841bee
FIX: Preserve custom conditional index hooks
bewithgaurav Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 84 additions & 14 deletions mssql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Table,
)
from django import VERSION as django_version
from django.core.exceptions import FieldDoesNotExist
from django.db.models import NOT_PROVIDED, Index, UniqueConstraint
from django.db.models.fields import AutoField, BigAutoField
from django.db.models.fields.related import ForeignKey
Expand Down Expand Up @@ -427,7 +428,7 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# indexes from Meta.indexes are not restored. The _delete_indexes() method
# fails with FieldDoesNotExist because it looks up the index field by the
# old field name, but RenameField has already updated the model state.
# Tests (marked @expectedFailure):
# Tests:
# - test_index_from_meta_indexes_retained_after_rename_and_type_change
# - test_index_from_meta_indexes_retained_after_rename_and_nullability_change
#
Expand Down Expand Up @@ -785,8 +786,8 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# - Only if type changed OR nullability changed
# - Only if column was NOT renamed (rename is handled separately)
# Test:
# - test_index_from_meta_indexes_retained_after_rename_and_type_change (@expectedFailure)
# - test_index_from_meta_indexes_retained_after_rename_and_nullability_change (@expectedFailure)
# - test_index_from_meta_indexes_retained_after_rename_and_type_change
# - test_index_from_meta_indexes_retained_after_rename_and_nullability_change
#

# Restore indexes & unique constraints deleted above, SQL Server requires explicit restoration
Expand Down Expand Up @@ -950,12 +951,25 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# For other changes, only restore indexes involving the altered field.
# --------------------------------------------------------------------------------
for index in model._meta.indexes:
# Get the field objects for this index
index_fields = [model._meta.get_field(field_name) for field_name, _ in index.fields_orders]
index_columns_list = [field.column for field in index_fields]
# Get the column names for this index, resolving stale field names
# that may remain after a RenameField updated the model state but
# not the Index.fields list.
# See https://github.com/microsoft/mssql-django/issues/499
try:
index_fields = [model._meta.get_field(field_name) for field_name, _ in index.fields_orders]
index_columns_list = [field.column for field in index_fields]
except FieldDoesNotExist:
# A field name in index.fields_orders is stale (from a preceding RenameField).
# Resolve columns individually, falling back to new_field.column.
index_columns_list = []
for field_name, _ in index.fields_orders:
try:
index_columns_list.append(model._meta.get_field(field_name).column)
except FieldDoesNotExist:
index_columns_list.append(new_field.column)

# Restore if: AutoField change (all indexes dropped) OR field is in this index
if is_autofield_change or old_field.column in index_columns_list:
if is_autofield_change or old_field.column in index_columns_list or new_field.column in index_columns_list:
indexes_to_restore.append(index) # Store the Index object, not field list

# --------------------------------------------------------------------------------
Expand All @@ -964,13 +978,52 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# Restore Index objects using index.create_sql() to preserve explicit names
# and attributes.
#
# If an index has stale field names (from a preceding RenameField that
# updated the model state but not Index.fields), clone the index with
# corrected field names so that create_sql() can resolve them.
# See https://github.com/microsoft/mssql-django/issues/499
#
# Deduplication: Skip if already in deferred_sql or post_actions
# (which contains other_actions from _alter_column_type_sql).
# This prevents duplicate index creation if the same index
# was already scheduled elsewhere.
# --------------------------------------------------------------------------------
for index in indexes_to_restore:
create_index_sql_statement = index.create_sql(model, self)
# Fix stale field names in index.fields before calling create_sql()
restored_index = index
has_stale_fields = False
for field_name in index.fields:
try:
model._meta.get_field(field_name)
except FieldDoesNotExist:
has_stale_fields = True
break
if has_stale_fields:
# Build corrected field names preserving ordering prefixes.
# index.fields stores raw strings like ['-a', 'b'] where '-'
# means descending. We need to replace just the name part
# while keeping the prefix, because Index.__init__ derives
# fields_orders (used by create_sql) from the raw strings.
corrected_fields = []
for raw_field in index.fields:
# Strip optional '-' prefix to get the bare field name
if raw_field.startswith('-'):
prefix = '-'
bare_name = raw_field[1:]
else:
prefix = ''
bare_name = raw_field
try:
model._meta.get_field(bare_name)
corrected_fields.append(raw_field)
except FieldDoesNotExist:
corrected_fields.append(prefix + new_field.name)
Comment thread
bewithgaurav marked this conversation as resolved.
Outdated
# Reconstruct via deconstruct() so fields_orders is correct
_, args, kwargs = index.deconstruct()
kwargs['fields'] = corrected_fields
restored_index = index.__class__(*args, **kwargs)

create_index_sql_statement = restored_index.create_sql(model, self)
if create_index_sql_statement and (str(create_index_sql_statement)
not in [str(sql) for sql in self.deferred_sql] + [str(statement[0]) for statement in post_actions]
):
Expand Down Expand Up @@ -1092,6 +1145,23 @@ def _delete_indexes(self, model, old_field, new_field):
return
index_columns = []
index_names = []

# After a RenameField + AlterField in the same migration, the model state
# has the new field name but Index.fields / index_together / unique_together
# may still reference the old field name (Django's rename_field() updates
# index_together and unique_together but NOT Meta.indexes). Additionally,
# sp_rename has already executed so the DB column uses new_field.column.
# This helper resolves a field name to its DB column, falling back to
# new_field.column when the field name is stale (FieldDoesNotExist).
# See https://github.com/microsoft/mssql-django/issues/499
def _resolve_column(field_name):
try:
return model._meta.get_field(field_name).column
except FieldDoesNotExist:
# The field was renamed by a preceding RenameField; the old name
# is stale. Use new_field.column since sp_rename has already run.
return new_field.column
Comment thread
bewithgaurav marked this conversation as resolved.
Outdated

if old_field.db_index and new_field.db_index:
index_columns.append([old_field.column])
elif old_field.null != new_field.null:
Expand All @@ -1101,19 +1171,19 @@ def _delete_indexes(self, model, old_field, new_field):
# Iterate over each set of field names defined in index_together
for fields in model._meta.index_together:
# Get the actual column names for each field in the set
columns = [model._meta.get_field(field).column for field in fields]
columns = [_resolve_column(field) for field in fields]
Comment thread
bewithgaurav marked this conversation as resolved.
Outdated
# If the old field's column is among these columns, add to index_columns for later index deletion
if old_field.column in columns:
if old_field.column in columns or new_field.column in columns:
index_columns.append(columns)

for index in model._meta.indexes:
columns = [model._meta.get_field(field_name).column for field_name, _ in index.fields_orders]
if old_field.column in columns:
columns = [_resolve_column(field) for field, _ in index.fields_orders]
if old_field.column in columns or new_field.column in columns:
index_columns.append(columns)

for fields in model._meta.unique_together:
columns = [model._meta.get_field(field).column for field in fields]
if old_field.column in columns:
columns = [_resolve_column(field) for field in fields]
if old_field.column in columns or new_field.column in columns:
index_columns.append(columns)
if index_columns:
# remove duplicates first
Expand Down
16 changes: 2 additions & 14 deletions testapp/tests/test_indexes.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,18 +599,12 @@ def test_index_from_meta_indexes_retained_after_field_rename(self):
),
)

@expectedFailure
def test_index_from_meta_indexes_retained_after_rename_and_type_change(self):
"""
Test that indexes from Meta.indexes are retained when a field is renamed
AND has its type changed in the same migration.

This tests a known bug: the TYPE CHANGE PATH drops indexes, but the
RESTORE PHASE is skipped when column is renamed (old_field.column != new_field.column).

Additionally, _delete_indexes() fails with FieldDoesNotExist because it tries to
look up the index field by the old field name, but RenameField has already updated
the model state, so the old field name no longer exists.
Regression test for https://github.com/microsoft/mssql-django/issues/499

Runs with both split and combined migrations
"""
Expand Down Expand Up @@ -830,18 +824,12 @@ def test_unique_together_retained_after_rename_and_type_change(self):
f"Expected unique_together to be retained."
)

@expectedFailure
def test_index_from_meta_indexes_retained_after_rename_and_nullability_change(self):
"""
Test that indexes from Meta.indexes are retained when a field is renamed
AND has its nullability changed in the same migration.

This tests a known bug: the NULLABILITY CHANGE PATH drops indexes, but the
RESTORE PHASE is skipped when column is renamed (old_field.column != new_field.column).

Additionally, _delete_indexes() fails with FieldDoesNotExist because it tries to
look up the index field by the old field name, but RenameField has already updated
the model state, so the old field name no longer exists.
Regression test for https://github.com/microsoft/mssql-django/issues/499

Runs with both split and combined migrations
"""
Expand Down
Loading