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);
""");
}