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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Migrations keep the indexes and constraints a column change used to drop or rebu
### 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: moving a table into the default schema keeps the complex indexes and constraints on it. They were dropped and created again, and on PostgreSQL, where Npgsql leaves such a table in its schema, the migration failed (42P01) ([#185](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/185), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-move-into-the-default-schema-keeps-what-is-on-the-table)).
- 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,8 @@ The differ recognizes two churn cases: a drop/create pair identical except for t
`RenameIndexOperation` when `CanRenameIndexes` is true (Npgsql and SqlServer satellites; core
default false — SQLite's generator can't rename annotation-only indexes), and source indexes on
tables the base operations rename are compared under their new table identity (drops still target
the old name, since they run before the rename). The exclusion/temporal differs apply the same two
the old name, since they run before the rename). EF writes the target's schema into `NewSchema`,
null for the default one, so null is a move there and never "unchanged" (#185). The exclusion/temporal differs apply the same two
rules: renamed-table normalization, and name-only changes → `ALTER TABLE … RENAME CONSTRAINT`
(placed *after* the base ops — the rename references the new table name). A constraint rename
keeps its dependents, so `DependsOnChangedTemporalConstraint` compares principal constraints
Expand Down
19 changes: 17 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

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
These change only migrations that rename a column, change a generated column or move a table into
the default schema. 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 @@ -498,3 +499,17 @@ 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.

### A move into the default schema keeps what is on the table

When you move a table into the default schema, `ToTable("bookings", "north")` to
`ToTable("bookings")` for example, the migration now keeps the complex indexes, exclusion
constraints, temporal constraints and temporal foreign keys on it. Before, `migrations add` dropped
each of them before the move and created it again after. On SQL Server that rebuilt every index for
nothing, since `ALTER SCHEMA … TRANSFER` keeps a table's indexes. Moves between two named schemas,
or out of the default schema, already kept them.

On PostgreSQL the migration failed when it ran (42P01). Npgsql 10 writes no statement for a move
into the default schema, so the table stays where it is, and the indexes were created on a table of
that name in the default schema, which does not exist. The migration now runs without an error, but
the table still stays in its old schema. To move it, name the schema: `ToTable("bookings", "public")`.
1 change: 1 addition & 0 deletions src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Migrations keep the indexes and constraints a column change used to drop or rebu
### 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: moving a table into the default schema keeps the complex indexes and constraints on it. They were dropped and created again, and on PostgreSQL, where Npgsql leaves such a table in its schema, the migration failed (42P01) ([#185](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/185), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-move-into-the-default-schema-keeps-what-is-on-the-table)).
- 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
Original file line number Diff line number Diff line change
Expand Up @@ -1153,13 +1153,14 @@ static string Describe(ExclusionDescriptor descriptor)
}
}

// Tables the base operations rename, keyed by old identity.
// Tables the base operations rename, keyed by old identity. NewSchema is the target's schema,
// null for the default one, as EF Core writes it (#185).
private static Dictionary<(string Name, string? Schema), (string Name, string? Schema)> BuildRenamedTables(
IReadOnlyList<MigrationOperation> operations)
{
var renamed = new Dictionary<(string Name, string? Schema), (string Name, string? Schema)>();
foreach (var rename in operations.OfType<RenameTableOperation>())
renamed[(rename.Name, rename.Schema)] = (rename.NewName ?? rename.Name, rename.NewSchema ?? rename.Schema);
renamed[(rename.Name, rename.Schema)] = (rename.NewName ?? rename.Name, rename.NewSchema);
return renamed;
}

Expand Down
1 change: 1 addition & 0 deletions src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Migrations keep the indexes and constraints a column change used to drop or rebu

### Changed answers

- Migrations: moving a table into the default schema keeps the complex indexes and constraints on it. They were dropped and created again, and on PostgreSQL, where Npgsql leaves such a table in its schema, the migration failed (42P01) ([#185](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/185), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-move-into-the-default-schema-keeps-what-is-on-the-table)).
- 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
1 change: 1 addition & 0 deletions src/EFCore.ComplexIndexes/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Migrations keep the indexes and constraints a column change used to drop or rebu

### Changed answers

- Migrations: moving a table into the default schema keeps the complex indexes and constraints on it. They were dropped and created again, and on PostgreSQL, where Npgsql leaves such a table in its schema, the migration failed (42P01) ([#185](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/issues/185), [Upgrading](https://github.com/CaffeinatedCoder/EFCore.ComplexIndexes/blob/main/docs/upgrading.md#a-move-into-the-default-schema-keeps-what-is-on-the-table)).
- 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
11 changes: 7 additions & 4 deletions src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,14 @@ private protected T ResolveSource<T>(IReadOnlyList<MigrationOperation> operation
}

// EF Core names the table of a RenameColumnOperation as the target has it, and renames a
// table before its columns; the key is the source's name for the table.
// table before its columns; the key is the source's name for the table. NewSchema is the
// target's schema, null for the default one (#185).
private static Dictionary<(string Table, string? Schema, string Column), string>? SourceColumnRenames(
IReadOnlyList<MigrationOperation> operations)
{
var sourceTables = new Dictionary<(string Name, string? Schema), (string Name, string? Schema)>();
foreach (var rename in operations.OfType<RenameTableOperation>())
sourceTables[(rename.NewName ?? rename.Name, rename.NewSchema ?? rename.Schema)] = (rename.Name, rename.Schema);
sourceTables[(rename.NewName ?? rename.Name, rename.NewSchema)] = (rename.Name, rename.Schema);

Dictionary<(string Table, string? Schema, string Column), string>? renames = null;
foreach (var rename in operations.OfType<RenameColumnOperation>())
Expand Down Expand Up @@ -254,10 +255,12 @@ public override IReadOnlyList<MigrationOperation> GetDifferences(

// Tables the base operations rename: compare source indexes under their *new* table
// identity so a renamed table doesn't drop and recreate every index it carries. Drops still
// execute against the old name — they run before the rename.
// execute against the old name — they run before the rename. EF Core writes the target's
// schema into NewSchema, null for the default one, so null is a move there, not "unchanged"
// (#185).
var renamedTables = new Dictionary<(string Name, string? Schema), (string Name, string? Schema)>();
foreach (var rename in operations.OfType<RenameTableOperation>())
renamedTables[(rename.Name, rename.Schema)] = (rename.NewName ?? rename.Name, rename.NewSchema ?? rename.Schema);
renamedTables[(rename.Name, rename.Schema)] = (rename.NewName ?? rename.Name, rename.NewSchema);

// Normalized descriptor → the original (table, schema) a drop must target.
var normalizedSource = new Dictionary<IndexDescriptor, (string Table, string? Schema)>();
Expand Down
2 changes: 1 addition & 1 deletion test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class DocumentationApiTests
private static readonly HashSet<string> ExternalApi = new(StringComparer.Ordinal)
{
// EF Core
"ComplexProperty", "ComplexCollection", "Property", "HasColumnName", "HasKey", "ToJson", "Entity",
"ComplexProperty", "ComplexCollection", "Property", "HasColumnName", "HasKey", "ToJson", "Entity", "ToTable",
"MigrationsAssembly", "UseInternalServiceProvider", "ReplaceService",
"EnsureCreated", "GenerateCreateScript", "Migrate", "GetEntityTypes", "HasConversion",
// Npgsql
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using EFCore.ComplexIndexes.PostgreSQL;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using NpgsqlTypes;

namespace EFCore.ComplexIndexes.Tests;

public partial class PostgresIntegrationTests
{
// ── A move into the default schema keeps what is on the table (#185) ──

// Npgsql 10 renders nothing for a RenameTableOperation whose NewSchema is null, so the table
// stays where it was. The differs read null as "unchanged", dropped the indexes and constraints
// under the old schema and created them on a table of that name in the default schema: 42P01.

private class SchemaMoveBooking
{
public int Id { get; set; }
public int RoomId { get; set; }
public string Code { get; set; } = "";
public NpgsqlRange<DateTime> Period { get; set; }
}

private abstract class SchemaMoveContext(DbContextOptions options, string? schema) : DbContext(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<SchemaMoveBooking>(b =>
{
b.ToTable("sm_bookings", schema);
b.HasComplexIndex(x => x.Code, indexName: "sm_ix_code");
b.HasExclusionConstraint(x => x.RoomId, x => x.Period, name: "sm_ex_room");
});
}

private class SchemaMoveNorthContext(DbContextOptions<SchemaMoveNorthContext> options) : SchemaMoveContext(options, "sm_north");

private class SchemaMoveDefaultContext(DbContextOptions<SchemaMoveDefaultContext> options) : SchemaMoveContext(options, null);

[TestMethod(DisplayName = "A move into the default schema applies and keeps the index and constraint (#185)")]
public void Move_into_default_schema_applies()
{
var north = BuildRelationalModel<SchemaMoveNorthContext>();
Apply(GetDifferences(null, north));
var index = RelationOid("sm_ix_code");
var constraint = ExclusionOid("sm_ex_room");

var target = BuildRelationalModel<SchemaMoveDefaultContext>();
ApplyInTransaction(GetDifferences(north, target), target.Model);

Assert.AreEqual(index, RelationOid("sm_ix_code"), "The index was rebuilt.");
Assert.AreEqual(constraint, ExclusionOid("sm_ex_room"), "The exclusion constraint was rebuilt.");
}

private static uint? ExclusionOid(string name)
{
using var connection = new NpgsqlConnection(ConnectionString);
connection.Open();
using var cmd = new NpgsqlCommand("SELECT oid FROM pg_constraint WHERE conname = @name AND contype = 'x'", connection);
cmd.Parameters.AddWithValue("name", name);
return (uint?)cmd.ExecuteScalar();
}
}
Loading
Loading