Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
248 changes: 187 additions & 61 deletions mssql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
# Licensed under the BSD license.

import binascii
import copy
import datetime


from collections import defaultdict

from django.db.backends.base.schema import (
Expand All @@ -19,6 +21,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 @@ -422,16 +425,8 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
#
#
# KNOWN BUGS/LIMITATIONS:
# 1. Rename + alter in same migration: When a field is renamed (via RenameField)
# AND has a type or nullability change (via AlterField) in the same migration,
# 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):
# - test_index_from_meta_indexes_retained_after_rename_and_type_change
# - test_index_from_meta_indexes_retained_after_rename_and_nullability_change
#
# 2. unique_together + unique=True: When a field has BOTH unique=True AND

# 1. unique_together + unique=True: When a field has BOTH unique=True AND
# participates in unique_together, only the single-field unique constraint
# is restored after field alteration. The unique_together constraint is NOT
# restored because the restoration code is in an 'else' block that only
Expand Down Expand Up @@ -517,6 +512,8 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
)
for fk_name in rel_fk_names:
self.execute(self._delete_constraint_sql(self.sql_delete_fk, new_rel.related_model, fk_name))
meta_index_replacements = self._get_meta_index_replacements(model)

# If working with an AutoField or BigAutoField drop all indexes on the related table
# This is needed when doing ALTER column statements on IDENTITY fields
# https://stackoverflow.com/questions/33429775/sql-server-alter-table-alter-column-giving-set-option-error
Expand Down Expand Up @@ -638,7 +635,11 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# Drop unique constraint, SQL Server requires explicit deletion
self._delete_unique_constraints(model, old_field, new_field, strict)
# Drop indexes, SQL Server requires explicit deletion
self._delete_indexes(model, old_field, new_field)
self._delete_indexes(
model, old_field, new_field,
meta_index_replacements=meta_index_replacements,
)

# db_default change?
if django_version >= (5,0):
if new_field.db_default is not NOT_PROVIDED:
Expand Down Expand Up @@ -690,7 +691,11 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
# Drop unique constraint, SQL Server requires explicit deletion
self._delete_unique_constraints(model, old_field, new_field, strict)
# Drop indexes, SQL Server requires explicit deletion
self._delete_indexes(model, old_field, new_field)
self._delete_indexes(
model, old_field, new_field,
meta_index_replacements=meta_index_replacements,
)


# ================================================================================
# 3. Column alteration
Expand Down Expand Up @@ -785,8 +790,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,32 +955,44 @@ 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]

# 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:
indexes_to_restore.append(index) # Store the Index object, not field list
replacements = meta_index_replacements.get(index.name)
Comment thread
Copilot marked this conversation as resolved.
Outdated
try:
index_fields = [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name, _ in index.fields_orders
]
included_fields = [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name in index.include
]
condition_fields = [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name in self._get_condition_field_names(index.condition)
]
except (AttributeError, FieldDoesNotExist):
# An unresolved reference isn't evidence that it names the field
# currently being altered. Its index was already removed or has
# never existed, so never recreate it with a different field.
continue

# --------------------------------------------------------------------------------
# Execute restoration: Meta.indexes
# --------------------------------------------------------------------------------
# Restore Index objects using index.create_sql() to preserve explicit names
# and attributes.
#
# 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)
index_columns_list = [
field.column for field in index_fields + included_fields + condition_fields
]
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, replacements))

# Restore Index objects using index.create_sql() to preserve explicit
# names and attributes. Stale references are repaired only when the
# existing physical index supplied an unambiguous replacement.
for index, replacements in indexes_to_restore:
restored_index = self._clone_index_with_replacements(index, replacements)
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]
):
self.execute(create_index_sql_statement)


# Type alteration on primary key? Then we need to alter the column
# referring to us.
rels_to_update = []
Expand Down Expand Up @@ -1082,51 +1099,160 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
if self.connection.features.connection_persists_old_columns:
self.connection.close()

def _delete_indexes(self, model, old_field, new_field):
def _get_index_metadata(self, model, index_name):
with self.connection.cursor() as cursor:
cursor.execute(
"""
SELECT ic.is_included_column, c.name, i.filter_definition
FROM sys.indexes AS i
INNER JOIN sys.index_columns AS ic
ON i.object_id = ic.object_id AND i.index_id = ic.index_id
INNER JOIN sys.columns AS c
ON ic.object_id = c.object_id AND ic.column_id = c.column_id
WHERE i.object_id = OBJECT_ID(%s) AND i.name = %s
ORDER BY ic.key_ordinal, ic.index_column_id
""",
[model._meta.db_table, index_name],
)
return cursor.fetchall()

def _get_condition_field_names(self, condition):
if condition is None:
return []
field_names = []
for child in condition.children:
if hasattr(child, 'children'):
field_names.extend(self._get_condition_field_names(child))
else:
field_names.append(child[0].split('__', 1)[0])
Comment thread
Copilot marked this conversation as resolved.
return field_names

def _get_meta_index_replacements(self, model):
fields_by_column = {field.column: field for field in model._meta.fields}
replacements = {}
for index in model._meta.indexes:
field_names = [field_name for field_name, _ in index.fields_orders]
include_names = list(index.include)
condition_names = self._get_condition_field_names(index.condition)
reference_names = field_names + include_names + condition_names
if all(name in model._meta._forward_fields_map for name in reference_names):
continue
Comment thread
Copilot marked this conversation as resolved.
Outdated

metadata = self._get_index_metadata(model, index.name)
if not metadata:
continue
key_columns = [column for included, column, _ in metadata if not included]
included_columns = [column for included, column, _ in metadata if included]
if len(field_names) != len(key_columns) or len(include_names) != len(included_columns):
continue

index_replacements = {}
for name, column in zip(field_names + include_names, key_columns + included_columns):
try:
field = model._meta.get_field(name)
except FieldDoesNotExist:
field = fields_by_column.get(column)
if field is None:
break
index_replacements[name] = field.name
else:
if field.column != column:
break
else:
filter_definition = metadata[0][2]
condition_columns = {
column for column in fields_by_column
if f"[{column.replace(']', ']]')}]" in (filter_definition or '')
}
for name in condition_names:
try:
condition_columns.remove(model._meta.get_field(name).column)
except FieldDoesNotExist:
if name not in index_replacements and len(condition_columns) == 1:
index_replacements[name] = fields_by_column[condition_columns.pop()].name
if all(name in index_replacements or name in model._meta._forward_fields_map for name in reference_names):
replacements[index.name] = index_replacements
return replacements

def _clone_index_with_replacements(self, index, replacements):
if not replacements:
return index
_, args, kwargs = index.deconstruct()
kwargs['fields'] = [
('-' if field_name.startswith('-') else '') + replacements.get(field_name.lstrip('-'), field_name.lstrip('-'))
for field_name in index.fields
]
if index.include:
kwargs['include'] = [replacements.get(field_name, field_name) for field_name in index.include]
if index.condition:
condition = copy.deepcopy(index.condition)
self._replace_condition_field_names(condition, replacements)
kwargs['condition'] = condition
return index.__class__(*args, **kwargs)

def _replace_condition_field_names(self, condition, replacements):
for index, child in enumerate(condition.children):
if hasattr(child, 'children'):
self._replace_condition_field_names(child, replacements)
else:
field_name, lookup = child[0].split('__', 1) if '__' in child[0] else (child[0], '')
if field_name in replacements:
condition.children[index] = (
replacements[field_name] + ('__' + lookup if lookup else ''), child[1]
)

def _delete_indexes(self, model, old_field, new_field, meta_index_replacements=None):
if (
django_version >= (4, 2)
and isinstance(new_field, ForeignKey)
and type(new_field.db_comment) != type(None)
and "fk_on_delete_keep_index" in new_field.db_comment
):
return
return []
if isinstance(old_field, (AutoField, BigAutoField)) or isinstance(new_field, (AutoField, BigAutoField)):
return []
Comment thread
Copilot marked this conversation as resolved.

meta_index_replacements = meta_index_replacements or {}
index_columns = []
index_names = []
if old_field.db_index and new_field.db_index:
index_columns.append([old_field.column])
elif old_field.null != new_field.null:
index_columns.append([old_field.column])
# Handle index_together for only django version < 5.1
if django_version < (5, 1):
# 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]
# If the old field's column is among these columns, add to index_columns for later index deletion
if old_field.column in columns:
index_columns.append(columns)
if django_version < (5, 1):
for fields in model._meta.index_together:
columns = [model._meta.get_field(field).column for field in fields]
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:
index_columns.append(columns)
replacements = meta_index_replacements.get(index.name, {})
try:
fields = [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name, _ in index.fields_orders
]
fields += [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name in index.include
]
fields += [
model._meta.get_field(replacements.get(field_name, field_name))
for field_name in self._get_condition_field_names(index.condition)
]
except FieldDoesNotExist:
continue
if old_field.column in [field.column for field in fields] or new_field.column in [field.column for field in fields]:
index_names.append(index.name)

for fields in model._meta.unique_together:
columns = [model._meta.get_field(field).column for field in fields]
if old_field.column in columns:
if old_field.column in columns or new_field.column in columns:
index_columns.append(columns)
if index_columns:
# remove duplicates first
temp = []
for columns in index_columns:
if columns not in temp:
temp.append(columns)
index_columns = temp

for columns in index_columns:
index_names = self._constraint_names(model, columns, index=True)
for index_name in index_names:
self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
for columns in {tuple(columns) for columns in index_columns}:
index_names.extend(self._constraint_names(model, columns, index=True))
for index_name in set(index_names):
self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
Comment thread
Copilot marked this conversation as resolved.
Outdated
return index_names

def _delete_unique_constraints(self, model, old_field, new_field, strict=False):
Expand Down
Loading
Loading