Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ upgrade does is in [docs/upgrading.md](docs/upgrading.md), and

## [5.6.0] — 2026-10-23

A column rename keeps what reads the column: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).
Migrations keep the indexes and constraints a column change used to drop or rebuild: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).

### Changed answers

- Migrations: on PostgreSQL, changing a generated column's expression, its storage or a `tsvector` column's configuration drops the complex indexes, exclusion constraints, temporal constraints and temporal foreign keys on the column before the change and adds them after it. The migration dropped them with the column and never added them back ([#184](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/184), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-changed-generated-column-keeps-what-reads-it)).
- Migrations: renaming a column keeps the complex indexes, exclusion constraints and temporal constraints that read it, and renames a default name that includes the column. They were dropped and created again around the rename. SQL Server still re-creates an index whose filter reads the column ([#182](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/182), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-column-rename-keeps-what-reads-the-column)).

## [5.5.0] — 2026-10-20
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ names the operation's table as the target has it), so a renamed column no longer
everything that reads it. Only SQL this package renders from a column takes the new name — column
parts, placeholders, JSON extractions, INCLUDE entries, the implicit filter — while names are built
from the source's own columns (`ResolvedIndexPart.NameToken`), which turns a default name that
follows the column into a rename. Verbatim SQL is compared as written. SQL Server is the exception for filters: `sp_rename` rejects a column a filtered index reads, so the SQL Server differ's internal `RequiresIndexRecreation` overload, which also gets the filter's columns, rebuilds such an index around the rename (the implicit `IS NOT NULL` filter included). Because renames run after the base operations, a pair stays a
follows the column into a rename. Verbatim SQL is compared as written. SQL Server is the exception for filters: `sp_rename` rejects a column a filtered index reads, so the SQL Server differ's internal `RequiresIndexRecreation` overload, which also gets the filter's columns, rebuilds such an index around the rename (the implicit `IS NOT NULL` filter included). PostgreSQL has a case of its own (#184): Npgsql renders an `ALTER COLUMN` that changes a generated column's expression, storage or `tsvector` configuration as `DROP COLUMN` plus `ADD COLUMN`, and re-creates EF's indexes only. The Npgsql differ's `RequiresIndexRecreation` treats such an operation as in the way, and the exclusion and temporal diffs apply the same check (`RecreatedColumns`), a temporal FK going with a rebuilt principal constraint as it does for #143. Because renames run after the base operations, a pair stays a
rename only when nothing before it needs the old object gone: not a base operation it is in the way of
(`RequiresIndexRecreation`, #141), and not a name the target model gives to anything else in its namespace
(`CustomMigrationsModelDiffer.IsIndexNameInUse`, the Npgsql `TargetNames` inventory for constraints, #143). Otherwise it
Expand Down
35 changes: 33 additions & 2 deletions docs/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,9 @@ know the index is missing, so re-create it by hand with `migrationBuilder.Sql(

## Changed answers in 5.6.0

This changes only migrations that rename a column. A model you have not changed writes no
migration, and the snapshot stays as it is.
These change only migrations that rename a column or change a generated column. A model you have
not changed writes no migration, and the snapshot stays as it is. One of them, on PostgreSQL, also
asks you to check your database for objects an earlier migration dropped.

### A column rename keeps what reads the column

Expand Down Expand Up @@ -467,3 +468,33 @@ Good to know:
Otherwise `migrations add` fails and names the entry ([INCLUDE entries must name a column](#include-entries-must-name-a-column)).
- **Anything else that changed** in the same migration still drops and re-creates the index. The
drop runs before the rename and the create after it.

### A changed generated column keeps what reads it

On PostgreSQL, when you change a generated column's expression (`HasComputedColumnSql`), whether it
is stored, or a `HasGeneratedTsVectorColumn` configuration, the migration now drops the complex
indexes, exclusion constraints, temporal constraints and temporal foreign keys on that column before
the change and adds them again after it. PostgreSQL cannot change a generation expression in place,
so Npgsql drops the column and adds it again, which takes everything on the column with it. Npgsql
then re-creates EF Core's own indexes only. Before, the migration ran without an error and left this
package's indexes and constraints out. The snapshot still listed them, so no later migration added
them back. A generated range column used as the period of an exclusion or temporal constraint is
the usual case.

Check a database that ran such a migration. List what the table has:

```sql
SELECT indexname FROM pg_indexes WHERE tablename = '<table>';
SELECT conname FROM pg_constraint WHERE conrelid = '<table>'::regclass;
```

Compare it with what your model declares on the table. To add what is missing, take the statements
from the script `GenerateCreateScript()` writes with `UseNpgsqlComplexIndexes()`, and put them in a
new migration with `migrationBuilder.Sql(…)`. An exclusion or temporal constraint fails to add over
rows that overlap (23P01). Find and fix those rows first.

Good to know:

- **A column that becomes generated, or stops being one,** takes the same path.
- **A temporal foreign key** goes with the constraint it references when that constraint is rebuilt,
and is added back after it.
3 changes: 2 additions & 1 deletion src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ covers all three packages, which share one version number and release together.

## [5.6.0] — 2026-10-23

A column rename keeps what reads the column: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).
Migrations keep the indexes and constraints a column change used to drop or rebuild: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).

### Changed answers

- Migrations: on PostgreSQL, changing a generated column's expression, its storage or a `tsvector` column's configuration drops the complex indexes, exclusion constraints, temporal constraints and temporal foreign keys on the column before the change and adds them after it. The migration dropped them with the column and never added them back ([#184](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/184), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-changed-generated-column-keeps-what-reads-it)).
- Migrations: renaming a column keeps the complex indexes, exclusion constraints and temporal constraints that read it, and renames a default name that includes the column. They were dropped and created again around the rename. SQL Server still re-creates an index whose filter reads the column ([#182](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/182), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-column-rename-keeps-what-reads-the-column)).

## [5.5.0] — 2026-10-20
Expand Down
10 changes: 10 additions & 0 deletions src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlAnnotations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ internal static class NpgsqlAnnotations
public const string CreatedConcurrently = "Npgsql:CreatedConcurrently";
public const string NullsDistinct = "Npgsql:NullsDistinct";

/// <summary>
/// A generated <c>tsvector</c> column's text search configuration and source properties.
/// Npgsql's generator turns them into the column's generation expression, so a change to
/// either drops and re-adds the column (#184).
/// </summary>
public const string TsVectorConfig = "Npgsql:TsVectorConfig";

/// <inheritdoc cref="TsVectorConfig"/>
public const string TsVectorProperties = "Npgsql:TsVectorProperties";

/// <summary>
/// Per-column index collations. Npgsql's model key; on the operation Npgsql's generator reads
/// <c>Relational:Collation</c> instead, which the differ maps to (see
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,44 @@ protected override bool IsForwardedIndexAnnotation(string annotationName)
/// <summary>PostgreSQL renames indexes standalone (<c>ALTER INDEX … RENAME TO</c>).</summary>
protected override bool CanRenameIndexes => true;

/// <summary>
/// An <c>ALTER COLUMN</c> that Npgsql renders as <c>DROP COLUMN</c> plus <c>ADD COLUMN</c>
/// (<see cref="RecreatesColumn"/>), on a column the index reads: <c>DROP COLUMN</c> takes the
/// index with it, and Npgsql re-creates only EF Core's own (#184).
/// </summary>
protected override bool RequiresIndexRecreation(MigrationOperation operation, IReadOnlySet<string> columns)
=> operation is AlterColumnOperation alter && columns.Contains(alter.Name) && RecreatesColumn(alter);

/// <summary>
/// Whether Npgsql renders the operation as <c>DROP COLUMN</c> plus <c>ADD COLUMN</c>: PostgreSQL
/// cannot change a generated column's expression or storage in place. Npgsql decides on
/// <c>ComputedColumnSql</c> and <c>IsStored</c>, after filling them in for a <c>tsvector</c>
/// column from its configuration and properties, so those annotations count too
/// (<c>NpgsqlMigrationsSqlGenerator.Generate(AlterColumnOperation, …)</c>, Npgsql 10).
/// </summary>
private static bool RecreatesColumn(AlterColumnOperation alter)
=> alter.ComputedColumnSql != alter.OldColumn.ComputedColumnSql
|| alter.IsStored != alter.OldColumn.IsStored
|| !AnnotationValues.ValuesEqual(alter[NpgsqlAnnotations.TsVectorConfig], alter.OldColumn[NpgsqlAnnotations.TsVectorConfig])
|| !AnnotationValues.ValuesEqual(alter[NpgsqlAnnotations.TsVectorProperties], alter.OldColumn[NpgsqlAnnotations.TsVectorProperties]);

// The columns the base operations drop and add again, by table as the target names it.
private static HashSet<(string Table, string? Schema, string Column)> RecreatedColumns(IReadOnlyList<MigrationOperation> operations)
=> [.. operations.OfType<AlterColumnOperation>().Where(RecreatesColumn).Select(o => (o.Table, o.Schema, o.Name))];

// Whether a constraint of the target model reads a column the base operations drop and add
// again: one of its columns, or a column its expressions or filter name (ColumnsNamedIn, an
// over-approximation that costs at most a needless rebuild).
private static bool ReadsRecreatedColumn(
IRelationalModel target,
HashSet<(string Table, string? Schema, string Column)> recreated,
string table,
string? schema,
IEnumerable<string> columns,
string text = "")
=> recreated.Count > 0
&& columns.Concat(ColumnsNamedIn(target, table, schema, text)).Any(column => recreated.Contains((table, schema, column)));

/// <summary>
/// PostgreSQL keeps index names unique among the relations of a schema, so the core's own checks
/// already reject two complex indexes, or a complex and a native index, sharing a name anywhere
Expand Down Expand Up @@ -738,6 +776,18 @@ out bool needsBtreeGist

var pendingConstraintAdds = targetConstraints.Where(tgt => !normalizedConstraints.ContainsKey(tgt)).ToList();

// A column the base operations drop and add again takes the constraint, or the foreign key,
// with it (#184); a principal column that goes fails the drop instead (2BP01) while a
// foreign key references the constraint.
var recreated = RecreatedColumns(operations);
bool ConstraintInTheWay(TemporalDescriptor constraint)
=> target is not null
&& ReadsRecreatedColumn(target, recreated, constraint.Table, constraint.Schema, constraint.KeyColumns.Append(constraint.PeriodColumn));
bool ForeignKeyInTheWay(TemporalForeignKeyDescriptor foreignKey)
=> target is not null
&& ReadsRecreatedColumn(target, recreated, foreignKey.DependentTable, foreignKey.DependentSchema,
foreignKey.DependentColumns.Append(foreignKey.DependentPeriodColumn));

// Drop/add pairs identical except for the name — typically default-named constraints on a
// renamed table — become RENAME CONSTRAINT. Dependent foreign keys survive a rename, so
// they don't churn (DependsOnChangedTemporalConstraint compares names insensitively). A
Expand All @@ -757,7 +807,8 @@ out bool needsBtreeGist
continue;

if (target is not null
&& IsNameInUse(names ??= TargetNames(target), DefaultSchema(target), dropped.Name, renamed.Table, renamed.Schema, indexBacked: true))
&& (ConstraintInTheWay(renamed)
|| IsNameInUse(names ??= TargetNames(target), DefaultSchema(target), dropped.Name, renamed.Table, renamed.Schema, indexBacked: true)))
{
rebuilt.Add(renamed);
continue;
Expand All @@ -772,6 +823,18 @@ out bool needsBtreeGist
pendingConstraintAdds.Remove(renamed);
}

// An unchanged constraint in the way is dropped before the base operations and added after,
// and its foreign keys with it.
foreach (var (kept, origin) in normalizedConstraints)
{
if (targetConstraints.TryGetValue(kept, out var current) && !droppedTables.Contains(origin) && ConstraintInTheWay(current))
{
pendingConstraintDrops.Add(kept);
pendingConstraintAdds.Add(current);
rebuilt.Add(current);
}
}

bool DependsOnRebuiltConstraint(TemporalForeignKeyDescriptor foreignKey)
=> rebuilt.Any(c => IsPrincipalConstraintFor(foreignKey, c));

Expand All @@ -784,13 +847,15 @@ bool DependsOnRebuiltConstraint(TemporalForeignKeyDescriptor foreignKey)
.Where(src => (!droppedTables.Contains(normalizedForeignKeys[src]) || principalDropped.Contains(src))
&& (!targetForeignKeys.Contains(src)
|| DependsOnChangedTemporalConstraint(src, normalizedConstraints.Keys, targetConstraints)
|| DependsOnRebuiltConstraint(src)))
|| DependsOnRebuiltConstraint(src)
|| ForeignKeyInTheWay(src)))
.ToList();

var pendingForeignKeyAdds = targetForeignKeys
.Where(tgt => !normalizedForeignKeys.ContainsKey(tgt)
|| DependsOnChangedTemporalConstraint(tgt, normalizedConstraints.Keys, targetConstraints)
|| DependsOnRebuiltConstraint(tgt))
|| DependsOnRebuiltConstraint(tgt)
|| ForeignKeyInTheWay(tgt))
.ToList();

for (var i = pendingForeignKeyDrops.Count - 1; i >= 0; i--)
Expand Down Expand Up @@ -950,6 +1015,14 @@ out bool needsBtreeGist

var pendingAdds = targetConstraints.Where(tgt => !normalizedSource.ContainsKey(tgt)).ToList();

// A column the base operations drop and add again takes the constraint with it (#184).
var recreated = RecreatedColumns(operations);
bool InTheWay(ExclusionDescriptor constraint)
=> target is not null
&& ReadsRecreatedColumn(target, recreated, constraint.Table, constraint.Schema,
constraint.Parts.Where(p => !p.IsExpression).Select(p => p.Value),
string.Join(" ", constraint.Parts.Where(p => p.IsExpression).Select(p => p.Value).Append(constraint.Filter ?? "")));

// A drop/add pair identical except for the name — typically a default-named constraint on a
// renamed table — becomes RENAME CONSTRAINT: a catalog update instead of a gist rebuild.
// Placed after the base operations, which may themselves rename the table — and may create
Expand All @@ -966,7 +1039,8 @@ out bool needsBtreeGist
continue;

if (target is not null
&& IsNameInUse(names ??= TargetNames(target), DefaultSchema(target), dropped.Name, renamed.Table, renamed.Schema, indexBacked: true))
&& (InTheWay(renamed)
|| IsNameInUse(names ??= TargetNames(target), DefaultSchema(target), dropped.Name, renamed.Table, renamed.Schema, indexBacked: true)))
continue;

renames.Add(new SqlOperation
Expand All @@ -978,6 +1052,16 @@ out bool needsBtreeGist
pendingAdds.Remove(renamed);
}

// An unchanged constraint in the way is dropped before the base operations and added after.
foreach (var (kept, origin) in normalizedSource)
{
if (targetConstraints.TryGetValue(kept, out var current) && !droppedTables.Contains(origin) && InTheWay(current))
{
pendingDrops.Add(kept);
pendingAdds.Add(current);
}
}

var drops = new List<MigrationOperation>();
foreach (var src in pendingDrops)
{
Expand Down
2 changes: 1 addition & 1 deletion src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ covers all three packages, which share one version number and release together.

## [5.6.0] — 2026-10-23

A column rename keeps what reads the column: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).
Migrations keep the indexes and constraints a column change used to drop or rebuild: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).

### Changed answers

Expand Down
2 changes: 1 addition & 1 deletion src/EFCore.ComplexIndexes/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ covers all three packages, which share one version number and release together.

## [5.6.0] — 2026-10-23

A column rename keeps what reads the column: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).
Migrations keep the indexes and constraints a column change used to drop or rebuild: [Changed answers in 5.6.0](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#changed-answers-in-560).

### Changed answers

Expand Down
Loading
Loading