From 218a5d798c3596e6e1bf5c41fd18563f30cb12fb Mon Sep 17 00:00:00 2001 From: m_axwel_l Date: Wed, 13 May 2026 22:32:03 +0500 Subject: [PATCH] Use CREATE INDEX with DROP_EXISTING=ON for index facet changes (SQL Server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an index facet changes (fill factor, sort order, uniqueness, filter, columns), the migration model differ produces a DropIndexOperation + CreateIndexOperation pair. On SQL Server this can be collapsed into a single `CREATE INDEX ... WITH (DROP_EXISTING = ON)`, which: - Lets queries continue using the old index while the new one is being built (no un-indexed gap during a long rebuild on large tables) - Atomically replaces the old index in a single statement The rewrite lives in SqlServerMigrationsSqlGenerator.RewriteOperations (new helper RewriteDropAndCreateIndexAsDropExisting). The matching DropIndexOperation is removed from the operation list and the CreateIndexOperation is marked with a new internal annotation SqlServerAnnotationNames.UseDropExisting, which IndexOptions reads to emit DROP_EXISTING = ON in the WITH clause. Limited to standard indexes — memory-optimized, full-text, and vector indexes use different syntax/restrictions and fall back to the existing drop+create path. MigrationsModelDiffer is unchanged; the differ still emits Drop+Create as before. Other providers are unaffected. Fixes #35067 --- .../Internal/SqlServerAnnotationNames.cs | 8 ++ .../SqlServerMigrationsSqlGenerator.cs | 83 +++++++++++++++++++ .../Migrations/MigrationsSqlServerTest.cs | 77 +++++++++++++++-- 3 files changed, 159 insertions(+), 9 deletions(-) diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs index fb932681f6f..1c6ea77fad8 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationNames.cs @@ -354,4 +354,12 @@ public static class SqlServerAnnotationNames /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public const string FullTextCatalogs = Prefix + "FullTextCatalogs"; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string UseDropExisting = Prefix + "UseDropExisting"; } diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 0d550996239..c3cc65feea5 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -2132,6 +2132,15 @@ protected override void IndexOptions(MigrationOperation operation, IModel? model }); } + // When this CreateIndexOperation was rewritten from a Drop+Create pair (an index facet + // changed and the index needs to be recreated), emit DROP_EXISTING = ON so SQL Server + // atomically replaces the index without leaving the table un-indexed during the rebuild. + // See #35067. + if (operation[SqlServerAnnotationNames.UseDropExisting] is true) + { + options.Add("DROP_EXISTING = ON"); + } + // Vector index options. // Note that the metric facet is mandatory, and used to determine if the index is a vector index. if (operation[SqlServerAnnotationNames.VectorIndexMetric] is string vectorMetric) @@ -2664,6 +2673,79 @@ private string Uniquify(string variableName, bool increase = true) return _variableCounter == 0 ? variableName : variableName + _variableCounter; } + private IReadOnlyList RewriteDropAndCreateIndexAsDropExisting( + IReadOnlyList migrationOperations) + { + // The differ produces a DropIndexOperation + CreateIndexOperation pair when an index facet + // changes (e.g. fill factor, sort order, uniqueness, filter, columns). On SQL Server the + // pair can be collapsed into a single `CREATE INDEX ... WITH (DROP_EXISTING = ON)` which + // is more efficient: queries can continue using the old index while the new one is being + // built, instead of going un-indexed during the drop. See #35067. + // + // The rewrite is limited to non-special indexes (no memory-optimized, full-text or vector + // index, since those use different syntax/restrictions). + + // Short-circuit when no DropIndex+CreateIndex pair is possible. + if (!migrationOperations.OfType().Any() + || !migrationOperations.OfType().Any()) + { + return migrationOperations; + } + + // Map (Name, Table, Schema) → DropIndexOperation, only for drops we may collapse. + var dropsByIdentity = new Dictionary<(string Name, string Table, string? Schema), DropIndexOperation>(); + foreach (var dropOperation in migrationOperations.OfType()) + { + if (dropOperation.Table is null) + { + continue; + } + + dropsByIdentity[(dropOperation.Name, dropOperation.Table, dropOperation.Schema)] = dropOperation; + } + + // Identify the drops we are going to collapse and mark the matching creates. + var dropsToRemove = new HashSet(); + foreach (var createOperation in migrationOperations.OfType()) + { + if (createOperation.Table is null + || !dropsByIdentity.TryGetValue( + (createOperation.Name, createOperation.Table, createOperation.Schema), out var dropOperation)) + { + continue; + } + + // Skip special index types that don't support DROP_EXISTING. + if (createOperation[SqlServerAnnotationNames.FullTextIndex] is not null + || createOperation[SqlServerAnnotationNames.VectorIndexMetric] is not null + || IsMemoryOptimized(createOperation, model: null, createOperation.Schema, createOperation.Table)) + { + continue; + } + + createOperation.AddAnnotation(SqlServerAnnotationNames.UseDropExisting, true); + dropsToRemove.Add(dropOperation); + } + + if (dropsToRemove.Count == 0) + { + return migrationOperations; + } + + var resultOperations = new List(migrationOperations.Count - dropsToRemove.Count); + foreach (var migrationOperation in migrationOperations) + { + if (migrationOperation is DropIndexOperation dropOperation && dropsToRemove.Contains(dropOperation)) + { + continue; + } + + resultOperations.Add(migrationOperation); + } + + return resultOperations; + } + private IReadOnlyList FixLegacyTemporalAnnotations(IReadOnlyList migrationOperations) { // short-circuit for non-temporal migrations (which is the majority) @@ -2797,6 +2879,7 @@ private IReadOnlyList RewriteOperations( MigrationsSqlGenerationOptions options) { migrationOperations = FixLegacyTemporalAnnotations(migrationOperations); + migrationOperations = RewriteDropAndCreateIndexAsDropExisting(migrationOperations); var operations = new List(); var availableSchemas = new List(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 655c1d30cfb..105f4c4dd46 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -2039,11 +2039,7 @@ public override async Task Alter_index_make_unique() AssertSql( """ -DROP INDEX [IX_People_X] ON [People]; -""", - // - """ -CREATE UNIQUE INDEX [IX_People_X] ON [People] ([X]); +CREATE UNIQUE INDEX [IX_People_X] ON [People] ([X]) WITH (DROP_EXISTING = ON); """); } @@ -2053,11 +2049,74 @@ public override async Task Alter_index_change_sort_order() AssertSql( """ -DROP INDEX [IX_People_X_Y_Z] ON [People]; -""", - // +CREATE INDEX [IX_People_X_Y_Z] ON [People] ([X], [Y] DESC, [Z]) WITH (DROP_EXISTING = ON); +"""); + } + + [ConditionalFact] + public virtual async Task Alter_index_fill_factor_uses_drop_existing() + { + // Regression test for #35067: when only an index facet (fill factor here) changes, the + // migration must collapse the differ's Drop+Create pair into a single CREATE INDEX + // ... WITH (DROP_EXISTING = ON), which lets queries continue using the old index while the + // new one is being built. + await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Name").IsRequired(); + e.HasIndex("Name").HasFillFactor(80); + }), + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Name").IsRequired(); + e.HasIndex("Name").HasFillFactor(90); + }), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal(90, index[SqlServerAnnotationNames.FillFactor]); + }); + + AssertSql( """ -CREATE INDEX [IX_People_X_Y_Z] ON [People] ([X], [Y] DESC, [Z]); +CREATE INDEX [IX_People_Name] ON [People] ([Name]) WITH (FILLFACTOR = 90, DROP_EXISTING = ON); +"""); + } + + [ConditionalFact] + public virtual async Task Alter_index_filter_uses_drop_existing() + { + // Changing the index filter must also collapse to CREATE INDEX ... WITH (DROP_EXISTING = ON). + await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Name"); + e.HasIndex("Name").HasFilter("[Name] IS NOT NULL"); + }), + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Name"); + e.HasIndex("Name").HasFilter("[Name] IS NOT NULL AND [Name] <> N''"); + }), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal("([Name] IS NOT NULL AND [Name]<>N'')", index.Filter); + }); + + AssertSql( + """ +CREATE INDEX [IX_People_Name] ON [People] ([Name]) WHERE [Name] IS NOT NULL AND [Name] <> N'' WITH (DROP_EXISTING = ON); """); }