diff --git a/CHANGELOG.md b/CHANGELOG.md
index 726d221..66de9e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 73a285c..596a736 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/docs/upgrading.md b/docs/upgrading.md
index 218d028..a93f13a 100644
--- a/docs/upgrading.md
+++ b/docs/upgrading.md
@@ -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
@@ -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 = '
';
+SELECT conname FROM pg_constraint WHERE conrelid = ''::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.
diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md
index 38c2e51..18fe048 100644
--- a/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md
+++ b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md
@@ -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
diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlAnnotations.cs b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlAnnotations.cs
index 464f5a9..881e1b0 100644
--- a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlAnnotations.cs
+++ b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlAnnotations.cs
@@ -14,6 +14,16 @@ internal static class NpgsqlAnnotations
public const string CreatedConcurrently = "Npgsql:CreatedConcurrently";
public const string NullsDistinct = "Npgsql:NullsDistinct";
+ ///
+ /// A generated tsvector 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).
+ ///
+ public const string TsVectorConfig = "Npgsql:TsVectorConfig";
+
+ ///
+ public const string TsVectorProperties = "Npgsql:TsVectorProperties";
+
///
/// Per-column index collations. Npgsql's model key; on the operation Npgsql's generator reads
/// Relational:Collation instead, which the differ maps to (see
diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs
index 7e4e833..594e0c4 100644
--- a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs
+++ b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs
@@ -87,6 +87,44 @@ protected override bool IsForwardedIndexAnnotation(string annotationName)
/// PostgreSQL renames indexes standalone (ALTER INDEX … RENAME TO).
protected override bool CanRenameIndexes => true;
+ ///
+ /// An ALTER COLUMN that Npgsql renders as DROP COLUMN plus ADD COLUMN
+ /// (), on a column the index reads: DROP COLUMN takes the
+ /// index with it, and Npgsql re-creates only EF Core's own (#184).
+ ///
+ protected override bool RequiresIndexRecreation(MigrationOperation operation, IReadOnlySet columns)
+ => operation is AlterColumnOperation alter && columns.Contains(alter.Name) && RecreatesColumn(alter);
+
+ ///
+ /// Whether Npgsql renders the operation as DROP COLUMN plus ADD COLUMN: PostgreSQL
+ /// cannot change a generated column's expression or storage in place. Npgsql decides on
+ /// ComputedColumnSql and IsStored, after filling them in for a tsvector
+ /// column from its configuration and properties, so those annotations count too
+ /// (NpgsqlMigrationsSqlGenerator.Generate(AlterColumnOperation, …), Npgsql 10).
+ ///
+ 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 operations)
+ => [.. operations.OfType().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 columns,
+ string text = "")
+ => recreated.Count > 0
+ && columns.Concat(ColumnsNamedIn(target, table, schema, text)).Any(column => recreated.Contains((table, schema, column)));
+
///
/// 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
@@ -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
@@ -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;
@@ -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));
@@ -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--)
@@ -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
@@ -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
@@ -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();
foreach (var src in pendingDrops)
{
diff --git a/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md
index 8746b1e..58e521b 100644
--- a/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md
+++ b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md
@@ -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
diff --git a/src/EFCore.ComplexIndexes/CHANGELOG.md b/src/EFCore.ComplexIndexes/CHANGELOG.md
index cefdf7f..b64edd4 100644
--- a/src/EFCore.ComplexIndexes/CHANGELOG.md
+++ b/src/EFCore.ComplexIndexes/CHANGELOG.md
@@ -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
diff --git a/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs b/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs
index 03b999d..1dc4523 100644
--- a/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs
+++ b/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs
@@ -300,7 +300,7 @@ public override IReadOnlyList GetDifferences(
bool InTheWay(IndexDescriptor current)
{
var columns = ColumnsReadBy(target!, current);
- var filterColumns = ColumnsNamedIn(target!, current, current.Filter ?? "");
+ var filterColumns = ColumnsNamedIn(target!, current.TableName, current.Schema, current.Filter ?? "");
return operations.Any(o => o is ITableMigrationOperation table
&& table.Table == current.TableName
&& table.Schema == current.Schema
@@ -657,14 +657,15 @@ private static HashSet ColumnsReadBy(IRelationalModel target, IndexDescr
columns.UnionWith(names);
var text = string.Join(" ", index.Parts.Where(p => p.IsExpression).Select(p => p.Value).Append(index.Filter ?? ""));
- columns.UnionWith(ColumnsNamedIn(target, index, text));
+ columns.UnionWith(ColumnsNamedIn(target, index.TableName, index.Schema, text));
return columns;
}
// The table's columns whose names occur in the text: an over-approximation, like ColumnsReadBy.
- private static HashSet ColumnsNamedIn(IRelationalModel target, IndexDescriptor index, string text)
- => text.Length > 0 && target.FindTable(index.TableName, index.Schema) is { } table
+ // Internal for the PostgreSQL differ's constraints, which read columns the same way (#184).
+ internal static HashSet ColumnsNamedIn(IRelationalModel target, string tableName, string? schema, string text)
+ => text.Length > 0 && target.FindTable(tableName, schema) is { } table
? table.Columns.Select(c => c.Name).Where(name => text.Contains(name, StringComparison.Ordinal)).ToHashSet(StringComparer.Ordinal)
: [];
diff --git a/test/EFCore.ComplexIndexes.Tests/GeneratedColumnRecreationTests.cs b/test/EFCore.ComplexIndexes.Tests/GeneratedColumnRecreationTests.cs
new file mode 100644
index 0000000..636ccd4
--- /dev/null
+++ b/test/EFCore.ComplexIndexes.Tests/GeneratedColumnRecreationTests.cs
@@ -0,0 +1,200 @@
+using EFCore.ComplexIndexes.PostgreSQL;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Migrations.Operations;
+using NpgsqlTypes;
+
+namespace EFCore.ComplexIndexes.Tests;
+
+///
+/// PostgreSQL cannot change a generated column's expression in place, so Npgsql renders an
+/// ALTER COLUMN that changes it, its storage or a tsvector column's configuration as
+/// DROP COLUMN plus ADD COLUMN, and re-creates only EF Core's own indexes on the column.
+/// DROP COLUMN takes every index and constraint that reads the column with it. Until #184 the
+/// declarations were unchanged, so the differs emitted nothing: the migration ran without an error,
+/// the snapshot still listed the indexes and constraints, and the database had none of them. They
+/// are now dropped before the base operations and created again after them.
+///
+[TestClass]
+public class GeneratedColumnRecreationTests
+{
+ private class EmailAddress
+ {
+ public string Value { get; set; } = "";
+ }
+
+ private class Booking
+ {
+ public int Id { get; set; }
+ public int RoomId { get; set; }
+ public string Code { get; set; } = "";
+ public DateTime Start { get; set; }
+ public DateTime End { get; set; }
+ public string Search { get; set; } = "";
+ public NpgsqlRange During { get; set; }
+ public NpgsqlTsVector Words { get; set; } = null!;
+ public EmailAddress Email { get; set; } = new();
+ }
+
+ private class Stay
+ {
+ public int Id { get; set; }
+ public string Code { get; set; } = "";
+ public DateTime From { get; set; }
+ public DateTime To { get; set; }
+ public NpgsqlRange Period { get; set; }
+ }
+
+ private abstract class BookingContext(DbContextOptions options) : DbContext(options)
+ {
+ protected virtual string SearchSql => "lower(\"Code\")";
+ protected virtual string DuringSql => "tstzrange(\"Start\", \"End\")";
+ protected virtual string PeriodSql => "tstzrange(\"From\", \"To\")";
+ protected virtual string WordsConfig => "english";
+ protected virtual string? RoomIdSql => null;
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("bookings");
+ b.Property(x => x.Search).HasComputedColumnSql(SearchSql, stored: true);
+ b.Property(x => x.During).HasComputedColumnSql(DuringSql, stored: true);
+ b.HasGeneratedTsVectorColumn(x => x.Words, WordsConfig, x => new { x.Code });
+ if (RoomIdSql is { } roomId)
+ b.Property(x => x.RoomId).HasComputedColumnSql(roomId, stored: true);
+ b.ComplexProperty(x => x.Email);
+
+ b.HasComplexCompositeIndex(x => new { x.Search, x.Email.Value }, indexName: "ix_bookings_search_email");
+ b.HasComplexIndex(x => x.Code, filter: "{Search} <> ''", indexName: "ix_bookings_code_searchable");
+ b.HasComplexIndex(x => x.Words, ix => ix.HasName("ix_bookings_words").UseGin());
+ b.HasComplexIndex(x => x.RoomId, indexName: "ix_bookings_room");
+ b.HasComplexIndex(x => x.Email.Value, indexName: "ix_bookings_email");
+
+ b.HasExclusionConstraint(x => x.RoomId, x => x.During, name: "ex_bookings_room_during");
+ b.HasExclusionConstraint(ex => ex.WithEquality(x => x.Email.Value).WithExpression("tstzrange(\"Start\", \"End\")", "&&").HasName("ex_bookings_email"));
+ b.HasTemporalConstraint(x => x.Code, x => x.During, name: "tc_bookings_code");
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("stays");
+ b.Property(x => x.Period).HasComputedColumnSql(PeriodSql, stored: true);
+ b.HasTemporalForeignKey(x => x.Code, x => x.Period, x => x.Code, x => x.During, name: "fk_stays_booking");
+ });
+ }
+ }
+
+ private class Before(DbContextOptions options) : BookingContext(options);
+
+ private class SearchChanged(DbContextOptions options) : BookingContext(options)
+ {
+ protected override string SearchSql => "upper(\"Code\")";
+ }
+
+ private class DuringChanged(DbContextOptions options) : BookingContext(options)
+ {
+ protected override string DuringSql => "tstzrange(\"Start\", \"End\", '[]')";
+ }
+
+ private class PeriodChanged(DbContextOptions options) : BookingContext(options)
+ {
+ protected override string PeriodSql => "tstzrange(\"From\", \"To\", '[]')";
+ }
+
+ private class WordsChanged(DbContextOptions options) : BookingContext(options)
+ {
+ protected override string WordsConfig => "simple";
+ }
+
+ private class RoomIdGenerated(DbContextOptions options) : BookingContext(options)
+ {
+ protected override string RoomIdSql => "length(\"Code\")";
+ }
+
+ private static List Diff() where TTarget : DbContext
+ => MigrationHarness.NpgsqlDiff(MigrationHarness.NpgsqlModel(), MigrationHarness.NpgsqlModel()).ToList();
+
+ // The names this package's operations drop, in order, and the SQL they add, by name.
+ private static string[] Dropped(List operations, int before)
+ => [.. operations.Take(before).Select(o => o switch
+ {
+ DropIndexOperation drop => drop.Name,
+ DropUniqueConstraintOperation drop => drop.Name,
+ DropForeignKeyOperation drop => drop.Name,
+ SqlOperation sql when sql.Sql.Contains("DROP CONSTRAINT IF EXISTS") => sql.Sql.Split('"')[^2],
+ _ => null
+ }).OfType()];
+
+ private static string[] Created(List operations, int after)
+ => [.. operations.Skip(after + 1).Select(o => o switch
+ {
+ CreateIndexOperation create => create.Name,
+ SqlOperation sql when sql.Sql.Contains("ADD CONSTRAINT") => sql.Sql.Split("ADD CONSTRAINT ")[1].Split('"')[1],
+ _ => null
+ }).OfType()];
+
+ private static int AlterOf(List operations, string table, string column)
+ => operations.FindIndex(o => o is AlterColumnOperation alter && alter.Table == table && alter.Name == column);
+
+ [TestMethod]
+ public void A_changed_expression_rebuilds_the_indexes_that_read_the_column_around_it()
+ {
+ var operations = Diff();
+ var alter = AlterOf(operations, "bookings", "Search");
+
+ Assert.IsTrue(alter >= 0);
+ CollectionAssert.AreEquivalent(new[] { "ix_bookings_search_email", "ix_bookings_code_searchable" }, Dropped(operations, alter));
+ CollectionAssert.AreEquivalent(new[] { "ix_bookings_search_email", "ix_bookings_code_searchable" }, Created(operations, alter));
+ }
+
+ [TestMethod]
+ public void A_changed_period_rebuilds_the_constraints_on_it_and_the_temporal_foreign_key_that_references_one()
+ {
+ var operations = Diff();
+ var alter = AlterOf(operations, "bookings", "During");
+
+ Assert.IsTrue(alter >= 0);
+ var dropped = Dropped(operations, alter);
+ CollectionAssert.AreEquivalent(new[] { "fk_stays_booking", "tc_bookings_code", "ex_bookings_room_during" }, dropped);
+ Assert.IsTrue(Array.IndexOf(dropped, "fk_stays_booking") < Array.IndexOf(dropped, "tc_bookings_code"),
+ "The foreign key goes before the constraint it references (2BP01).");
+
+ var created = Created(operations, alter);
+ CollectionAssert.AreEquivalent(new[] { "ex_bookings_room_during", "tc_bookings_code", "fk_stays_booking" }, created);
+ Assert.IsTrue(Array.IndexOf(created, "tc_bookings_code") < Array.IndexOf(created, "fk_stays_booking"));
+ }
+
+ [TestMethod]
+ public void A_changed_dependent_period_rebuilds_the_temporal_foreign_key_only()
+ {
+ var operations = Diff();
+ var alter = AlterOf(operations, "stays", "Period");
+
+ Assert.IsTrue(alter >= 0);
+ CollectionAssert.AreEqual(new[] { "fk_stays_booking" }, Dropped(operations, alter));
+ CollectionAssert.AreEqual(new[] { "fk_stays_booking" }, Created(operations, alter));
+ }
+
+ [TestMethod]
+ public void A_changed_tsvector_configuration_rebuilds_the_index_on_the_column()
+ {
+ var operations = Diff();
+ var alter = AlterOf(operations, "bookings", "Words");
+
+ Assert.IsTrue(alter >= 0);
+ CollectionAssert.AreEqual(new[] { "ix_bookings_words" }, Dropped(operations, alter));
+ CollectionAssert.AreEqual(new[] { "ix_bookings_words" }, Created(operations, alter));
+ }
+
+ [TestMethod]
+ public void A_column_that_becomes_generated_rebuilds_what_reads_it()
+ {
+ var operations = Diff();
+ var alter = AlterOf(operations, "bookings", "RoomId");
+
+ Assert.IsTrue(alter >= 0);
+ CollectionAssert.AreEquivalent(new[] { "ix_bookings_room", "ex_bookings_room_during" }, Dropped(operations, alter));
+ CollectionAssert.AreEquivalent(new[] { "ix_bookings_room", "ex_bookings_room_during" }, Created(operations, alter));
+ }
+}
diff --git a/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.GeneratedColumns.cs b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.GeneratedColumns.cs
new file mode 100644
index 0000000..440da0e
--- /dev/null
+++ b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.GeneratedColumns.cs
@@ -0,0 +1,84 @@
+using EFCore.ComplexIndexes.PostgreSQL;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using NpgsqlTypes;
+
+namespace EFCore.ComplexIndexes.Tests;
+
+public partial class PostgresIntegrationTests
+{
+ // ── A changed generation expression keeps what reads the column (#184) ──
+
+ // Npgsql renders the change as DROP COLUMN plus ADD COLUMN, which takes every index and
+ // constraint on the column with it, and re-creates only EF Core's own indexes.
+
+ private class GeneratedBooking
+ {
+ public int Id { get; set; }
+ public int RoomId { get; set; }
+ public string Code { get; set; } = "";
+ public DateTime Start { get; set; }
+ public DateTime End { get; set; }
+ public string Search { get; set; } = "";
+ public NpgsqlRange During { get; set; }
+ }
+
+ private class GeneratedStay
+ {
+ public int Id { get; set; }
+ public string Code { get; set; } = "";
+ public NpgsqlRange Period { get; set; }
+ }
+
+ private abstract class GeneratedColumnContext(DbContextOptions options, string searchSql, string duringSql) : DbContext(options)
+ {
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("gc_bookings");
+ b.Property(x => x.Search).HasComputedColumnSql(searchSql, stored: true);
+ b.Property(x => x.During).HasComputedColumnSql(duringSql, stored: true);
+ b.HasIndex(x => x.Search).HasDatabaseName("gc_ix_native_search");
+
+ b.HasComplexIndex(x => x.Search, isUnique: true, indexName: "gc_ux_search");
+ b.HasExclusionConstraint(x => x.RoomId, x => x.During, name: "gc_ex_room_during");
+ b.HasTemporalConstraint(x => x.Code, x => x.During, name: "gc_uq_code_during");
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.ToTable("gc_stays");
+ b.HasTemporalForeignKey(x => x.Code, x => x.Period, x => x.Code, x => x.During, name: "gc_fk_stays");
+ });
+ }
+ }
+
+ private class GeneratedColumnV1Context(DbContextOptions options)
+ : GeneratedColumnContext(options, "lower(\"Code\")", "tstzrange(\"Start\", \"End\")");
+
+ private class GeneratedColumnV2Context(DbContextOptions options)
+ : GeneratedColumnContext(options, "upper(\"Code\")", "tstzrange(\"Start\", \"End\", '[]')");
+
+ [TestMethod(DisplayName = "A changed generation expression keeps the indexes and constraints on the column (#184)")]
+ public void Changed_generation_expression_keeps_indexes_and_constraints()
+ {
+ var v1 = BuildRelationalModel();
+ Apply(GetDifferences(null, v1));
+
+ var v2 = BuildRelationalModel();
+ ApplyInTransaction(GetDifferences(v1, v2), v2.Model);
+
+ Assert.IsNotNull(RelationOid("gc_ix_native_search"), "EF Core's own index, which Npgsql re-creates.");
+ Assert.IsNotNull(RelationOid("gc_ux_search"), "The complex index went with the column.");
+ Assert.AreEqual("x", ConstraintType("gc_bookings", "gc_ex_room_during"));
+ Assert.AreEqual("u", ConstraintType("gc_bookings", "gc_uq_code_during"));
+ Assert.AreEqual("f", ConstraintType("gc_stays", "gc_fk_stays"));
+
+ // And they enforce: the exclusion constraint over the new, closed ranges.
+ Sql("INSERT INTO gc_bookings (\"Id\", \"RoomId\", \"Code\", \"Start\", \"End\") VALUES (1, 7, 'a', '2024-01-01Z', '2024-01-10Z')");
+ var refused = Assert.ThrowsExactly(
+ () => Sql("INSERT INTO gc_bookings (\"Id\", \"RoomId\", \"Code\", \"Start\", \"End\") VALUES (2, 7, 'b', '2024-01-10Z', '2024-01-20Z')"));
+ Assert.AreEqual(PostgresErrorCodes.ExclusionViolation, refused.SqlState); // 23P01
+ }
+}
diff --git a/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.cs b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.cs
index ce7231e..815a6b7 100644
--- a/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.cs
+++ b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.cs
@@ -423,11 +423,12 @@ protected override void ConfigureModel(ModelBuilder modelBuilder)
}
// As `database update` applies a migration: in one transaction, so a failed statement leaves
- // nothing behind.
- private static void ApplyInTransaction(IReadOnlyList operations)
+ // nothing behind. Pass the target model where Npgsql's generator reads it, as `database update`
+ // passes the migration's: it re-creates EF Core's own indexes on a column it drops and adds.
+ private static void ApplyInTransaction(IReadOnlyList operations, IModel? model = null)
{
using var context = new MigrationHarness.EmptyContext(new DbContextOptionsBuilder().UseNpgsql(ConnectionString).UseNpgsqlComplexIndexes().Options);
- var commands = context.GetService().Generate(operations, model: null);
+ var commands = context.GetService().Generate(operations, model);
using var connection = new NpgsqlConnection(ConnectionString);
connection.Open();