From 21209e1f04ca8fb62ca05ba0ec0888443db5fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Fri, 23 Oct 2026 10:54:39 +0200 Subject: [PATCH] fix: a move into the default schema keeps the indexes and constraints on the table EF Core writes the target's schema into RenameTableOperation.NewSchema, null for the default schema. The differs read null as "unchanged", so a table moved into the default schema was compared under its old schema and every complex index, exclusion constraint, temporal constraint and temporal foreign key on it was dropped and created again. On SQL Server that rebuilt each index although ALTER SCHEMA ... TRANSFER keeps them. On PostgreSQL, where Npgsql 10 writes nothing for that move, the creates named a table that was not there (42P01). NewSchema is now read as EF Core writes it in the three places that built renamed-table identities, as the SQLite rebuild path already did. Closes #185 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + CLAUDE.md | 3 +- docs/upgrading.md | 19 ++- .../CHANGELOG.md | 1 + ...NpgsqlComplexIndexMigrationsModelDiffer.cs | 5 +- .../CHANGELOG.md | 1 + src/EFCore.ComplexIndexes/CHANGELOG.md | 1 + .../CustomMigrationsModelDiffer.cs | 11 +- .../DocumentationApiTests.cs | 2 +- .../PostgresIntegrationTests.SchemaMoves.cs | 62 +++++++++ .../SchemaMoveTests.cs | 131 ++++++++++++++++++ .../SqlServerIntegrationTests.cs | 34 +++++ 12 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.SchemaMoves.cs create mode 100644 test/EFCore.ComplexIndexes.Tests/SchemaMoveTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 66de9e3..8fd689e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 596a736..01ab613 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/upgrading.md b/docs/upgrading.md index a93f13a..6d55476 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 -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 @@ -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")`. diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md index 18fe048..21b979b 100644 --- a/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md +++ b/src/EFCore.ComplexIndexes.PostgreSQL/CHANGELOG.md @@ -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 diff --git a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs index 594e0c4..a9c19b0 100644 --- a/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs +++ b/src/EFCore.ComplexIndexes.PostgreSQL/NpgsqlComplexIndexMigrationsModelDiffer.cs @@ -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 operations) { var renamed = new Dictionary<(string Name, string? Schema), (string Name, string? Schema)>(); foreach (var rename in operations.OfType()) - 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; } diff --git a/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md index 58e521b..44d4880 100644 --- a/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md +++ b/src/EFCore.ComplexIndexes.SqlServer/CHANGELOG.md @@ -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 diff --git a/src/EFCore.ComplexIndexes/CHANGELOG.md b/src/EFCore.ComplexIndexes/CHANGELOG.md index b64edd4..e935da1 100644 --- a/src/EFCore.ComplexIndexes/CHANGELOG.md +++ b/src/EFCore.ComplexIndexes/CHANGELOG.md @@ -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 diff --git a/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs b/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs index 1dc4523..ab5419d 100644 --- a/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs +++ b/src/EFCore.ComplexIndexes/CustomMigrationsModelDiffer.cs @@ -130,13 +130,14 @@ private protected T ResolveSource(IReadOnlyList 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 operations) { var sourceTables = new Dictionary<(string Name, string? Schema), (string Name, string? Schema)>(); foreach (var rename in operations.OfType()) - 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()) @@ -254,10 +255,12 @@ public override IReadOnlyList 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()) - 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(); diff --git a/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs b/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs index e50995c..b8e469e 100644 --- a/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs +++ b/test/EFCore.ComplexIndexes.Tests/DocumentationApiTests.cs @@ -50,7 +50,7 @@ public class DocumentationApiTests private static readonly HashSet 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 diff --git a/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.SchemaMoves.cs b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.SchemaMoves.cs new file mode 100644 index 0000000..47af3c7 --- /dev/null +++ b/test/EFCore.ComplexIndexes.Tests/PostgresIntegrationTests.SchemaMoves.cs @@ -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 Period { get; set; } + } + + private abstract class SchemaMoveContext(DbContextOptions options, string? schema) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(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 options) : SchemaMoveContext(options, "sm_north"); + + private class SchemaMoveDefaultContext(DbContextOptions 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(); + Apply(GetDifferences(null, north)); + var index = RelationOid("sm_ix_code"); + var constraint = ExclusionOid("sm_ex_room"); + + var target = BuildRelationalModel(); + 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(); + } +} diff --git a/test/EFCore.ComplexIndexes.Tests/SchemaMoveTests.cs b/test/EFCore.ComplexIndexes.Tests/SchemaMoveTests.cs new file mode 100644 index 0000000..30393bb --- /dev/null +++ b/test/EFCore.ComplexIndexes.Tests/SchemaMoveTests.cs @@ -0,0 +1,131 @@ +using EFCore.ComplexIndexes.PostgreSQL; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using NpgsqlTypes; + +namespace EFCore.ComplexIndexes.Tests; + +/// +/// EF Core's RenameTableOperation carries the target's schema in NewSchema, where +/// null is the default schema. The differs read null as "unchanged", so a table moved +/// into the default schema was compared under its old schema, and every complex index and +/// constraint on it was dropped and created again (#185). On SQL Server that is a needless rebuild; +/// on PostgreSQL, whose Npgsql 10 renders nothing for that move, the creates named a table that was +/// not there (42P01). +/// +[TestClass] +public class SchemaMoveTests +{ + 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 NpgsqlRange Period { get; set; } + public EmailAddress Email { get; set; } = new(); + } + + private class Stay + { + public int Id { get; set; } + public string Code { get; set; } = ""; + public NpgsqlRange During { get; set; } + } + + // ── PostgreSQL ──────────────────────────────────────────────────────────────────────────────── + + private abstract class NpgsqlContext(DbContextOptions options, string? schema, string codeColumn = "Code") : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.ToTable("bookings", schema); + b.Property(x => x.Code).HasColumnName(codeColumn); + b.ComplexProperty(x => x.Email, c => c.Property(x => x.Value).HasComplexIndex(isUnique: true)); + b.HasComplexCompositeIndex(x => new { x.Code, x.Email.Value }, indexName: "ix_bookings_code_email"); + b.HasExclusionConstraint(x => x.RoomId, x => x.Period, name: "ex_bookings_room"); + b.HasTemporalConstraint(x => x.Code, x => x.Period, name: "tc_bookings_code"); + }); + + modelBuilder.Entity(b => + { + b.ToTable("stays"); + b.HasTemporalForeignKey(x => x.Code, x => x.During, x => x.Code, x => x.Period, name: "fk_stays_booking"); + }); + } + } + + private class InNorth(DbContextOptions options) : NpgsqlContext(options, "north"); + + private class InSouth(DbContextOptions options) : NpgsqlContext(options, "south"); + + private class InDefault(DbContextOptions options) : NpgsqlContext(options, null); + + private class InDefaultRenamed(DbContextOptions options) : NpgsqlContext(options, null, "booking_code"); + + private static void AssertOnlyMoves(IReadOnlyList operations, params Type[] allowed) + { + var others = operations.Where(o => !allowed.Contains(o.GetType())).Select(o => o is SqlOperation sql ? sql.Sql : o.GetType().Name).ToList(); + Assert.AreEqual(0, others.Count, string.Join(Environment.NewLine, others)); + } + + [TestMethod] + public void PostgreSQL_move_into_the_default_schema_keeps_everything() + => AssertOnlyMoves( + MigrationHarness.NpgsqlDiff(MigrationHarness.NpgsqlModel(), MigrationHarness.NpgsqlModel()), + typeof(RenameTableOperation)); + + [TestMethod] + public void PostgreSQL_move_into_the_default_schema_with_a_column_rename_renames_the_column_only() + { + var operations = MigrationHarness.NpgsqlDiff(MigrationHarness.NpgsqlModel(), MigrationHarness.NpgsqlModel()); + + AssertOnlyMoves(operations, typeof(RenameTableOperation), typeof(RenameColumnOperation)); + Assert.AreEqual("booking_code", operations.OfType().Single().NewName); + } + + // The moves that already worked, so the fix does not turn them around. + [TestMethod] + public void PostgreSQL_move_between_named_schemas_keeps_everything() + => AssertOnlyMoves( + MigrationHarness.NpgsqlDiff(MigrationHarness.NpgsqlModel(), MigrationHarness.NpgsqlModel()), + typeof(EnsureSchemaOperation), typeof(RenameTableOperation)); + + [TestMethod] + public void PostgreSQL_move_out_of_the_default_schema_keeps_everything() + => AssertOnlyMoves( + MigrationHarness.NpgsqlDiff(MigrationHarness.NpgsqlModel(), MigrationHarness.NpgsqlModel()), + typeof(EnsureSchemaOperation), typeof(RenameTableOperation)); + + // ── SQL Server ──────────────────────────────────────────────────────────────────────────────── + + private abstract class SqlServerContext(DbContextOptions options, string? schema) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(b => + { + b.ToTable("bookings", schema); + b.Ignore(x => x.Period); + b.Property(x => x.Code).HasMaxLength(50); + b.ComplexProperty(x => x.Email, c => c.Property(x => x.Value).HasMaxLength(200).HasComplexIndex(isUnique: true)); + b.HasComplexCompositeIndex(x => new { x.Code, x.Email.Value }, indexName: "ix_bookings_code_email"); + }); + } + + private class SqlServerInNorth(DbContextOptions options) : SqlServerContext(options, "north"); + + private class SqlServerInDefault(DbContextOptions options) : SqlServerContext(options, null); + + [TestMethod] + public void SqlServer_move_into_the_default_schema_keeps_the_indexes() + => AssertOnlyMoves( + MigrationHarness.SqlServerDiff(MigrationHarness.SqlServerModel(), MigrationHarness.SqlServerModel()), + typeof(RenameTableOperation)); +} diff --git a/test/EFCore.ComplexIndexes.Tests/SqlServerIntegrationTests.cs b/test/EFCore.ComplexIndexes.Tests/SqlServerIntegrationTests.cs index 8670088..8827eec 100644 --- a/test/EFCore.ComplexIndexes.Tests/SqlServerIntegrationTests.cs +++ b/test/EFCore.ComplexIndexes.Tests/SqlServerIntegrationTests.cs @@ -783,6 +783,40 @@ public void Column_rename_keeps_the_indexes() CollectionAssert.AreEqual(new[] { "email_v2" }, AssertIndex("subscribers", "ix_name_covering").Included); } + // ── A move into the default schema keeps the indexes (#185) ── + + private abstract class SchemaMoveContext(DbContextOptions options, string? schema) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(b => + { + b.ToTable("moved_subscribers", schema); + b.HasKey(x => x.Id).IsClustered(false); + b.Property(x => x.Name).HasMaxLength(100); + b.ComplexProperty(x => x.Contact, c => c.Property(x => x.Email).HasMaxLength(256)); + b.HasComplexCompositeIndex(x => new { x.Name, x.Contact.Email }, indexName: "ix_moved_name_email"); + }); + } + + private class SchemaMoveNorthContext(DbContextOptions options) : SchemaMoveContext(options, "north"); + + private class SchemaMoveDefaultContext(DbContextOptions options) : SchemaMoveContext(options, null); + + [TestMethod(DisplayName = "A move into the default schema keeps the indexes, with no rebuild (#185)")] + public void Move_into_default_schema_keeps_the_indexes() + { + var north = Model(); + Migrate(null, north); + + // ALTER SCHEMA … TRANSFER keeps a table's indexes. + var operations = MigrationHarness.SqlServerDiff(north, Model()); + Assert.IsFalse(operations.Any(o => o is DropIndexOperation or CreateIndexOperation)); + + Migrate(north, Model()); + + CollectionAssert.AreEqual(new[] { "Name", "Contact_Email" }, AssertIndex("moved_subscribers", "ix_moved_name_email").Keys); + } + // ── The clustered slot and key names (#90) ── private class OneClusteredContext(DbContextOptions options) : CustomerContext(options)