diff --git a/EFCore.slnx b/EFCore.slnx
index afa719a2331..7e256785fff 100644
--- a/EFCore.slnx
+++ b/EFCore.slnx
@@ -59,6 +59,7 @@
+
@@ -70,6 +71,7 @@
+
diff --git a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs
index 07c895f736e..8224c22e842 100644
--- a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs
+++ b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Globalization;
using System.Text;
using Microsoft.EntityFrameworkCore.Internal;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
@@ -830,7 +831,10 @@ protected virtual void GenerateIndex(
// Note - method names below are meant to be hard-coded
// because old snapshot files will fail if they are changed
- var indexProperties = string.Join(", ", index.Properties.Select(p => Code.Literal(p.Name)));
+ var collectionIndices = index.CollectionIndices;
+ var indexProperties = string.Join(
+ ", ",
+ index.Properties.Select((p, i) => Code.Literal(BuildIndexPropertyPath(p, collectionIndices?[i]))));
var indexBuilderName = $"{entityTypeBuilderName}.HasIndex("
+ (index.Name is null
? indexProperties
@@ -863,6 +867,63 @@ protected virtual void GenerateIndex(
GenerateIndexAnnotations(indexBuilderName, index, stringBuilder);
}
+ private static string BuildIndexPropertyPath(IPropertyBase property, IReadOnlyList? collectionIndices)
+ {
+ // Fast path: a scalar declared directly on the entity has no brackets in any form.
+ if (property.DeclaringType is IEntityType
+ && property is not IComplexProperty { IsCollection: true })
+ {
+ return property.Name;
+ }
+
+ // Build the path leaf-first, walking up through enclosing complex types. Each
+ // collection-traversal step (including the leaf when the leaf itself is a complex collection)
+ // consumes one entry from CollectionIndices, in reverse order.
+ var segments = new List();
+ var collectionSegmentIndex = (collectionIndices?.Count ?? 0) - 1;
+
+ // The indexed leaf may itself be a complex collection (e.g. "Posts[]" / "Posts[3]").
+ segments.Add(BuildSegment(
+ property.Name,
+ property is IComplexProperty { IsCollection: true },
+ collectionIndices,
+ ref collectionSegmentIndex));
+
+ var declaringType = property.DeclaringType;
+ while (declaringType is IComplexType complexType)
+ {
+ var complexProperty = complexType.ComplexProperty;
+ segments.Add(BuildSegment(
+ complexProperty.Name,
+ complexProperty.IsCollection,
+ collectionIndices,
+ ref collectionSegmentIndex));
+
+ declaringType = complexProperty.DeclaringType;
+ }
+
+ segments.Reverse();
+ return string.Join(".", segments);
+
+ static string BuildSegment(
+ string name,
+ bool collection,
+ IReadOnlyList? collectionIndices,
+ ref int collectionSegmentIndex)
+ {
+ if (!collection)
+ {
+ return name;
+ }
+
+ var indexEntry = collectionIndices?[collectionSegmentIndex];
+ collectionSegmentIndex--;
+ return name + (indexEntry is null
+ ? "[]"
+ : "[" + indexEntry.Value.ToString(CultureInfo.InvariantCulture) + "]");
+ }
+ }
+
///
/// Generates code for the annotations on an index.
///
diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs
index 74b59727768..17ff8e1ee42 100644
--- a/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs
+++ b/src/EFCore.Design/Scaffolding/Internal/CSharpRuntimeModelCodeGenerator.cs
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.EntityFrameworkCore.ChangeTracking.Internal;
@@ -2142,6 +2143,43 @@ private void Create(
mainBuilder.AppendLine();
}
+ private static void CollectionIndicesLiteral(
+ IndentedStringBuilder mainBuilder,
+ IReadOnlyList?> collectionIndices)
+ {
+ mainBuilder.Append("[");
+ for (var i = 0; i < collectionIndices.Count; i++)
+ {
+ if (i > 0)
+ {
+ mainBuilder.Append(", ");
+ }
+
+ var entry = collectionIndices[i];
+ if (entry is null)
+ {
+ mainBuilder.Append("null");
+ continue;
+ }
+
+ mainBuilder.Append("[");
+ for (var j = 0; j < entry.Count; j++)
+ {
+ if (j > 0)
+ {
+ mainBuilder.Append(", ");
+ }
+
+ var value = entry[j];
+ mainBuilder.Append(value is null ? "null" : value.Value.ToString(CultureInfo.InvariantCulture));
+ }
+
+ mainBuilder.Append("]");
+ }
+
+ mainBuilder.Append("]");
+ }
+
private void Create(
IIndex index,
CSharpRuntimeAnnotationCodeGeneratorParameters parameters,
@@ -2170,6 +2208,13 @@ private void Create(
.Append(_code.Literal(true));
}
+ if (index.CollectionIndices is { } collectionIndices)
+ {
+ mainBuilder.AppendLine(",")
+ .Append("collectionIndices: ");
+ CollectionIndicesLiteral(mainBuilder, collectionIndices);
+ }
+
mainBuilder
.AppendLine(");")
.DecrementIndent();
diff --git a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs
index f7531ffa9a7..7b888e559ec 100644
--- a/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs
+++ b/src/EFCore.Relational/Design/Internal/RelationalCSharpRuntimeAnnotationCodeGenerator.cs
@@ -1288,7 +1288,11 @@ private void Create(
/// The unique constraint to which the annotations are applied.
/// Additional parameters used during code generation.
public virtual void Generate(ITableIndex index, CSharpRuntimeAnnotationCodeGeneratorParameters parameters)
- => GenerateSimpleAnnotations(parameters);
+ {
+ var annotations = parameters.Annotations;
+ annotations.Remove(RelationalAnnotationNames.JsonIndex);
+ GenerateSimpleAnnotations(parameters);
+ }
private void Create(
IForeignKeyConstraint foreignKey,
@@ -2449,6 +2453,10 @@ public override void Generate(IIndex index, CSharpRuntimeAnnotationCodeGenerator
{
parameters.Annotations.Remove(RelationalAnnotationNames.TableIndexMappings);
}
+ else
+ {
+ parameters.Annotations.Remove(RelationalAnnotationNames.JsonIndex);
+ }
base.Generate(index, parameters);
}
diff --git a/src/EFCore.Relational/EFCore.Relational.baseline.json b/src/EFCore.Relational/EFCore.Relational.baseline.json
index dee6b2f7287..d38b5e8f89f 100644
--- a/src/EFCore.Relational/EFCore.Relational.baseline.json
+++ b/src/EFCore.Relational/EFCore.Relational.baseline.json
@@ -9985,6 +9985,14 @@
"Member": "const string JsonElementMappings",
"Value": "Relational:JsonElementMappings"
},
+ {
+ "Member": "const string JsonIndex",
+ "Value": "Relational:JsonIndex"
+ },
+ {
+ "Member": "const string JsonIndexPaths",
+ "Value": "Relational:JsonIndexPaths"
+ },
{
"Member": "const string JsonPropertyName",
"Value": "Relational:JsonPropertyName"
@@ -10134,6 +10142,9 @@
{
"Member": "RelationalAnnotationProvider(Microsoft.EntityFrameworkCore.Metadata.RelationalAnnotationProviderDependencies dependencies);"
},
+ {
+ "Member": "static Microsoft.EntityFrameworkCore.Metadata.IRelationalJsonElement FindJsonElement(Microsoft.EntityFrameworkCore.Metadata.IPropertyBase property, Microsoft.EntityFrameworkCore.Metadata.ITable table);"
+ },
{
"Member": "virtual System.Collections.Generic.IEnumerable For(Microsoft.EntityFrameworkCore.Metadata.IRelationalModel model, bool designTime);"
},
@@ -10190,6 +10201,12 @@
},
{
"Member": "virtual System.Collections.Generic.IEnumerable For(Microsoft.EntityFrameworkCore.Metadata.ITrigger trigger, bool designTime);"
+ },
+ {
+ "Member": "virtual bool IsJsonIndex(Microsoft.EntityFrameworkCore.Metadata.IIndex index);"
+ },
+ {
+ "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RelationalJsonIndex? TryBuildJsonIndex(Microsoft.EntityFrameworkCore.Metadata.ITableIndex index);"
}
],
"Properties": [
@@ -13128,6 +13145,31 @@
}
]
},
+ {
+ "Type": "sealed class Microsoft.EntityFrameworkCore.Metadata.RelationalJsonIndex : System.IEquatable",
+ "Methods": [
+ {
+ "Member": "RelationalJsonIndex(System.Collections.Generic.IReadOnlyList elements, System.Collections.Generic.IReadOnlyList?>? collectionIndices);"
+ },
+ {
+ "Member": "bool Equals(Microsoft.EntityFrameworkCore.Metadata.RelationalJsonIndex? other);"
+ },
+ {
+ "Member": "override bool Equals(object? obj);"
+ },
+ {
+ "Member": "override int GetHashCode();"
+ }
+ ],
+ "Properties": [
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList?>? CollectionIndices { get; }"
+ },
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList Elements { get; }"
+ }
+ ]
+ },
{
"Type": "static class Microsoft.EntityFrameworkCore.RelationalKeyBuilderExtensions",
"Methods": [
@@ -14117,6 +14159,9 @@
{
"Member": "override void ValidateIndexOnComplexProperty(Microsoft.EntityFrameworkCore.Metadata.IIndex index, System.Collections.Generic.IReadOnlyList complexProperties, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
},
+ {
+ "Member": "override void ValidateIndexProperty(Microsoft.EntityFrameworkCore.Metadata.IIndex index, Microsoft.EntityFrameworkCore.Metadata.IPropertyBase property, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
+ },
{
"Member": "virtual void ValidateIndexPropertyMapping(Microsoft.EntityFrameworkCore.Metadata.IIndex index, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
},
@@ -16561,6 +16606,15 @@
{
"Member": "static string JsonObjectWithMultiplePropertiesMappedToSameJsonProperty(object? property1, object? property2, object? type, object? jsonPropertyName);"
},
+ {
+ "Member": "static string JsonPathIndexElementsCollectionIndicesMismatch(object? elementCount, object? collectionIndicesCount);"
+ },
+ {
+ "Member": "static string JsonPathIndexPropertiesInDifferentJsonColumns(object? indexProperties, object? entityType, object? firstColumn, object? secondColumn);"
+ },
+ {
+ "Member": "static string JsonPathIndexPropertyMissingJsonColumn(object? indexProperties, object? entityType, object? property);"
+ },
{
"Member": "static string JsonProjectingCollectionElementAccessedUsingParmeterNoTrackingWithIdentityResolution(object? entityTypeName, object? asNoTrackingWithIdentityResolution);"
},
@@ -19904,10 +19958,13 @@
"Type": "class Microsoft.EntityFrameworkCore.Infrastructure.StructuredJsonPath",
"Methods": [
{
- "Member": "StructuredJsonPath(System.Collections.Generic.IReadOnlyList segments, int[] indices);"
+ "Member": "StructuredJsonPath(System.Collections.Generic.IReadOnlyList segments, System.Collections.Generic.IReadOnlyList? indices);"
+ },
+ {
+ "Member": "virtual System.Text.StringBuilder AppendTo(System.Text.StringBuilder builder, bool useAsteriskForNullIndex = true);"
},
{
- "Member": "virtual System.Text.StringBuilder AppendTo(System.Text.StringBuilder builder);"
+ "Member": "virtual string ToString(bool useAsteriskForNullIndex = true);"
},
{
"Member": "override string ToString();"
@@ -19915,7 +19972,7 @@
],
"Properties": [
{
- "Member": "virtual int[] Indices { get; }"
+ "Member": "virtual System.Collections.Generic.IReadOnlyList? Indices { get; }"
},
{
"Member": "virtual bool IsRoot { get; }"
diff --git a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs
index 0c3bda36e45..c537c3fa9f7 100644
--- a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs
+++ b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs
@@ -69,11 +69,12 @@ public static class RelationalIndexExtensions
return null;
}
+ var nameSegments = GetJsonPathNames(index) ?? columnNames;
var baseName = new StringBuilder()
.Append("IX_")
.Append(tableName)
.Append('_')
- .AppendJoin(columnNames, "_")
+ .AppendJoin(nameSegments, "_")
.ToString();
return Uniquifier.Truncate(baseName, index.DeclaringEntityType.Model.GetMaxIdentifierLength());
@@ -131,16 +132,67 @@ public static class RelationalIndexExtensions
return rootIndex.GetDatabaseName(storeObject);
}
+ var nameSegments = GetJsonPathNames(index) ?? columnNames;
var baseName = new StringBuilder()
.Append("IX_")
.Append(storeObject.Name)
.Append('_')
- .AppendJoin(columnNames, "_")
+ .AppendJoin(nameSegments, "_")
.ToString();
return Uniquifier.Truncate(baseName, index.DeclaringEntityType.Model.GetMaxIdentifierLength());
}
+ private static IReadOnlyList? GetJsonPathNames(IReadOnlyIndex index)
+ {
+ // For an index on properties contained inside a JSON-mapped complex type, the index covers
+ // a single JSON container column, so naming purely by column would produce ambiguous default
+ // names when multiple JSON-path indexes share a column. Use the property path through the
+ // complex-type chain (e.g. "Items_Value") instead so each path gets a distinct default name.
+ var segments = new List();
+ foreach (var property in index.Properties)
+ {
+ switch (property)
+ {
+ case IReadOnlyProperty scalar
+ when scalar.DeclaringType is IReadOnlyComplexType complexType && complexType.IsMappedToJson():
+ {
+ var stack = new Stack();
+ stack.Push(scalar.Name);
+ IReadOnlyTypeBase current = scalar.DeclaringType;
+ while (current is IReadOnlyComplexType ct)
+ {
+ stack.Push(ct.ComplexProperty.Name);
+ current = ct.ComplexProperty.DeclaringType;
+ }
+
+ segments.AddRange(stack);
+ break;
+ }
+
+ case IReadOnlyComplexProperty { ComplexType: var ct } when ct.IsMappedToJson():
+ {
+ var stack = new Stack();
+ stack.Push(((IReadOnlyComplexProperty)property).Name);
+ IReadOnlyTypeBase current = ((IReadOnlyComplexProperty)property).DeclaringType;
+ while (current is IReadOnlyComplexType parentCt)
+ {
+ stack.Push(parentCt.ComplexProperty.Name);
+ current = parentCt.ComplexProperty.DeclaringType;
+ }
+
+ segments.AddRange(stack);
+ break;
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ return segments;
+ }
+
///
/// Sets the name of the index in the database.
///
diff --git a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs
index 4ff64db7560..6bdc7070da5 100644
--- a/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs
+++ b/src/EFCore.Relational/Infrastructure/RelationalModelValidator.cs
@@ -2683,24 +2683,90 @@ protected override void ValidateIndex(
base.ValidateIndex(index, logger);
ValidateIndexPropertyMapping(index, logger);
+ ValidateJsonPathIndexSingleContainer(index);
+ }
+
+ private static void ValidateJsonPathIndexSingleContainer(IIndex index)
+ {
+ if (index.CollectionIndices is null)
+ {
+ return;
+ }
+
+ string? firstContainer = null;
+ for (var i = 0; i < index.Properties.Count; i++)
+ {
+ var property = index.Properties[i];
+
+ // Only properties mapped inside a JSON container matter here. Mixed JSON / non-JSON indexes
+ // are rejected earlier by ValidateIndexPropertyMapping.
+ var container = property.DeclaringType is IReadOnlyComplexType complexType && complexType.IsMappedToJson()
+ ? complexType.GetContainerColumnName()
+ : property is IReadOnlyComplexProperty complexProperty && complexProperty.ComplexType.IsMappedToJson()
+ ? complexProperty.ComplexType.GetContainerColumnName()
+ : null;
+
+ if (container is null)
+ {
+ // Not a JSON-contained property. If this position carries a non-null collection-indices
+ // entry (i.e., the path traverses a complex collection but doesn't end in JSON), the
+ // index identity points at a JSON path that has no JSON container — that's invalid.
+ if (index.CollectionIndices[i] is not null)
+ {
+ throw new InvalidOperationException(
+ RelationalStrings.JsonPathIndexPropertyMissingJsonColumn(
+ index.Properties.Format(),
+ index.DeclaringEntityType.DisplayName(),
+ property.Name));
+ }
+
+ continue;
+ }
+
+ if (firstContainer is null)
+ {
+ firstContainer = container;
+ }
+ else if (!string.Equals(firstContainer, container, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ RelationalStrings.JsonPathIndexPropertiesInDifferentJsonColumns(
+ index.Properties.Format(),
+ index.DeclaringEntityType.DisplayName(),
+ firstContainer,
+ container));
+ }
+ }
}
///
- protected override void ValidateIndexOnComplexProperty(
+ protected override void ValidateIndexProperty(
IIndex index,
- IReadOnlyList complexProperties,
+ IPropertyBase property,
IDiagnosticsLogger logger)
{
- var complexCollectionProperty = complexProperties.FirstOrDefault(cp => cp.IsCollection);
- if (complexCollectionProperty != null)
+ // A JSON-mapped leaf inside a complex collection is valid only when the index carries
+ // per-leaf collection indices identifying which elements to index (i.e., a JSON-path index).
+ // Without those, defer to the base validator which rejects indexes that traverse a collection.
+ var inJsonComplex = (property is IReadOnlyComplexProperty complexProperty
+ && complexProperty.ComplexType.IsMappedToJson())
+ || (property.DeclaringType is IReadOnlyComplexType complexType
+ && complexType.IsMappedToJson());
+
+ if (inJsonComplex && index.CollectionIndices is not null)
{
- throw new InvalidOperationException(
- CoreStrings.IndexOnComplexCollection(
- index.Properties.Format(),
- index.DeclaringEntityType.DisplayName(),
- complexCollectionProperty.Name));
+ return;
}
+ base.ValidateIndexProperty(index, property, logger);
+ }
+
+ ///
+ protected override void ValidateIndexOnComplexProperty(
+ IIndex index,
+ IReadOnlyList complexProperties,
+ IDiagnosticsLogger logger)
+ {
var nonJsonComplexProperty = complexProperties.FirstOrDefault(cp => !cp.ComplexType.IsMappedToJson());
if (nonJsonComplexProperty != null)
{
@@ -2766,7 +2832,6 @@ protected virtual void ValidateIndexPropertyMapping(
case IReadOnlyComplexProperty complexProperty:
{
- Check.DebugAssert(!complexProperty.IsCollection, "Collections of complex properties must not appear in indexes at this point.");
Check.DebugAssert(complexProperty.ComplexType.IsMappedToJson(), "Complex properties in indexes must be mapped to JSON at this point.");
if (complexProperty.DeclaringType is IReadOnlyComplexType declaringComplexType
diff --git a/src/EFCore.Relational/Infrastructure/StructuredJsonPath.cs b/src/EFCore.Relational/Infrastructure/StructuredJsonPath.cs
index 8d094f952cd..c9fe4498287 100644
--- a/src/EFCore.Relational/Infrastructure/StructuredJsonPath.cs
+++ b/src/EFCore.Relational/Infrastructure/StructuredJsonPath.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Text;
-using Microsoft.EntityFrameworkCore.Metadata;
namespace Microsoft.EntityFrameworkCore.Infrastructure;
@@ -26,15 +25,19 @@ public class StructuredJsonPath
/// The path segments.
///
/// The index values for array index placeholders. Must have one entry for each segment
- /// where is .
+ /// where is . A
+ /// entry means the indexer is unspecified (all elements) and is
+ /// rendered as [*] by default, or as [].
///
- public StructuredJsonPath(IReadOnlyList segments, int[] indices)
+ public StructuredJsonPath(IReadOnlyList segments, IReadOnlyList? indices)
{
var arraySegmentCount = segments.Count(s => s.IsArray);
- if (indices.Length != arraySegmentCount)
+ if (indices is null
+ ? arraySegmentCount != 0
+ : indices.Count != arraySegmentCount)
{
throw new ArgumentException(
- CoreStrings.InvalidStructuredJsonPathIndexCount(indices.Length, arraySegmentCount),
+ CoreStrings.InvalidStructuredJsonPathIndexCount(indices?.Count ?? 0, arraySegmentCount),
nameof(indices));
}
@@ -50,8 +53,9 @@ public StructuredJsonPath(IReadOnlyList segments, int
///
/// Gets the index values for array index placeholders. The indices are applied in order
/// to the segments where is .
+ /// A entry means the indexer is unspecified (all elements).
///
- public virtual int[] Indices { get; }
+ public virtual IReadOnlyList? Indices { get; }
///
/// Gets a value indicating whether this path represents the root of a JSON document ($).
@@ -63,8 +67,12 @@ public virtual bool IsRoot
/// Appends the JSON path string representation to the given .
///
/// The string builder.
+ ///
+ /// When (the default), unspecified array indices are rendered as [*];
+ /// when , the indices are rendered as [].
+ ///
/// The same for chaining.
- public virtual StringBuilder AppendTo(StringBuilder builder)
+ public virtual StringBuilder AppendTo(StringBuilder builder, bool useAsteriskForNullIndex = true)
{
builder.Append('$');
@@ -73,9 +81,22 @@ public virtual StringBuilder AppendTo(StringBuilder builder)
{
if (segment.IsArray)
{
- builder.Append('[');
- builder.Append(Indices[indexPosition++]);
- builder.Append(']');
+ Check.DebugAssert(Indices is not null, "Indices must be non-null when a segment is an array (enforced by the constructor).");
+
+ if (Indices[indexPosition] is { } index)
+ {
+ builder.Append('[').Append(index).Append(']');
+ }
+ else if (useAsteriskForNullIndex)
+ {
+ builder.Append("[*]");
+ }
+ else
+ {
+ builder.Append("[]");
+ }
+
+ indexPosition++;
}
else
{
@@ -87,6 +108,14 @@ public virtual StringBuilder AppendTo(StringBuilder builder)
return builder;
}
+ ///
+ /// Returns the JSON path string representation.
+ ///
+ /// Indicates whether to use an asterisk for unspecified array indices.
+ /// The JSON path string representation.
+ public virtual string ToString(bool useAsteriskForNullIndex = true)
+ => AppendTo(new StringBuilder(), useAsteriskForNullIndex).ToString();
+
///
public override string ToString()
=> AppendTo(new StringBuilder()).ToString();
diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs
index 5e775f1d3a7..47decfd8eaa 100644
--- a/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs
+++ b/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs
@@ -151,6 +151,24 @@ private static string FormatColumnNames(IEnumerable columnNames)
switch (property)
{
case IReadOnlyProperty scalar:
+ if (scalar.DeclaringType is IReadOnlyComplexType complexType && complexType.IsMappedToJson())
+ {
+ // Index over a scalar inside a JSON-mapped complex type: maps to the JSON container column.
+ var jsonContainerName = complexType.GetContainerColumnName();
+ if (string.IsNullOrEmpty(jsonContainerName))
+ {
+ return null;
+ }
+
+ // Multiple index properties may map to the same JSON container column; deduplicate.
+ if (!names.Contains(jsonContainerName))
+ {
+ names.Add(jsonContainerName);
+ }
+
+ break;
+ }
+
var columnName = storeObject is { } so ? scalar.GetColumnName(so) : scalar.GetColumnName();
if (columnName == null)
{
@@ -160,14 +178,18 @@ private static string FormatColumnNames(IEnumerable columnNames)
names.Add(columnName);
break;
- case IReadOnlyComplexProperty { IsCollection: false } complexProperty:
- var containerColumnName = complexProperty.ComplexType.GetContainerColumnName();
+ case IReadOnlyComplexProperty { ComplexType: var ct } when ct.IsMappedToJson():
+ var containerColumnName = ct.GetContainerColumnName();
if (string.IsNullOrEmpty(containerColumnName))
{
return null;
}
- names.Add(containerColumnName);
+ if (!names.Contains(containerColumnName))
+ {
+ names.Add(containerColumnName);
+ }
+
break;
default:
diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs
index 0df8a077e4d..689b69c51f5 100644
--- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs
+++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs
@@ -1781,7 +1781,39 @@ private static IEnumerable GetTableColumnMappings(IProperty prop
var columns = new List(index.Properties.Count);
foreach (var propertyBase in index.Properties)
{
- if (!TryAppendIndexColumns(table, propertyBase, columns))
+ // For an index over a property inside a JSON-mapped complex type (scalar leaf, non-collection
+ // complex property, or collection complex property), the index covers the JSON container column.
+ // The per-leaf JSON path is exposed separately via the RelationalJsonIndex annotation.
+ var containerName = propertyBase switch
+ {
+ IProperty { DeclaringType: IComplexType complexType } when complexType.IsMappedToJson()
+ => complexType.GetContainerColumnName(),
+ IComplexProperty { ComplexType: var complexType } when complexType.IsMappedToJson()
+ => complexType.GetContainerColumnName(),
+ _ => null
+ };
+
+ if (containerName is not null)
+ {
+ if (string.IsNullOrEmpty(containerName)
+ || table.FindColumn(containerName) is not Column container)
+ {
+ return null;
+ }
+
+ // Multiple index properties may map to the same JSON container column; deduplicate
+ // while preserving the order of first occurrence.
+ if (!columns.Contains(container))
+ {
+ columns.Add(container);
+ }
+ }
+ else if (propertyBase is IProperty property
+ && FindColumn(table, property) is Column column)
+ {
+ columns.Add(column);
+ }
+ else
{
return null;
}
@@ -1790,37 +1822,6 @@ private static IEnumerable GetTableColumnMappings(IProperty prop
return columns;
}
- private static bool TryAppendIndexColumns(Table table, IPropertyBase propertyBase, List columns)
- {
- switch (propertyBase)
- {
- case IProperty property:
- Check.DebugAssert(property.DeclaringType is not IComplexType complexType || !complexType.IsMappedToJson(),
- "Properties mapped to JSON should not be indexed directly; the index should be on the JSON container column instead.");
-
- if (FindColumn(table, property) is not Column column)
- {
- return false;
- }
-
- columns.Add(column);
- return true;
-
- case IComplexProperty { IsCollection: false } complexProperty:
- var containerColumnName = complexProperty.ComplexType.GetContainerColumnName();
- if (string.IsNullOrEmpty(containerColumnName)
- || table.FindColumn(containerColumnName) is not Column jsonColumn)
- {
- return false;
- }
-
- columns.Add(jsonColumn);
- return true;
-
- default:
- return false;
- }
- }
private static void PopulateTableConfiguration(Table table, bool designTime)
{
var storeObject = StoreObjectIdentifier.Table(table.Name, table.Schema);
diff --git a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs
index 4dbc77b80cc..11c4f990bcf 100644
--- a/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs
+++ b/src/EFCore.Relational/Metadata/RelationalAnnotationNames.cs
@@ -359,6 +359,20 @@ public static class RelationalAnnotationNames
///
public const string JsonElementMappings = Prefix + "JsonElementMappings";
+ ///
+ /// The name for the annotation that captures the mapped JSON elements and complex-collection
+ /// indices for a table index defined over properties contained in a JSON-mapped column.
+ ///
+ public const string JsonIndex = Prefix + nameof(JsonIndex);
+
+ ///
+ /// The name for the annotation that captures the JSON paths of a scaffolded JSON index. The
+ /// value is a of the JSON container column name and the
+ /// ordered list of indexed JSON paths (each in the SQL/JSON `$.path` form accepted by the
+ /// provider's CREATE JSON INDEX statement).
+ ///
+ public const string JsonIndexPaths = Prefix + nameof(JsonIndexPaths);
+
///
/// 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
@@ -433,6 +447,8 @@ public static class RelationalAnnotationNames
ContainerColumnType,
JsonPropertyName,
StoreType,
- JsonElementMappings
+ JsonElementMappings,
+ JsonIndex,
+ JsonIndexPaths
};
}
diff --git a/src/EFCore.Relational/Metadata/RelationalAnnotationProvider.cs b/src/EFCore.Relational/Metadata/RelationalAnnotationProvider.cs
index 048c4c0bac2..73086b55928 100644
--- a/src/EFCore.Relational/Metadata/RelationalAnnotationProvider.cs
+++ b/src/EFCore.Relational/Metadata/RelationalAnnotationProvider.cs
@@ -90,7 +90,96 @@ public virtual IEnumerable For(IForeignKeyConstraint foreignKey, bo
///
public virtual IEnumerable For(ITableIndex index, bool designTime)
- => [];
+ {
+ if (!designTime
+ || TryBuildJsonIndex(index) is not { } jsonIndex)
+ {
+ yield break;
+ }
+
+ yield return new Annotation(RelationalAnnotationNames.JsonIndex, jsonIndex);
+ }
+
+ ///
+ /// Attempts to build a for the given table index when its
+ /// leaves resolve to properties (or whole complex properties) contained in a JSON-mapped column.
+ /// Returns for non-JSON indexes.
+ ///
+ ///
+ /// Providers can override this to customize JSON index detection or element resolution. The base
+ /// implementation handles indexes whose leaves are either scalar properties inside JSON-mapped
+ /// complex types, or non-collection complex properties whose type is itself JSON-mapped. When
+ /// overriding, use to resolve the JSON element for an individual
+ /// property on the index's table.
+ ///
+ /// The table index.
+ /// The describing the JSON paths, or .
+ protected virtual RelationalJsonIndex? TryBuildJsonIndex(ITableIndex index)
+ {
+ var modelIndex = index.MappedIndexes.FirstOrDefault();
+ if (modelIndex is null
+ || !IsJsonIndex(modelIndex))
+ {
+ return null;
+ }
+
+ var elements = new IRelationalJsonElement[modelIndex.Properties.Count];
+ for (var i = 0; i < modelIndex.Properties.Count; i++)
+ {
+ elements[i] = FindJsonElement(modelIndex.Properties[i], index.Table);
+ }
+
+ return new RelationalJsonIndex(elements, modelIndex.CollectionIndices);
+ }
+
+ ///
+ /// Returns whether the given mapped is a JSON index — i.e. all its leaves
+ /// are contained in a JSON-mapped column. Providers can override to recognize additional shapes.
+ ///
+ /// The mapped index.
+ /// if the index is a JSON index.
+ protected virtual bool IsJsonIndex(IIndex index)
+ {
+ foreach (var property in index.Properties)
+ {
+ switch (property)
+ {
+ case IProperty { DeclaringType: IComplexType complexType } when complexType.IsMappedToJson():
+ case IComplexProperty { ComplexType: var ct } when ct.IsMappedToJson():
+ continue;
+ default:
+ return false;
+ }
+ }
+
+ return index.Properties.Count > 0;
+ }
+
+ ///
+ /// Resolves the for the given property on the given table.
+ /// All JSON element mappings are populated before table-index annotations are gathered, so a
+ /// mapping is expected to exist for any property reaching this code path.
+ ///
+ /// The property (scalar or complex) participating in the index.
+ /// The table containing the index.
+ /// The JSON element on the given table.
+ protected static IRelationalJsonElement FindJsonElement(IPropertyBase property, ITable table)
+ {
+ // Read the JsonElementMappings runtime annotation directly: GetJsonElementMappings() would
+ // call EnsureRelationalModel, recursively re-entering RelationalModel.Create.
+ var mappings = (IEnumerable?)property.FindRuntimeAnnotationValue(
+ RelationalAnnotationNames.JsonElementMappings)
+ ?? throw new UnreachableException($"Missing JSON element mappings for property '{property.Name}'.");
+ foreach (var mapping in mappings)
+ {
+ if (mapping.TableMapping.Table == table)
+ {
+ return mapping.Element;
+ }
+ }
+
+ throw new UnreachableException($"No JSON element mapping for property '{property.Name}' on table '{table.Name}'.");
+ }
///
public virtual IEnumerable For(IUniqueConstraint constraint, bool designTime)
diff --git a/src/EFCore.Relational/Metadata/RelationalJsonIndex.cs b/src/EFCore.Relational/Metadata/RelationalJsonIndex.cs
new file mode 100644
index 00000000000..c6d251558a6
--- /dev/null
+++ b/src/EFCore.Relational/Metadata/RelationalJsonIndex.cs
@@ -0,0 +1,187 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.Metadata;
+
+///
+/// Describes a relational table index defined over one or more properties contained within a
+/// JSON-mapped column. Holds the JSON elements targeted by the index together with the
+/// complex-collection indices traversed to reach each indexed property.
+///
+///
+///
+/// The list contains one per
+/// indexed property, identifying the leaf JSON element within the JSON-mapped column.
+///
+///
+/// The list runs parallel to and,
+/// for each indexed property, contains an entry whose values resolve the indexers of the
+/// complex-collection segments on the path to the property: a
+/// entry indicates the indexer is unspecified (all elements), and a fixed value indicates a
+/// specific element. A top-level entry indicates the property is
+/// not reached through any complex collection.
+///
+///
+public sealed class RelationalJsonIndex : IEquatable
+{
+ ///
+ /// Creates a new instance.
+ ///
+ /// The JSON elements targeted by the index, one per indexed property.
+ ///
+ /// The complex-collection indices traversed to reach each indexed property, parallel to
+ /// .
+ ///
+ public RelationalJsonIndex(
+ IReadOnlyList elements,
+ IReadOnlyList?>? collectionIndices)
+ {
+ Check.NotNull(elements);
+
+ if (collectionIndices is not null && elements.Count != collectionIndices.Count)
+ {
+ throw new ArgumentException(
+ RelationalStrings.JsonPathIndexElementsCollectionIndicesMismatch(elements.Count, collectionIndices.Count),
+ nameof(collectionIndices));
+ }
+
+ Elements = elements;
+ CollectionIndices = collectionIndices;
+ }
+
+ ///
+ /// Gets the JSON elements targeted by the index, one per indexed property.
+ ///
+ public IReadOnlyList Elements { get; }
+
+ ///
+ /// Gets the complex-collection indices traversed to reach each indexed property.
+ ///
+ public IReadOnlyList?>? CollectionIndices { get; }
+
+ ///
+ public bool Equals(RelationalJsonIndex? other)
+ {
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (other is null
+ || Elements.Count != other.Elements.Count
+ || (CollectionIndices is null) != (other.CollectionIndices is null))
+ {
+ return false;
+ }
+
+ // CollectionIndices, when non-null, has the same Count as Elements (enforced by the constructor).
+ for (var i = 0; i < Elements.Count; i++)
+ {
+ if (!JsonElementsEqual(Elements[i], other.Elements[i]))
+ {
+ return false;
+ }
+
+ if (!CollectionIndicesEntryEqual(CollectionIndices?[i], other.CollectionIndices?[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ public override bool Equals(object? obj)
+ => Equals(obj as RelationalJsonIndex);
+
+ ///
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ for (var i = 0; i < Elements.Count; i++)
+ {
+ var element = Elements[i];
+ hash.Add(element.ContainingColumn.Name);
+ foreach (var segment in element.Path)
+ {
+ hash.Add(segment.IsArray);
+ hash.Add(segment.PropertyName);
+ }
+
+ var indices = CollectionIndices?[i];
+ if (indices is null)
+ {
+ hash.Add(-1);
+ }
+ else
+ {
+ foreach (var entry in indices)
+ {
+ hash.Add(entry.HasValue ? entry.Value : -1);
+ }
+ }
+ }
+
+ return hash.ToHashCode();
+ }
+
+ private static bool JsonElementsEqual(IRelationalJsonElement? left, IRelationalJsonElement? right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left is null || right is null)
+ {
+ return false;
+ }
+
+ if (!string.Equals(left.ContainingColumn.Name, right.ContainingColumn.Name, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (left.Path.Count != right.Path.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Path.Count; i++)
+ {
+ var leftSegment = left.Path[i];
+ var rightSegment = right.Path[i];
+ if (leftSegment.IsArray != rightSegment.IsArray
+ || !string.Equals(leftSegment.PropertyName, rightSegment.PropertyName, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool CollectionIndicesEntryEqual(IReadOnlyList? left, IReadOnlyList? right)
+ {
+ if (ReferenceEquals(left, right))
+ {
+ return true;
+ }
+
+ if (left is null || right is null || left.Count != right.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < left.Count; i++)
+ {
+ if (left[i] != right[i])
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
index cb4f7f2868d..3d26327a564 100644
--- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
+++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
@@ -1525,7 +1525,28 @@ private bool IndexStructureEquals(ITableIndex source, ITableIndex target, DiffCo
&& MultilineEquals(source.Filter, target.Filter)
&& !HasDifferences(source.GetAnnotations(), target.GetAnnotations())
&& source.Columns.Select(p => p.Name).SequenceEqual(
- target.Columns.Select(p => diffContext.FindSource(p)?.Name));
+ target.Columns.Select(p => diffContext.FindSource(p)?.Name))
+ && JsonIndexEqual(source, target);
+
+ private static bool JsonIndexEqual(ITableIndex source, ITableIndex target)
+ {
+ // The JsonIndex annotation captures both the mapped JSON elements and the complex-collection
+ // indices traversed to reach each indexed property. RelationalJsonIndex.Equals compares both
+ // element identity (column + path) and the parallel collection-indices list.
+ var sourceJson = source[RelationalAnnotationNames.JsonIndex] as RelationalJsonIndex;
+ var targetJson = target[RelationalAnnotationNames.JsonIndex] as RelationalJsonIndex;
+ if (sourceJson is null && targetJson is null)
+ {
+ return true;
+ }
+
+ if (sourceJson is null || targetJson is null)
+ {
+ return false;
+ }
+
+ return sourceJson.Equals(targetJson);
+ }
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
index 52fa9637437..20fd3df7c4a 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
+++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs
@@ -1046,7 +1046,7 @@ public static string IncorrectDefaultValueType(object? value, object? valueType,
value, valueType, property, propertyType, entityType);
///
- /// The index {indexProperties} on the entity type '{entityType}' cannot defined on the complex property '{property}' as it's not mapped to mulptiple columns. Reference each property of the complex type individually.
+ /// The index {indexProperties} on the entity type '{entityType}' cannot contain the complex property '{property}' because it's mapped to multiple columns. Reference each scalar property of the complex type individually instead.
///
public static string IndexOnNonJsonComplexProperty(object? indexProperties, object? entityType, object? property)
=> string.Format(
@@ -1351,6 +1351,30 @@ public static string JsonObjectWithMultiplePropertiesMappedToSameJsonProperty(ob
public static string JsonPartialExecuteUpdateNotSupportedByProvider
=> GetString("JsonPartialExecuteUpdateNotSupportedByProvider");
+ ///
+ /// The number of elements ({elementCount}) must match the number of collection-index entries ({collectionIndicesCount}) when creating a RelationalJsonIndex.
+ ///
+ public static string JsonPathIndexElementsCollectionIndicesMismatch(object? elementCount, object? collectionIndicesCount)
+ => string.Format(
+ GetString("JsonPathIndexElementsCollectionIndicesMismatch", nameof(elementCount), nameof(collectionIndicesCount)),
+ elementCount, collectionIndicesCount);
+
+ ///
+ /// The index {indexProperties} on the entity type '{entityType}' cannot be configured because its properties are mapped to different JSON columns ('{firstColumn}' and '{secondColumn}'). All leaves of a JSON-path index (an index whose properties traverse a complex collection) must be contained in a single JSON column.
+ ///
+ public static string JsonPathIndexPropertiesInDifferentJsonColumns(object? indexProperties, object? entityType, object? firstColumn, object? secondColumn)
+ => string.Format(
+ GetString("JsonPathIndexPropertiesInDifferentJsonColumns", nameof(indexProperties), nameof(entityType), nameof(firstColumn), nameof(secondColumn)),
+ indexProperties, entityType, firstColumn, secondColumn);
+
+ ///
+ /// The index {indexProperties} on the entity type '{entityType}' cannot be configured because its property '{property}' traverses a complex collection but is not mapped to a JSON column.
+ ///
+ public static string JsonPathIndexPropertyMissingJsonColumn(object? indexProperties, object? entityType, object? property)
+ => string.Format(
+ GetString("JsonPathIndexPropertyMissingJsonColumn", nameof(indexProperties), nameof(entityType), nameof(property)),
+ indexProperties, entityType, property);
+
///
/// Using a parameter to access the element of a JSON collection '{entityTypeName}' is not supported when using '{asNoTrackingWithIdentityResolution}'. Use a constant, or project the entire JSON entity collection instead.
///
diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx
index a53d8cd2bd0..0d99a1ed26b 100644
--- a/src/EFCore.Relational/Properties/RelationalStrings.resx
+++ b/src/EFCore.Relational/Properties/RelationalStrings.resx
@@ -512,7 +512,7 @@
Default value '{value}' of type '{valueType}' cannot be set on property '{property}' of type '{propertyType}' in entity type '{entityType}'.
- The index {indexProperties} on the entity type '{entityType}' cannot defined on the complex property '{property}' as it's not mapped to mulptiple columns. Reference each property of the complex type individually.
+ The index {indexProperties} on the entity type '{entityType}' cannot contain the complex property '{property}' because it's mapped to multiple columns. Reference each scalar property of the complex type individually instead.
The index {indexProperties} on the entity type '{entityType}' cannot be configured because some of its properties are contained within a complex property mapped to a JSON column while others are not. All properties of an index must either all be mapped to JSON or all be mapped to regular columns.
@@ -631,6 +631,15 @@
The provider in use does not support partial updates with ExecuteUpdate within JSON columns.
+
+ The number of elements ({elementCount}) must match the number of collection-index entries ({collectionIndicesCount}) when creating a RelationalJsonIndex.
+
+
+ The index {indexProperties} on the entity type '{entityType}' cannot be configured because its properties are mapped to different JSON columns ('{firstColumn}' and '{secondColumn}'). All leaves of a JSON-path index (an index whose properties traverse a complex collection) must be contained in a single JSON column.
+
+
+ The index {indexProperties} on the entity type '{entityType}' cannot be configured because its property '{property}' traverses a complex collection but is not mapped to a JSON column.
+
Using a parameter to access the element of a JSON collection '{entityTypeName}' is not supported when using '{asNoTrackingWithIdentityResolution}'. Use a constant, or project the entire JSON entity collection instead.
diff --git a/src/EFCore.Relational/Update/ModificationCommand.cs b/src/EFCore.Relational/Update/ModificationCommand.cs
index 2df1ca244a4..82f70b14103 100644
--- a/src/EFCore.Relational/Update/ModificationCommand.cs
+++ b/src/EFCore.Relational/Update/ModificationCommand.cs
@@ -760,7 +760,7 @@ void HandleJson(List columnModifications)
var jsonProperty = finalUpdatePathElement.Property;
var propertyValue = finalUpdatePathElement.ParentEntry.GetCurrentValue(jsonProperty);
- var ordinals = new List();
+ var ordinals = new List();
foreach (var entry in updateInfo)
{
if (entry.Ordinal != null)
@@ -789,8 +789,8 @@ void HandleJson(List columnModifications)
// that has fewer array levels than the originally collected ordinals.
var arraySegmentCount = pathSegments.Count(s => s.IsArray);
var indicesArray = ordinals.Count > arraySegmentCount
- ? ordinals.GetRange(0, arraySegmentCount).ToArray()
- : ordinals.ToArray();
+ ? ordinals.GetRange(0, arraySegmentCount)
+ : ordinals;
var jsonPath = new StructuredJsonPath(pathSegments, indicesArray);
if (jsonProperty is IProperty property)
diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs
index 2697eb9ef2b..4d6e028803e 100644
--- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs
+++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs
@@ -170,6 +170,11 @@ public override IEnumerable For(IUniqueConstraint constraint, bool
///
public override IEnumerable For(ITableIndex index, bool designTime)
{
+ foreach (var annotation in base.For(index, designTime))
+ {
+ yield return annotation;
+ }
+
if (!designTime)
{
yield break;
diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
index 0d550996239..80c8779f31d 100644
--- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
+++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs
@@ -1,3734 +1,3776 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Collections;
-using System.Globalization;
-using System.Text;
-using Microsoft.EntityFrameworkCore.SqlServer.Internal;
-using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal;
-using Microsoft.EntityFrameworkCore.SqlServer.Update.Internal;
-
-// ReSharper disable once CheckNamespace
-namespace Microsoft.EntityFrameworkCore.Migrations;
-
-///
-/// SQL Server-specific implementation of .
-///
-///
-///
-/// The service lifetime is . This means that each
-/// instance will use its own instance of this service.
-/// The implementation may depend on other services registered with any lifetime.
-/// The implementation does not need to be thread-safe.
-///
-///
-/// See Database migrations, and
-/// Accessing SQL Server and Azure SQL databases with EF Core
-/// for more information and examples.
-///
-///
-public class SqlServerMigrationsSqlGenerator : MigrationsSqlGenerator
-{
- private IReadOnlyList _operations = null!;
- private int _variableCounter = -1;
-
- private readonly ICommandBatchPreparer _commandBatchPreparer;
-
- ///
- /// Creates a new instance.
- ///
- /// Parameter object containing dependencies for this service.
- /// The command batch preparer.
- public SqlServerMigrationsSqlGenerator(
- MigrationsSqlGeneratorDependencies dependencies,
- ICommandBatchPreparer commandBatchPreparer)
- : base(dependencies)
- => _commandBatchPreparer = commandBatchPreparer;
-
- ///
- /// Generates commands from a list of operations.
- ///
- /// The operations.
- /// The target model which may be if the operations exist without a model.
- /// The options to use when generating commands.
- /// The list of commands to be executed or scripted.
- public override IReadOnlyList Generate(
- IReadOnlyList operations,
- IModel? model = null,
- MigrationsSqlGenerationOptions options = MigrationsSqlGenerationOptions.Default)
- {
- _operations = operations;
- try
- {
- return base.Generate(RewriteOperations(operations, model, options), model, options);
- }
- finally
- {
- _operations = null!;
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- ///
- /// This method uses a double-dispatch mechanism to call the method
- /// that is specific to a certain subtype of . Typically database providers
- /// will override these specific methods rather than this method. However, providers can override
- /// this methods to handle provider-specific operations.
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- switch (operation)
- {
- case SqlServerCreateDatabaseOperation createDatabaseOperation:
- Generate(createDatabaseOperation, model, builder);
- break;
- case SqlServerDropDatabaseOperation dropDatabaseOperation:
- Generate(dropDatabaseOperation, model, builder);
- break;
- default:
- base.Generate(operation, model, builder);
- break;
- }
- }
-
- ///
- protected override void Generate(AddCheckConstraintOperation operation, IModel? model, MigrationCommandListBuilder builder)
- => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- AddColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate)
- {
- if (!terminate
- && operation.Comment != null)
- {
- throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(AddColumnOperation)));
- }
-
- if (IsIdentity(operation))
- {
- // NB: This gets added to all added non-nullable columns by MigrationsModelDiffer. We need to suppress
- // it, here because SQL Server can't have both IDENTITY and a DEFAULT constraint on the same column.
- operation.DefaultValue = null;
- }
-
- var needsExec = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent)
- && operation.ComputedColumnSql != null;
- if (needsExec)
- {
- var subBuilder = new MigrationCommandListBuilder(Dependencies);
- base.Generate(operation, model, subBuilder, terminate: false);
- subBuilder.EndCommand();
-
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
- var command = subBuilder.GetCommandList().Single();
-
- builder
- .Append("EXEC(")
- .Append(stringTypeMapping.GenerateSqlLiteral(command.CommandText))
- .Append(")");
- }
- else
- {
- base.Generate(operation, model, builder, terminate: false);
- }
-
- if (terminate)
- {
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- if (operation.Comment != null)
- {
- AddDescription(
- builder, operation.Comment,
- operation.Schema,
- operation.Table,
- operation.Name);
- }
-
- builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- AddForeignKeyOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- base.Generate(operation, model, builder, terminate: false);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- AddPrimaryKeyOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- base.Generate(operation, model, builder, terminate: false);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- AlterColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- if (operation[RelationalAnnotationNames.ColumnOrder] != operation.OldColumn[RelationalAnnotationNames.ColumnOrder])
- {
- Dependencies.MigrationsLogger.ColumnOrderIgnoredWarning(operation);
- }
-
- IEnumerable? indexesToRebuild = null;
- var column = model?.GetRelationalModel().FindTable(operation.Table, operation.Schema)
- ?.Columns.FirstOrDefault(c => c.Name == operation.Name);
-
- if (operation.ComputedColumnSql != operation.OldColumn.ComputedColumnSql
- || operation.IsStored != operation.OldColumn.IsStored)
- {
- var dropColumnOperation = new DropColumnOperation
- {
- Schema = operation.Schema,
- Table = operation.Table,
- Name = operation.Name
- };
- if (column != null)
- {
- dropColumnOperation.AddAnnotations(column.GetAnnotations());
- }
-
- var addColumnOperation = new AddColumnOperation
- {
- Schema = operation.Schema,
- Table = operation.Table,
- Name = operation.Name,
- ClrType = operation.ClrType,
- ColumnType = operation.ColumnType,
- IsUnicode = operation.IsUnicode,
- IsFixedLength = operation.IsFixedLength,
- MaxLength = operation.MaxLength,
- Precision = operation.Precision,
- Scale = operation.Scale,
- IsRowVersion = operation.IsRowVersion,
- IsNullable = operation.IsNullable,
- DefaultValue = operation.DefaultValue,
- DefaultValueSql = operation.DefaultValueSql,
- ComputedColumnSql = operation.ComputedColumnSql,
- IsStored = operation.IsStored,
- Comment = operation.Comment,
- Collation = operation.Collation
- };
- addColumnOperation.AddAnnotations(operation.GetAnnotations());
-
- // TODO: Use a column rebuild instead
- indexesToRebuild = GetIndexesToRebuild(column, operation).ToList();
- DropIndexes(indexesToRebuild, builder);
- Generate(dropColumnOperation, model, builder, terminate: false);
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- Generate(addColumnOperation, model, builder);
- CreateIndexes(indexesToRebuild, builder);
- builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
-
- return;
- }
-
- var columnType = operation.ColumnType
- ?? GetColumnType(
- operation.Schema,
- operation.Table,
- operation.Name,
- operation,
- model);
-
- var narrowed = false;
- var oldColumnSupported = IsOldColumnSupported(model);
- if (oldColumnSupported)
- {
- if (IsIdentity(operation) != IsIdentity(operation.OldColumn))
- {
- throw new InvalidOperationException(SqlServerStrings.AlterIdentityColumn);
- }
-
- var oldType = operation.OldColumn.ColumnType
- ?? GetColumnType(
- operation.Schema,
- operation.Table,
- operation.Name,
- operation.OldColumn,
- model);
- narrowed = columnType != oldType
- || operation.Collation != operation.OldColumn.Collation
- || operation is { IsNullable: false, OldColumn.IsNullable: true };
- }
-
- if (narrowed)
- {
- indexesToRebuild = GetIndexesToRebuild(column, operation).ToList();
- DropIndexes(indexesToRebuild, builder);
- }
-
- // Handle change of identity seed value
- if (IsIdentity(operation) && oldColumnSupported)
- {
- Check.DebugAssert(IsIdentity(operation.OldColumn), "Unsupported column change to identity");
-
- var oldSeed = 1;
- if (TryParseIdentitySeedIncrement(operation, out var newSeed, out _)
- && (operation.OldColumn[SqlServerAnnotationNames.Identity] is null
- || TryParseIdentitySeedIncrement(operation.OldColumn, out oldSeed, out _))
- && newSeed != oldSeed)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
- var table = stringTypeMapping.GenerateSqlLiteral(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema));
-
- builder
- .Append($"DBCC CHECKIDENT({table}, RESEED, {newSeed})")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
-
- var newAnnotations = operation.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.Identity);
- var oldAnnotations = operation.OldColumn.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.Identity);
-
- var alterStatementNeeded = narrowed
- || !oldColumnSupported
- || operation.ClrType != operation.OldColumn.ClrType
- || columnType != operation.OldColumn.ColumnType
- || operation.IsUnicode != operation.OldColumn.IsUnicode
- || operation.IsFixedLength != operation.OldColumn.IsFixedLength
- || operation.MaxLength != operation.OldColumn.MaxLength
- || operation.Precision != operation.OldColumn.Precision
- || operation.Scale != operation.OldColumn.Scale
- || operation.IsRowVersion != operation.OldColumn.IsRowVersion
- || operation.IsNullable != operation.OldColumn.IsNullable
- || operation.Collation != operation.OldColumn.Collation
- || HasDifferences(newAnnotations, oldAnnotations);
-
- var (oldDefaultValue, oldDefaultValueSql) = (operation.OldColumn.DefaultValue, operation.OldColumn.DefaultValueSql);
-
- if (alterStatementNeeded
- || !Equals(operation.DefaultValue, oldDefaultValue)
- || operation.DefaultValueSql != oldDefaultValueSql)
- {
- var oldDefaultConstraintName = operation.OldColumn[RelationalAnnotationNames.DefaultConstraintName] as string;
-
- DropDefaultConstraint(operation.Schema, operation.Table, operation.Name, oldDefaultConstraintName, builder);
- (oldDefaultValue, oldDefaultValueSql) = (null, null);
- }
-
- // The column is being made non-nullable. Generate an update statement before doing that, to convert any existing null values to
- // the default value (otherwise SQL Server fails).
- if (operation is { IsNullable: false, OldColumn.IsNullable: true }
- && (operation.DefaultValueSql is not null || operation.DefaultValue is not null))
- {
- string defaultValueSql;
- if (operation.DefaultValueSql is not null)
- {
- defaultValueSql = operation.DefaultValueSql;
- }
- else
- {
- Check.DebugAssert(operation.DefaultValue is not null);
-
- var typeMapping = Dependencies.TypeMappingSource.FindMapping(operation.DefaultValue.GetType(), columnType)
- ?? Dependencies.TypeMappingSource.GetMappingForValue(operation.DefaultValue);
-
- defaultValueSql = typeMapping.GenerateSqlLiteral(operation.DefaultValue);
- }
-
- var updateBuilder = new StringBuilder()
- .Append("UPDATE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append(" SET ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" = ")
- .Append(defaultValueSql)
- .Append(" WHERE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" IS NULL");
-
- if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
- {
- builder
- .Append("EXEC(N'")
- .Append(updateBuilder.ToString().TrimEnd('\n', '\r', ';').Replace("'", "''"))
- .Append("')");
- }
- else
- {
- builder.Append(updateBuilder.ToString());
- }
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- if (alterStatementNeeded)
- {
- builder
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append(" ALTER COLUMN ");
-
- // NB: ComputedColumnSql, IsStored, DefaultValue, DefaultValueSql, Comment, ValueGenerationStrategy, and Identity are
- // handled elsewhere. Don't copy them here.
- var definitionOperation = new AlterColumnOperation
- {
- Schema = operation.Schema,
- Table = operation.Table,
- Name = operation.Name,
- ClrType = operation.ClrType,
- ColumnType = operation.ColumnType,
- IsUnicode = operation.IsUnicode,
- IsFixedLength = operation.IsFixedLength,
- MaxLength = operation.MaxLength,
- Precision = operation.Precision,
- Scale = operation.Scale,
- IsRowVersion = operation.IsRowVersion,
- IsNullable = operation.IsNullable,
- Collation = operation.Collation,
- OldColumn = operation.OldColumn
- };
- definitionOperation.AddAnnotations(
- operation.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.ValueGenerationStrategy
- && a.Name != SqlServerAnnotationNames.Identity));
-
- ColumnDefinition(
- operation.Schema,
- operation.Table,
- operation.Name,
- definitionOperation,
- model,
- builder);
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- if (!Equals(operation.DefaultValue, oldDefaultValue) || operation.DefaultValueSql != oldDefaultValueSql)
- {
- var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
-
- builder
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append(" ADD");
- DefaultValue(operation.DefaultValue, operation.DefaultValueSql, operation.ColumnType, defaultConstraintName, builder);
- builder
- .Append(" FOR ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- if (operation.OldColumn.Comment != operation.Comment)
- {
- var dropDescription = operation.OldColumn.Comment != null;
- if (dropDescription)
- {
- DropDescription(
- builder,
- operation.Schema,
- operation.Table,
- operation.Name);
- }
-
- if (operation.Comment != null)
- {
- AddDescription(
- builder, operation.Comment,
- operation.Schema,
- operation.Table,
- operation.Name,
- omitVariableDeclarations: dropDescription);
- }
- }
-
- if (narrowed)
- {
- CreateIndexes(indexesToRebuild!, builder);
- }
-
- builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- RenameIndexOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- if (string.IsNullOrEmpty(operation.Table))
- {
- throw new InvalidOperationException(SqlServerStrings.IndexTableRequired);
- }
-
- Rename(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)
- + "."
- + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name),
- operation.NewName,
- "INDEX",
- builder);
- builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(RenameSequenceOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- var name = operation.Name;
- if (operation.NewName != null
- && operation.NewName != name)
- {
- Rename(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema),
- operation.NewName,
- "OBJECT",
- builder);
-
- name = operation.NewName;
- }
-
- if (operation.NewSchema != operation.Schema
- && (operation.NewSchema != null
- || !HasLegacyRenameOperations(model)))
- {
- Transfer(operation.NewSchema, operation.Schema, name, builder);
- }
-
- builder.EndCommand();
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// , and then terminates the final command.
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- RestartSequenceOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- builder
- .Append("ALTER SEQUENCE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema))
- .Append(" RESTART");
-
- if (operation.StartValue.HasValue)
- {
- builder
- .Append(" WITH ")
- .Append(IntegerConstant(operation.StartValue.Value));
- }
-
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- EndStatement(builder);
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- CreateTableOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- var hasComments = operation.Comment != null || operation.Columns.Any(c => c.Comment != null);
-
- if (!terminate && hasComments)
- {
- throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(CreateTableOperation)));
- }
-
- var needsExec = false;
-
- var tableCreationOptions = new List();
-
- if (operation[SqlServerAnnotationNames.IsTemporal] as bool? == true)
- {
- var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string
- ?? model?.GetDefaultSchema();
-
- needsExec = historyTableSchema == null;
- var subBuilder = needsExec
- ? new MigrationCommandListBuilder(Dependencies)
- : builder;
-
- subBuilder
- .Append("CREATE TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema))
- .AppendLine(" (");
-
- using (subBuilder.Indent())
- {
- CreateTableColumns(operation, model, subBuilder);
- CreateTableConstraints(operation, model, subBuilder);
- subBuilder.AppendLine(",");
- var startColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
- var endColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
- var start = Dependencies.SqlGenerationHelper.DelimitIdentifier(startColumnName!);
- var end = Dependencies.SqlGenerationHelper.DelimitIdentifier(endColumnName!);
- subBuilder.AppendLine($"PERIOD FOR SYSTEM_TIME({start}, {end})");
- }
-
- subBuilder.Append(")");
-
- var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
- string historyTable;
- if (needsExec)
- {
- subBuilder
- .EndCommand();
-
- var execBody = subBuilder.GetCommandList().Single().CommandText.Replace("'", "''");
-
- var schemaVariable = Uniquify("@historyTableSchema");
- builder
- .AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME())")
- .Append("EXEC(N'")
- .Append(execBody);
-
- historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!);
- tableCreationOptions.Add($"SYSTEM_VERSIONING = ON (HISTORY_TABLE = ' + {schemaVariable} + N'.{historyTable})");
- }
- else
- {
- historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!, historyTableSchema);
- tableCreationOptions.Add($"SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable})");
- }
- }
- else
- {
- base.Generate(operation, model, builder, terminate: false);
- }
-
- var memoryOptimized = IsMemoryOptimized(operation);
- if (memoryOptimized)
- {
- tableCreationOptions.Add("MEMORY_OPTIMIZED = ON");
- }
-
- if (tableCreationOptions.Count > 0)
- {
- builder.Append(" WITH (");
- if (tableCreationOptions.Count == 1)
- {
- builder
- .Append(tableCreationOptions[0])
- .Append(")");
- }
- else
- {
- builder.AppendLine();
-
- using (builder.Indent())
- {
- for (var i = 0; i < tableCreationOptions.Count; i++)
- {
- builder.Append(tableCreationOptions[i]);
-
- if (i < tableCreationOptions.Count - 1)
- {
- builder.Append(",");
- }
-
- builder.AppendLine();
- }
- }
-
- builder.Append(")");
- }
- }
-
- if (needsExec)
- {
- builder.Append("')");
- }
-
- if (hasComments)
- {
- Check.DebugAssert(terminate, "terminate is false but there are comments");
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- var firstDescription = true;
- if (operation.Comment != null)
- {
- AddDescription(builder, operation.Comment, operation.Schema, operation.Name);
-
- firstDescription = false;
- }
-
- foreach (var column in operation.Columns)
- {
- if (column.Comment == null)
- {
- continue;
- }
-
- AddDescription(
- builder, column.Comment,
- operation.Schema,
- operation.Name,
- column.Name,
- omitVariableDeclarations: !firstDescription);
-
- firstDescription = false;
- }
-
- builder.EndCommand(suppressTransaction: memoryOptimized);
- }
- else if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: memoryOptimized);
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- RenameTableOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- var name = operation.Name;
- if (operation.NewName != null
- && operation.NewName != name)
- {
- Rename(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema),
- operation.NewName,
- "OBJECT",
- builder);
-
- name = operation.NewName;
- }
-
- if (operation.NewSchema != operation.Schema
- && (operation.NewSchema != null
- || !HasLegacyRenameOperations(model)))
- {
- Transfer(operation.NewSchema, operation.Schema, name, builder);
- }
-
- builder.EndCommand();
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- DropTableOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- base.Generate(operation, model, builder, terminate: false);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Name));
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- CreateIndexOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- if (operation[SqlServerAnnotationNames.FullTextIndex] is string keyIndex)
- {
- GenerateFullTextIndex(keyIndex);
- return;
- }
-
- if (operation[SqlServerAnnotationNames.VectorIndexMetric] is string)
- {
- GenerateVectorIndex();
- return;
- }
-
- var table = model?.GetRelationalModel().FindTable(operation.Table, operation.Schema);
- var hasNullableColumns = operation.Columns.Any(c => table?.FindColumn(c)?.IsNullable != false);
-
- var memoryOptimized = IsMemoryOptimized(operation, model, operation.Schema, operation.Table);
- if (memoryOptimized)
- {
- builder.Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append(" ADD INDEX ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" ");
-
- if (operation.IsUnique && !hasNullableColumns)
- {
- builder.Append("UNIQUE ");
- }
-
- IndexTraits(operation, model, builder);
-
- builder.Append("(");
- GenerateIndexColumnList(operation, model, builder);
- builder.Append(")");
- }
- else
- {
- var needsLegacyFilter = UseLegacyIndexFilters(operation, model);
- var needsExec = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent)
- && (operation.Filter != null
- || needsLegacyFilter);
- var subBuilder = needsExec
- ? new MigrationCommandListBuilder(Dependencies)
- : builder;
-
- base.Generate(operation, model, subBuilder, terminate: false);
-
- if (needsExec)
- {
- subBuilder
- .EndCommand();
-
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
- var command = subBuilder.GetCommandList().Single();
-
- builder
- .Append("EXEC(")
- .Append(stringTypeMapping.GenerateSqlLiteral(command.CommandText))
- .Append(")");
- }
- }
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: memoryOptimized);
- }
-
- void GenerateFullTextIndex(string keyIndex)
- {
- builder.Append("CREATE FULLTEXT INDEX ON ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append("(");
-
- var languages = (Dictionary?)operation.FindAnnotation(SqlServerAnnotationNames.FullTextLanguages)?.Value;
-
- for (var i = 0; i < operation.Columns.Length; i++)
- {
- if (i > 0)
- {
- builder.Append(", ");
- }
-
- builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Columns[i]));
-
- if (languages is not null && languages.TryGetValue(operation.Columns[i], out var language))
- {
- builder.Append(" LANGUAGE ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(language));
- }
- }
-
- builder.Append(") KEY INDEX ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(keyIndex));
-
- if (operation[SqlServerAnnotationNames.FullTextCatalog] is string catalog)
- {
- builder.Append(" ON ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(catalog));
- }
-
- if (operation[SqlServerAnnotationNames.FullTextChangeTracking] is FullTextChangeTracking changeTracking)
- {
- builder.Append(" WITH CHANGE_TRACKING = ");
- builder.Append(changeTracking switch
- {
- FullTextChangeTracking.Auto => "AUTO",
- FullTextChangeTracking.Manual => "MANUAL",
- FullTextChangeTracking.Off => "OFF",
- FullTextChangeTracking.OffNoPopulation => "OFF, NO POPULATION",
-
- _ => throw new UnreachableException(),
- });
- }
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true);
- }
- }
-
- void GenerateVectorIndex()
- {
- builder.Append("CREATE VECTOR INDEX ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" ON ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
- .Append("(");
- GenerateIndexColumnList(operation, model, builder);
- builder.Append(")");
-
- IndexOptions(operation, model, builder);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true);
- }
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- DropPrimaryKeyOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- base.Generate(operation, model, builder, terminate: false);
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(EnsureSchemaOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- if (string.Equals(operation.Name, "dbo", StringComparison.OrdinalIgnoreCase))
- {
- return;
- }
-
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- builder
- .Append("IF SCHEMA_ID(")
- .Append(stringTypeMapping.GenerateSqlLiteral(operation.Name))
- .Append(") IS NULL EXEC(")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- "CREATE SCHEMA "
- + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)
- + Dependencies.SqlGenerationHelper.StatementTerminator))
- .Append(")")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand();
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// , and then terminates the final command.
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- CreateSequenceOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- builder
- .Append("CREATE SEQUENCE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema));
-
- if (operation.ClrType != typeof(long))
- {
- var typeMapping = Dependencies.TypeMappingSource.GetMapping(operation.ClrType);
-
- builder
- .Append(" AS ")
- .Append(typeMapping.StoreTypeNameBase);
- }
-
- builder
- .Append(" START WITH ")
- .Append(IntegerConstant(operation.StartValue));
-
- SequenceOptions(operation, model, builder);
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- EndStatement(builder);
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected virtual void Generate(
- SqlServerCreateDatabaseOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- builder
- .Append("CREATE DATABASE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
-
- if (!string.IsNullOrEmpty(operation.FileName))
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- var fileName = ExpandFileName(operation.FileName);
- var name = Path.GetFileNameWithoutExtension(fileName);
-
- var logFileName = Path.ChangeExtension(fileName, ".ldf");
- var logName = name + "_log";
-
- // Match default naming behavior of SQL Server
- logFileName = logFileName.Insert(logFileName.Length - ".ldf".Length, "_log");
-
- builder
- .AppendLine()
- .Append("ON (NAME = ")
- .Append(stringTypeMapping.GenerateSqlLiteral(name))
- .Append(", FILENAME = ")
- .Append(stringTypeMapping.GenerateSqlLiteral(fileName))
- .Append(")")
- .AppendLine()
- .Append("LOG ON (NAME = ")
- .Append(stringTypeMapping.GenerateSqlLiteral(logName))
- .Append(", FILENAME = ")
- .Append(stringTypeMapping.GenerateSqlLiteral(logFileName))
- .Append(")");
- }
-
- if (!string.IsNullOrEmpty(operation.Collation))
- {
- builder
- .AppendLine()
- .Append("COLLATE ")
- .Append(operation.Collation);
- }
-
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true)
- .AppendLine("IF SERVERPROPERTY('EngineEdition') <> 5")
- .AppendLine("BEGIN");
-
- using (builder.Indent())
- {
- builder
- .Append("ALTER DATABASE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" SET READ_COMMITTED_SNAPSHOT ON")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- builder
- .Append("END")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true);
- }
-
- private static string ExpandFileName(string fileName)
- {
- if (fileName.StartsWith("|DataDirectory|", StringComparison.OrdinalIgnoreCase))
- {
- var dataDirectory = AppDomain.CurrentDomain.GetData("DataDirectory") as string;
- if (string.IsNullOrEmpty(dataDirectory))
- {
- dataDirectory = AppDomain.CurrentDomain.BaseDirectory;
- }
-
- fileName = Path.Combine(dataDirectory, fileName["|DataDirectory|".Length..]);
- }
-
- return Path.GetFullPath(fileName);
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected virtual void Generate(
- SqlServerDropDatabaseOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- builder
- .AppendLine("IF SERVERPROPERTY('EngineEdition') <> 5")
- .AppendLine("BEGIN");
-
- using (builder.Indent())
- {
- builder
- .Append("ALTER DATABASE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" SET SINGLE_USER WITH ROLLBACK IMMEDIATE")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- builder
- .Append("END")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true)
- .Append("DROP DATABASE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true);
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- AlterDatabaseOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- if (operation[SqlServerAnnotationNames.EditionOptions] is string editionOptions)
- {
- var dbVariable = Uniquify("@db_name");
- builder
- .AppendLine("BEGIN")
- .AppendLine($"DECLARE {dbVariable} nvarchar(max) = QUOTENAME(DB_NAME());")
- .AppendLine($"EXEC(N'ALTER DATABASE ' + {dbVariable} + ' MODIFY ( ")
- .Append(editionOptions.Replace("'", "''"))
- .AppendLine(" );');")
- .AppendLine("END")
- .AppendLine();
- }
-
- if (operation.Collation != operation.OldDatabase.Collation)
- {
- var dbVariable = Uniquify("@db_name");
- builder
- .AppendLine("BEGIN")
- .AppendLine($"DECLARE {dbVariable} nvarchar(max) = QUOTENAME(DB_NAME());");
-
- var collation = operation.Collation;
- if (operation.Collation == null)
- {
- var collationVariable = Uniquify("@defaultCollation");
- builder.AppendLine($"DECLARE {collationVariable} nvarchar(max) = CAST(SERVERPROPERTY('Collation') AS nvarchar(max));");
- collation = "' + " + collationVariable + " + N'";
- }
-
- builder
- .AppendLine($"EXEC(N'ALTER DATABASE ' + {dbVariable} + ' COLLATE {collation};');")
- .AppendLine("END")
- .AppendLine();
- }
-
- GenerateFullTextCatalogStatements(operation, builder);
-
- if (!IsMemoryOptimized(operation))
- {
- builder.EndCommand(suppressTransaction: true);
- return;
- }
-
- builder.AppendLine("IF SERVERPROPERTY('IsXTPSupported') = 1 AND SERVERPROPERTY('EngineEdition') <> 5");
- using (builder.Indent())
- {
- builder
- .AppendLine("BEGIN")
- .AppendLine("IF NOT EXISTS (");
- using (builder.Indent())
- {
- builder
- .Append("SELECT 1 FROM [sys].[filegroups] [FG] ")
- .Append("JOIN [sys].[database_files] [F] ON [FG].[data_space_id] = [F].[data_space_id] ")
- .AppendLine("WHERE [FG].[type] = N'FX' AND [F].[type] = 2)");
- }
-
- using (builder.Indent())
- {
- var dbVariable = Uniquify("@db_name");
- builder
- .AppendLine("BEGIN")
- .AppendLine("ALTER DATABASE CURRENT SET AUTO_CLOSE OFF;")
- .AppendLine($"DECLARE {dbVariable} nvarchar(max) = DB_NAME();")
- .AppendLine("DECLARE @fg_name nvarchar(max);")
- .AppendLine("SELECT TOP(1) @fg_name = [name] FROM [sys].[filegroups] WHERE [type] = N'FX';")
- .AppendLine()
- .AppendLine("IF @fg_name IS NULL");
-
- using (builder.Indent())
- {
- builder
- .AppendLine("BEGIN")
- .AppendLine($"SET @fg_name = QUOTENAME({dbVariable} + N'_MODFG');")
- .AppendLine("EXEC(N'ALTER DATABASE CURRENT ADD FILEGROUP ' + @fg_name + ' CONTAINS MEMORY_OPTIMIZED_DATA;');")
- .AppendLine("END");
- }
-
- var pathVariable = Uniquify("@path");
- builder
- .AppendLine()
- .AppendLine($"DECLARE {pathVariable} nvarchar(max);")
- .Append($"SELECT TOP(1) {pathVariable} = [physical_name] FROM [sys].[database_files] ")
- .AppendLine("WHERE charindex('\\', [physical_name]) > 0 ORDER BY [file_id];")
- .AppendLine($"IF ({pathVariable} IS NULL)")
- .IncrementIndent().AppendLine($"SET {pathVariable} = '\\' + {dbVariable};").DecrementIndent()
- .AppendLine()
- .AppendLine($"DECLARE @filename nvarchar(max) = right({pathVariable}, charindex('\\', reverse({pathVariable})) - 1);")
- .AppendLine(
- "SET @filename = REPLACE(left(@filename, len(@filename) - charindex('.', reverse(@filename))), '''', '''''') + N'_MOD';")
- .AppendLine(
- "DECLARE @new_path nvarchar(max) = REPLACE(CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS nvarchar(max)), '''', '''''') + @filename;")
- .AppendLine()
- .AppendLine("EXEC(N'");
-
- using (builder.Indent())
- {
- builder
- .AppendLine("ALTER DATABASE CURRENT")
- .AppendLine("ADD FILE (NAME=''' + @filename + ''', filename=''' + @new_path + ''')")
- .AppendLine("TO FILEGROUP ' + @fg_name + ';')");
- }
-
- builder.AppendLine("END");
- }
-
- builder.AppendLine("END");
- }
-
- builder.AppendLine()
- .AppendLine("IF SERVERPROPERTY('IsXTPSupported') = 1")
- .AppendLine("EXEC(N'");
- using (builder.Indent())
- {
- builder
- .AppendLine("ALTER DATABASE CURRENT")
- .AppendLine("SET MEMORY_OPTIMIZED_ELEVATE_TO_SNAPSHOT ON;')");
- }
-
- builder.EndCommand(suppressTransaction: true);
- }
-
- private void GenerateFullTextCatalogStatements(
- AlterDatabaseOperation operation,
- MigrationCommandListBuilder builder)
- {
- var oldCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation.OldDatabase).ToDictionary(c => c.Name, c => c);
- var newCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation).ToDictionary(c => c.Name, c => c);
-
- // Drop removed catalogs
- foreach (var (name, _) in oldCatalogs)
- {
- if (!newCatalogs.ContainsKey(name))
- {
- builder
- .Append("DROP FULLTEXT CATALOG ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .AppendLine();
- }
- }
-
- // Create added catalogs
- foreach (var (name, catalog) in newCatalogs)
- {
- if (!oldCatalogs.ContainsKey(name))
- {
- builder.Append("CREATE FULLTEXT CATALOG ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name));
-
- if (!catalog.IsAccentSensitive)
- {
- builder.Append(" WITH ACCENT_SENSITIVITY = OFF");
- }
-
- if (catalog.IsDefault)
- {
- builder.Append(" AS DEFAULT");
- }
-
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .AppendLine();
- }
- }
-
- // Alter changed catalogs
- foreach (var (name, catalog) in newCatalogs)
- {
- if (oldCatalogs.TryGetValue(name, out var oldProps))
- {
- if (oldProps.IsAccentSensitive != catalog.IsAccentSensitive)
- {
- builder
- .Append("ALTER FULLTEXT CATALOG ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
- .Append(" REBUILD WITH ACCENT_SENSITIVITY = ")
- .Append(catalog.IsAccentSensitive ? "ON" : "OFF")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .AppendLine();
- }
-
- if (!oldProps.IsDefault && catalog.IsDefault)
- {
- builder
- .Append("ALTER FULLTEXT CATALOG ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
- .Append(" AS DEFAULT")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .AppendLine();
- }
- }
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(AlterTableOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- if (IsMemoryOptimized(operation)
- ^ IsMemoryOptimized(operation.OldTable))
- {
- throw new InvalidOperationException(SqlServerStrings.AlterMemoryOptimizedTable);
- }
-
- if (operation.OldTable.Comment != operation.Comment)
- {
- var dropDescription = operation.OldTable.Comment != null;
- if (dropDescription)
- {
- DropDescription(builder, operation.Schema, operation.Name);
- }
-
- if (operation.Comment != null)
- {
- AddDescription(
- builder,
- operation.Comment,
- operation.Schema,
- operation.Name,
- omitVariableDeclarations: dropDescription);
- }
- }
-
- builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Name));
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- DropForeignKeyOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- base.Generate(operation, model, builder, terminate: false);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- DropIndexOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate)
- {
- if (string.IsNullOrEmpty(operation.Table))
- {
- throw new InvalidOperationException(SqlServerStrings.IndexTableRequired);
- }
-
- if (operation[SqlServerAnnotationNames.FullTextIndex] is string)
- {
- builder
- .Append("DROP FULLTEXT INDEX ON ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table!, operation.Schema));
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: true);
- }
-
- return;
- }
-
- var memoryOptimized = IsMemoryOptimized(operation, model, operation.Schema, operation.Table);
- if (memoryOptimized)
- {
- builder
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table!, operation.Schema))
- .Append(" DROP INDEX ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
- }
- else
- {
- builder
- .Append("DROP INDEX ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
- .Append(" ON ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema));
- }
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: memoryOptimized);
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- DropColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
-
- DropDefaultConstraint(operation.Schema, operation.Table, operation.Name, defaultConstraintName, builder);
- base.Generate(operation, model, builder, terminate: false);
-
- if (terminate)
- {
- builder
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
- }
- }
-
- ///
- /// Builds commands for the given
- /// by making calls on the given .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(
- RenameColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- Rename(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)
- + "."
- + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name),
- operation.NewName,
- "COLUMN",
- builder);
- builder.EndCommand();
- }
-
- private enum ParsingState
- {
- Normal,
- InBlockComment,
- InSquareBrackets,
- InDoubleQuotes,
- InQuotes
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// , and then terminates the final command.
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- protected override void Generate(SqlOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- if (Options.HasFlag(MigrationsSqlGenerationOptions.Script))
- {
- builder.Append(operation.Sql);
- if (!operation.Sql.EndsWith('\n'))
- {
- builder.AppendLine();
- }
-
- EndStatement(builder, operation.SuppressTransaction);
- return;
- }
-
- var preBatched = operation.Sql
- .Replace("\\\n", "")
- .Replace("\\\r\n", "")
- .Split(["\r\n", "\n"], StringSplitOptions.None);
-
- var state = ParsingState.Normal;
- var batchBuilder = new StringBuilder();
- foreach (var line in preBatched)
- {
- var trimmed = line.TrimStart();
-
- if (state == ParsingState.Normal
- && trimmed.StartsWith("GO", StringComparison.OrdinalIgnoreCase)
- && (trimmed.Length == 2
- || char.IsWhiteSpace(trimmed[2])))
- {
- var batch = batchBuilder.ToString();
- batchBuilder.Clear();
-
- var count = trimmed.Length >= 4
- && int.TryParse(trimmed.AsSpan(3), out var specifiedCount)
- ? specifiedCount
- : 1;
-
- for (var j = 0; j < count; j++)
- {
- AppendBatch(batch);
- }
- }
- else
- {
- for (var i = 0; i < trimmed.Length; i++)
- {
- var c = trimmed[i];
- var next = i + 1 < trimmed.Length ? trimmed[i + 1] : '\0';
-
- if (state == ParsingState.Normal && c == '-' && next == '-')
- {
- goto LineEnd;
- }
-
- state = state switch
- {
- ParsingState.Normal when c == '\'' => ParsingState.InQuotes,
- ParsingState.Normal when c == '[' => ParsingState.InSquareBrackets,
- ParsingState.Normal when c == '"' => ParsingState.InDoubleQuotes,
- ParsingState.Normal when c == '/' && next == '*' => ConsumeAndReturn(ref i, ParsingState.InBlockComment),
-
- ParsingState.InQuotes when c == '\'' => ParsingState.Normal,
-
- ParsingState.InSquareBrackets when c == ']' && next == ']' => ConsumeAndReturn(ref i, ParsingState.InSquareBrackets),
- ParsingState.InSquareBrackets when c == ']' => ParsingState.Normal,
-
- ParsingState.InDoubleQuotes when c == '"' => ParsingState.Normal,
-
- ParsingState.InBlockComment when c == '*' && next == '/' => ConsumeAndReturn(ref i, ParsingState.Normal),
-
- _ => state
- };
- }
-
- LineEnd:
- batchBuilder.AppendLine(line);
- }
- }
-
- AppendBatch(batchBuilder.ToString());
-
- ParsingState ConsumeAndReturn(ref int index, ParsingState newState)
- {
- index++;
- return newState;
- }
-
- void AppendBatch(string batch)
- {
- if (!string.IsNullOrWhiteSpace(batch))
- {
- builder.Append(batch);
- EndStatement(builder, operation.SuppressTransaction);
- }
- }
- }
-
- ///
- /// Builds commands for the given by making calls on the given
- /// .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to build the commands.
- /// Indicates whether or not to terminate the command after generating SQL for the operation.
- protected override void Generate(
- InsertDataOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool terminate = true)
- {
- GenerateIdentityInsert(builder, operation, on: true, model);
-
- var sqlBuilder = new StringBuilder();
-
- var modificationCommands = GenerateModificationCommands(operation, model).ToList();
- var updateSqlGenerator = (ISqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator;
-
- foreach (var batch in _commandBatchPreparer.CreateCommandBatches(modificationCommands, moreCommandSets: true))
- {
- updateSqlGenerator.AppendBulkInsertOperation(sqlBuilder, batch.ModificationCommands, commandPosition: 0);
- }
-
- if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
- {
- builder
- .Append("EXEC(N'")
- .Append(sqlBuilder.ToString().TrimEnd('\n', '\r', ';').Replace("'", "''"))
- .Append("')")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- else
- {
- builder.Append(sqlBuilder.ToString());
- }
-
- GenerateIdentityInsert(builder, operation, on: false, model);
-
- if (terminate)
- {
- builder.EndCommand();
- }
- }
-
- private void GenerateIdentityInsert(MigrationCommandListBuilder builder, InsertDataOperation operation, bool on, IModel? model)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- builder
- .Append("IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE")
- .Append(" [name] IN (")
- .Append(string.Join(", ", operation.Columns.Select(stringTypeMapping.GenerateSqlLiteral)))
- .Append(") AND [object_id] = OBJECT_ID(")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema ?? model?.GetDefaultSchema())))
- .AppendLine("))");
-
- using (builder.Indent())
- {
- builder
- .Append("SET IDENTITY_INSERT ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema ?? model?.GetDefaultSchema()))
- .Append(on ? " ON" : " OFF")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
-
- ///
- protected override void Generate(DeleteDataOperation operation, IModel? model, MigrationCommandListBuilder builder)
- => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
-
- ///
- protected override void Generate(UpdateDataOperation operation, IModel? model, MigrationCommandListBuilder builder)
- => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
-
- ///
- /// Generates a SQL fragment for the named default constraint of a column.
- ///
- /// The default value for the column.
- /// The SQL expression to use for the column's default constraint.
- /// Store/database type of the column.
- /// The command builder to use to add the SQL fragment.
- /// The constraint name to use to add the SQL fragment.
- protected virtual void DefaultValue(
- object? defaultValue,
- string? defaultValueSql,
- string? columnType,
- string? constraintName,
- MigrationCommandListBuilder builder)
- {
- if (constraintName != null && (defaultValue != null || defaultValueSql != null))
- {
- builder
- .Append(" CONSTRAINT [")
- .Append(constraintName)
- .Append("]");
- }
-
- base.DefaultValue(defaultValue, defaultValueSql, columnType, builder);
- }
-
- ///
- protected override void SequenceOptions(
- string? schema,
- string name,
- SequenceOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder,
- bool forAlter)
- {
- builder
- .Append(" INCREMENT BY ")
- .Append(IntegerConstant(operation.IncrementBy));
-
- if (operation.MinValue.HasValue)
- {
- builder
- .Append(" MINVALUE ")
- .Append(IntegerConstant(operation.MinValue.Value));
- }
- else if (forAlter)
- {
- builder.Append(" NO MINVALUE");
- }
-
- if (operation.MaxValue.HasValue)
- {
- builder
- .Append(" MAXVALUE ")
- .Append(IntegerConstant(operation.MaxValue.Value));
- }
- else if (forAlter)
- {
- builder.Append(" NO MAXVALUE");
- }
-
- builder.Append(operation.IsCyclic ? " CYCLE" : " NO CYCLE");
- }
-
- ///
- /// Generates a SQL fragment for a column definition for the given column metadata.
- ///
- /// The schema that contains the table, or to use the default schema.
- /// The table that contains the column.
- /// The column name.
- /// The column metadata.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to add the SQL fragment.
- protected override void ColumnDefinition(
- string? schema,
- string table,
- string name,
- ColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- if (operation.ComputedColumnSql != null)
- {
- ComputedColumnDefinition(schema, table, name, operation, model, builder);
-
- return;
- }
-
- var columnType = operation.ColumnType ?? GetColumnType(schema, table, name, operation, model);
- builder
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
- .Append(" ")
- .Append(columnType);
-
- if (operation.Collation != null)
- {
- // SQL Server collation docs: https://learn.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support
-
- // The default behavior in MigrationsSqlGenerator is to quote collation names, but SQL Server does not support that.
- // Instead, make sure the collation name only contains a restricted set of characters.
- foreach (var c in operation.Collation)
- {
- if (!char.IsLetterOrDigit(c) && c != '_')
- {
- throw new InvalidOperationException(SqlServerStrings.InvalidCollationName(operation.Collation));
- }
- }
-
- builder
- .Append(" COLLATE ")
- .Append(operation.Collation);
- }
-
- if (operation[SqlServerAnnotationNames.Sparse] is bool isSparse && isSparse)
- {
- builder.Append(" SPARSE");
- }
-
- var isPeriodStartColumn = operation[SqlServerAnnotationNames.TemporalIsPeriodStartColumn] as bool? == true;
- var isPeriodEndColumn = operation[SqlServerAnnotationNames.TemporalIsPeriodEndColumn] as bool? == true;
-
- if (isPeriodStartColumn || isPeriodEndColumn)
- {
- builder.Append(" GENERATED ALWAYS AS ROW ");
- builder.Append(isPeriodStartColumn ? "START" : "END");
- builder.Append(" HIDDEN");
- }
-
- builder.Append(operation.IsNullable ? " NULL" : " NOT NULL");
-
- var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
-
- if (!string.Equals(columnType, "rowversion", StringComparison.OrdinalIgnoreCase)
- && !string.Equals(columnType, "timestamp", StringComparison.OrdinalIgnoreCase))
- {
- // rowversion/timestamp columns cannot have default values, but also don't need them when adding a new column.
- DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, defaultConstraintName, builder);
- }
-
- var identity = operation[SqlServerAnnotationNames.Identity] as string;
- if (identity != null
- || operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
- == SqlServerValueGenerationStrategy.IdentityColumn)
- {
- builder.Append(" IDENTITY");
-
- if (!string.IsNullOrEmpty(identity)
- && identity != "1, 1")
- {
- builder
- .Append("(")
- .Append(identity)
- .Append(")");
- }
- }
- }
-
- ///
- /// Generates a SQL fragment for a computed column definition for the given column metadata.
- ///
- /// The schema that contains the table, or to use the default schema.
- /// The table that contains the column.
- /// The column name.
- /// The column metadata.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to add the SQL fragment.
- protected override void ComputedColumnDefinition(
- string? schema,
- string table,
- string name,
- ColumnOperation operation,
- IModel? model,
- MigrationCommandListBuilder builder)
- {
- builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name));
-
- builder
- .Append(" AS ")
- .Append(operation.ComputedColumnSql!);
-
- if (operation.Collation != null)
- {
- builder
- .Append(" COLLATE ")
- .Append(operation.Collation);
- }
-
- if (operation.IsStored == true)
- {
- builder.Append(" PERSISTED");
- }
- }
-
- ///
- /// Generates a rename.
- ///
- /// The old name.
- /// The new name.
- /// The command builder to use to build the commands.
- protected virtual void Rename(
- string name,
- string newName,
- MigrationCommandListBuilder builder)
- => Rename(name, newName, /*type:*/ null, builder);
-
- ///
- /// Generates a rename.
- ///
- /// The old name.
- /// The new name.
- /// If not , then appends literal for type of object being renamed (e.g. column or index.)
- /// The command builder to use to build the commands.
- protected virtual void Rename(
- string name,
- string newName,
- string? type,
- MigrationCommandListBuilder builder)
- {
- // Types come from https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-rename-transact-sql
- var typeMappingSource = Dependencies.TypeMappingSource;
- var nameTypeMapping = typeMappingSource.FindMapping(typeof(string), "nvarchar(776)")!;
-
- builder
- .Append("EXEC sp_rename ")
- .Append(nameTypeMapping.GenerateSqlLiteral(name))
- .Append(", ")
- .Append(nameTypeMapping.GenerateSqlLiteral(newName));
-
- if (type != null)
- {
- builder
- .Append(", ")
- .Append(typeMappingSource.FindMapping(typeof(string), "varchar(13)")!.GenerateSqlLiteral(type));
- }
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- ///
- /// Generates a transfer from one schema to another.
- ///
- /// The schema to transfer to.
- /// The schema to transfer from.
- /// The name of the item to transfer.
- /// The command builder to use to build the commands.
- protected virtual void Transfer(
- string? newSchema,
- string? schema,
- string name,
- MigrationCommandListBuilder builder)
- {
- if (newSchema == null)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- var schemaVariable = Uniquify("@defaultSchema");
- builder
- .AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME());")
- .Append("EXEC(")
- .Append($"N'ALTER SCHEMA ' + {schemaVariable} + ")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- " TRANSFER " + Dependencies.SqlGenerationHelper.DelimitIdentifier(name, schema) + ";"))
- .AppendLine(");");
- }
- else
- {
- builder
- .Append("ALTER SCHEMA ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(newSchema))
- .Append(" TRANSFER ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name, schema))
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
-
- ///
- /// Generates a SQL fragment for traits of an index from a ,
- /// , or .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to add the SQL fragment.
- protected override void IndexTraits(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- if (operation[SqlServerAnnotationNames.Clustered] is bool clustered)
- {
- builder.Append(clustered ? "CLUSTERED " : "NONCLUSTERED ");
- }
- }
-
- ///
- /// Generates a SQL fragment for extras (filter, included columns, options) of an index from a .
- ///
- /// The operation.
- /// The target model which may be if the operations exist without a model.
- /// The command builder to use to add the SQL fragment.
- protected override void IndexOptions(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
- {
- if (operation[SqlServerAnnotationNames.Include] is IReadOnlyList includeColumns
- && includeColumns.Count > 0)
- {
- builder.Append(" INCLUDE (");
- for (var i = 0; i < includeColumns.Count; i++)
- {
- builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(includeColumns[i]));
-
- if (i != includeColumns.Count - 1)
- {
- builder.Append(", ");
- }
- }
-
- builder.Append(")");
- }
-
- if (operation is CreateIndexOperation createIndexOperation)
- {
- if (!string.IsNullOrEmpty(createIndexOperation.Filter))
- {
- builder
- .Append(" WHERE ")
- .Append(createIndexOperation.Filter);
- }
- else if (UseLegacyIndexFilters(createIndexOperation, model))
- {
- var table = model?.GetRelationalModel().FindTable(createIndexOperation.Table, createIndexOperation.Schema);
- var nullableColumns = createIndexOperation.Columns
- .Where(c => table?.FindColumn(c)?.IsNullable != false)
- .ToList();
-
- builder.Append(" WHERE ");
- for (var i = 0; i < nullableColumns.Count; i++)
- {
- if (i != 0)
- {
- builder.Append(" AND ");
- }
-
- builder
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(nullableColumns[i]))
- .Append(" IS NOT NULL");
- }
- }
- }
-
- var options = new List();
-
- if (operation[SqlServerAnnotationNames.FillFactor] is int fillFactor)
- {
- options.Add("FILLFACTOR = " + fillFactor);
- }
-
- if (operation[SqlServerAnnotationNames.CreatedOnline] is bool isOnline && isOnline)
- {
- options.Add("ONLINE = ON");
- }
-
- if (operation[SqlServerAnnotationNames.SortInTempDb] is bool sortInTempDb && sortInTempDb)
- {
- options.Add("SORT_IN_TEMPDB = ON");
- }
-
- if (operation[SqlServerAnnotationNames.DataCompression] is DataCompressionType dataCompressionType)
- {
- options.Add("DATA_COMPRESSION = " + dataCompressionType switch
- {
- DataCompressionType.None => "NONE",
- DataCompressionType.Row => "ROW",
- DataCompressionType.Page => "PAGE",
-
- _ => throw new UnreachableException(),
- });
- }
-
- // 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)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping("varchar(max)");
-
- options.Add("METRIC = " + stringTypeMapping.GenerateSqlLiteral(vectorMetric));
-
- if (operation[SqlServerAnnotationNames.VectorIndexType] is string vectorType)
- {
- options.Add("TYPE = " + stringTypeMapping.GenerateSqlLiteral(vectorType));
- }
- }
-
- if (options.Count > 0)
- {
- builder
- .Append(" WITH (")
- .Append(string.Join(", ", options))
- .Append(")");
- }
- }
-
- ///
- /// Generates a SQL fragment for the given referential action.
- ///
- /// The referential action.
- /// The command builder to use to add the SQL fragment.
- protected override void ForeignKeyAction(ReferentialAction referentialAction, MigrationCommandListBuilder builder)
- {
- if (referentialAction == ReferentialAction.Restrict)
- {
- builder.Append("NO ACTION");
- }
- else
- {
- base.ForeignKeyAction(referentialAction, builder);
- }
- }
-
- ///
- /// Generates a SQL fragment to drop default constraints for a column.
- ///
- /// The schema that contains the table.
- /// The table that contains the column.
- /// The column.
- /// The name of the default constraint.
- /// The command builder to use to add the SQL fragment.
- protected virtual void DropDefaultConstraint(
- string? schema,
- string tableName,
- string columnName,
- string? defaultConstraintName,
- MigrationCommandListBuilder builder)
- {
- if (defaultConstraintName != null)
- {
- builder
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema))
- .Append(" DROP CONSTRAINT [")
- .Append(defaultConstraintName)
- .Append("]")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- return;
- }
-
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- var variable = Uniquify("@var");
-
- builder
- .Append("DECLARE ")
- .Append(variable)
- .AppendLine(" nvarchar(max);")
- .Append("SELECT ")
- .Append(variable)
- .AppendLine(" = QUOTENAME([d].[name])")
- .AppendLine("FROM [sys].[default_constraints] [d]")
- .AppendLine(
- "INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]")
- .Append("WHERE ([d].[parent_object_id] = OBJECT_ID(")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)))
- .Append(") AND [c].[name] = ")
- .Append(stringTypeMapping.GenerateSqlLiteral(columnName))
- .AppendLine(");")
- .Append("IF ")
- .Append(variable)
- .Append(" IS NOT NULL EXEC(")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- "ALTER TABLE " + Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema) + " DROP CONSTRAINT "))
- .Append(" + ")
- .Append(variable)
- .Append(" + '")
- .Append(Dependencies.SqlGenerationHelper.StatementTerminator)
- .Append("')")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- ///
- /// Gets the list of indexes that need to be rebuilt when the given column is changing.
- ///
- /// The column.
- /// The operation which may require a rebuild.
- /// The list of indexes affected.
- protected virtual IEnumerable GetIndexesToRebuild(
- IColumn? column,
- MigrationOperation currentOperation)
- {
- if (column == null)
- {
- yield break;
- }
-
- var table = column.Table;
- var createIndexOperations = _operations.SkipWhile(o => o != currentOperation).Skip(1)
- .OfType().Where(o => o.Table == table.Name && o.Schema == table.Schema).ToList();
- foreach (var index in table.Indexes)
- {
- var indexName = index.Name;
- if (createIndexOperations.Any(o => o.Name == indexName))
- {
- continue;
- }
-
- if (index.Columns.Any(c => c == column))
- {
- yield return index;
- }
- else if (index[SqlServerAnnotationNames.Include] is IReadOnlyList includeColumns
- && includeColumns.Contains(column.Name))
- {
- yield return index;
- }
- }
- }
-
- ///
- /// Generates SQL to drop the given indexes.
- ///
- /// The indexes to drop.
- /// The command builder to use to build the commands.
- protected virtual void DropIndexes(
- IEnumerable indexes,
- MigrationCommandListBuilder builder)
- {
- foreach (var index in indexes)
- {
- var table = index.Table;
- var operation = new DropIndexOperation
- {
- Schema = table.Schema,
- Table = table.Name,
- Name = index.Name
- };
- operation.AddAnnotations(index.GetAnnotations());
-
- Generate(operation, table.Model.Model, builder, terminate: false);
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
-
- ///
- /// Generates SQL to create the given indexes.
- ///
- /// The indexes to create.
- /// The command builder to use to build the commands.
- protected virtual void CreateIndexes(
- IEnumerable indexes,
- MigrationCommandListBuilder builder)
- {
- foreach (var index in indexes)
- {
- Generate(CreateIndexOperation.CreateFrom(index), index.Table.Model.Model, builder, terminate: false);
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
-
- ///
- /// Generates add commands for descriptions on tables and columns.
- ///
- /// The command builder to use to build the commands.
- /// The new description to be applied.
- /// The schema of the table.
- /// The name of the table.
- /// The name of the column.
- ///
- /// Indicates whether the variable declarations should be omitted.
- ///
- protected virtual void AddDescription(
- MigrationCommandListBuilder builder,
- string description,
- string? schema,
- string table,
- string? column = null,
- bool omitVariableDeclarations = false)
- {
- var schemaLiteral = Uniquify("@defaultSchema", increase: !omitVariableDeclarations);
- var descriptionVariable = Uniquify("@description", increase: false);
-
- if (schema == null)
- {
- if (!omitVariableDeclarations)
- {
- builder.Append($"DECLARE {schemaLiteral} AS sysname")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- builder.Append($"SET {schemaLiteral} = SCHEMA_NAME()")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
- else
- {
- schemaLiteral = Literal(schema);
- }
-
- if (!omitVariableDeclarations)
- {
- builder.Append($"DECLARE {descriptionVariable} AS sql_variant")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- builder.Append($"SET {descriptionVariable} = {Literal(description)}")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- builder
- .Append("EXEC sp_addextendedproperty 'MS_Description', ")
- .Append(descriptionVariable)
- .Append(", 'SCHEMA', ")
- .Append(schemaLiteral)
- .Append(", 'TABLE', ")
- .Append(Literal(table));
-
- if (column != null)
- {
- builder
- .Append(", 'COLUMN', ")
- .Append(Literal(column));
- }
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- string Literal(string s)
- => SqlLiteral(s);
-
- static string SqlLiteral(string value)
- {
- var builder = new StringBuilder();
-
- var start = 0;
- int i;
- int length;
- var openApostrophe = false;
- var lastConcatStartPoint = 0;
- var concatCount = 1;
- var concatStartList = new List();
- for (i = 0; i < value.Length; i++)
- {
- var lineFeed = value[i] == '\n';
- var carriageReturn = value[i] == '\r';
- var apostrophe = value[i] == '\'';
- if (lineFeed || carriageReturn || apostrophe)
- {
- length = i - start;
- if (length != 0)
- {
- if (!openApostrophe)
- {
- AddConcatOperatorIfNeeded();
- builder.Append("N\'");
- openApostrophe = true;
- }
-
- builder.Append(value.AsSpan().Slice(start, length));
- }
-
- if (lineFeed || carriageReturn)
- {
- if (openApostrophe)
- {
- builder.Append('\'');
- openApostrophe = false;
- }
-
- AddConcatOperatorIfNeeded();
- builder
- .Append("NCHAR(")
- .Append(lineFeed ? "10" : "13")
- .Append(')');
- }
- else if (apostrophe)
- {
- if (!openApostrophe)
- {
- AddConcatOperatorIfNeeded();
- builder.Append("N'");
- openApostrophe = true;
- }
-
- builder.Append("''");
- }
-
- start = i + 1;
- }
- }
-
- length = i - start;
- if (length != 0)
- {
- if (!openApostrophe)
- {
- AddConcatOperatorIfNeeded();
- builder.Append("N\'");
- openApostrophe = true;
- }
-
- builder.Append(value.AsSpan().Slice(start, length));
- }
-
- if (openApostrophe)
- {
- builder.Append('\'');
- }
-
- for (var j = concatStartList.Count - 1; j >= 0; j--)
- {
- builder.Insert(concatStartList[j], "CONCAT(");
- builder.Append(')');
- }
-
- if (builder.Length == 0)
- {
- builder.Append("N''");
- }
-
- var result = builder.ToString();
-
- return result;
-
- void AddConcatOperatorIfNeeded()
- {
- if (builder.Length != 0)
- {
- builder.Append(", ");
- concatCount++;
-
- if (concatCount == 2)
- {
- concatStartList.Add(lastConcatStartPoint);
- }
-
- if (concatCount == 254)
- {
- lastConcatStartPoint = builder.Length;
- concatCount = 1;
- }
- }
- }
- }
- }
-
- ///
- /// Generates drop commands for descriptions on tables and columns.
- ///
- /// The command builder to use to build the commands.
- /// The schema of the table.
- /// The name of the table.
- /// The name of the column.
- ///
- /// Indicates whether the variable declarations should be omitted.
- ///
- protected virtual void DropDescription(
- MigrationCommandListBuilder builder,
- string? schema,
- string table,
- string? column = null,
- bool omitVariableDeclarations = false)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- var schemaLiteral = Uniquify("@defaultSchema", increase: !omitVariableDeclarations);
- var descriptionVariable = Uniquify("@description", increase: false);
- if (schema == null)
- {
- if (!omitVariableDeclarations)
- {
- builder.Append($"DECLARE {schemaLiteral} AS sysname")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- builder.Append($"SET {schemaLiteral} = SCHEMA_NAME()")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
- }
- else
- {
- schemaLiteral = Literal(schema);
- }
-
- if (!omitVariableDeclarations)
- {
- builder.Append($"DECLARE {descriptionVariable} AS sql_variant")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
- }
-
- builder
- .Append("EXEC sp_dropextendedproperty 'MS_Description', 'SCHEMA', ")
- .Append(schemaLiteral)
- .Append(", 'TABLE', ")
- .Append(Literal(table));
-
- if (column != null)
- {
- builder
- .Append(", 'COLUMN', ")
- .Append(Literal(column));
- }
-
- builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- string Literal(string s)
- => stringTypeMapping.GenerateSqlLiteral(s);
- }
-
- ///
- /// Checks whether or not should have a filter generated for it by
- /// Migrations.
- ///
- /// The index creation operation.
- /// The target model.
- /// if a filter should be generated.
- protected virtual bool UseLegacyIndexFilters(CreateIndexOperation operation, IModel? model)
- => (!TryGetVersion(model, out var version) || VersionComparer.Compare(version, "2.0.0") < 0)
- && operation.Filter is null
- && operation.IsUnique
- && operation[SqlServerAnnotationNames.Clustered] is null or false
- && model?.GetRelationalModel().FindTable(operation.Table, operation.Schema) is var table
- && operation.Columns.Any(c => table?.FindColumn(c)?.IsNullable != false);
-
- private static string IntegerConstant(long value)
- => string.Format(CultureInfo.InvariantCulture, "{0}", value);
-
- private static bool IsMemoryOptimized(Annotatable annotatable, IModel? model, string? schema, string tableName)
- => annotatable[SqlServerAnnotationNames.MemoryOptimized] as bool?
- ?? model?.GetRelationalModel().FindTable(tableName, schema)?[SqlServerAnnotationNames.MemoryOptimized] as bool? == true;
-
- private static bool IsMemoryOptimized(Annotatable annotatable)
- => annotatable[SqlServerAnnotationNames.MemoryOptimized] as bool? == true;
-
- private static bool IsIdentity(ColumnOperation operation)
- => operation[SqlServerAnnotationNames.Identity] != null
- || operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
- == SqlServerValueGenerationStrategy.IdentityColumn;
-
- private static void RemoveIdentityAnnotations(ColumnOperation operation)
- {
- operation.RemoveAnnotation(SqlServerAnnotationNames.Identity);
-
- if (operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
- == SqlServerValueGenerationStrategy.IdentityColumn)
- {
- operation.RemoveAnnotation(SqlServerAnnotationNames.ValueGenerationStrategy);
- }
- }
-
- private static bool TryParseIdentitySeedIncrement(ColumnOperation operation, out int seed, out int increment)
- {
- if (operation[SqlServerAnnotationNames.Identity] is string seedIncrement
- && seedIncrement.Split(",") is [var seedString, var incrementString]
- && int.TryParse(seedString, out var seedParsed)
- && int.TryParse(incrementString, out var incrementParsed))
- {
- (seed, increment) = (seedParsed, incrementParsed);
- return true;
- }
-
- (seed, increment) = (0, 0);
- return false;
- }
-
- private void GenerateExecWhenIdempotent(
- MigrationCommandListBuilder builder,
- Action generate)
- {
- if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
- {
- var subBuilder = new MigrationCommandListBuilder(Dependencies);
- generate(subBuilder);
-
- var command = subBuilder.GetCommandList().Single();
- builder
- .Append("EXEC(N'")
- .Append(command.CommandText.TrimEnd('\n', '\r', ';').Replace("'", "''"))
- .Append("')")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
- .EndCommand(command.TransactionSuppressed);
-
- return;
- }
-
- generate(builder);
- }
-
- private static bool HasDifferences(IEnumerable source, IEnumerable target)
- {
- var targetAnnotations = target.ToDictionary(a => a.Name);
-
- var count = 0;
- foreach (var sourceAnnotation in source)
- {
- if (!targetAnnotations.TryGetValue(sourceAnnotation.Name, out var targetAnnotation)
- || !Equals(sourceAnnotation.Value, targetAnnotation.Value))
- {
- return true;
- }
-
- count++;
- }
-
- return count != targetAnnotations.Count;
- }
-
- private string Uniquify(string variableName, bool increase = true)
- {
- if (increase)
- {
- _variableCounter++;
- }
-
- return _variableCounter == 0 ? variableName : variableName + _variableCounter;
- }
-
- private IReadOnlyList FixLegacyTemporalAnnotations(IReadOnlyList migrationOperations)
- {
- // short-circuit for non-temporal migrations (which is the majority)
- if (migrationOperations.All(o => o[SqlServerAnnotationNames.IsTemporal] as bool? != true))
- {
- return migrationOperations;
- }
-
- var resultOperations = new List(migrationOperations.Count);
- foreach (var migrationOperation in migrationOperations)
- {
- var isTemporal = migrationOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- if (!isTemporal)
- {
- resultOperations.Add(migrationOperation);
- continue;
- }
-
- switch (migrationOperation)
- {
- case CreateTableOperation createTableOperation:
-
- foreach (var column in createTableOperation.Columns)
- {
- NormalizeTemporalAnnotationsForAddColumnOperation(column);
- }
-
- resultOperations.Add(migrationOperation);
- break;
-
- case AddColumnOperation addColumnOperation:
- NormalizeTemporalAnnotationsForAddColumnOperation(addColumnOperation);
- resultOperations.Add(addColumnOperation);
- break;
-
- case AlterColumnOperation alterColumnOperation:
- RemoveLegacyTemporalColumnAnnotations(alterColumnOperation);
- RemoveLegacyTemporalColumnAnnotations(alterColumnOperation.OldColumn);
- if (!CanSkipAlterColumnOperation(alterColumnOperation, alterColumnOperation.OldColumn))
- {
- resultOperations.Add(alterColumnOperation);
- }
-
- break;
-
- case DropColumnOperation dropColumnOperation:
- RemoveLegacyTemporalColumnAnnotations(dropColumnOperation);
- resultOperations.Add(dropColumnOperation);
- break;
-
- case RenameColumnOperation renameColumnOperation:
- RemoveLegacyTemporalColumnAnnotations(renameColumnOperation);
- resultOperations.Add(renameColumnOperation);
- break;
-
- default:
- resultOperations.Add(migrationOperation);
- break;
- }
- }
-
- return resultOperations;
-
- static void NormalizeTemporalAnnotationsForAddColumnOperation(AddColumnOperation addColumnOperation)
- {
- var periodStartColumnName = addColumnOperation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
- var periodEndColumnName = addColumnOperation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
- if (periodStartColumnName == addColumnOperation.Name)
- {
- addColumnOperation.AddAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn, true);
- }
- else if (periodEndColumnName == addColumnOperation.Name)
- {
- addColumnOperation.AddAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn, true);
- }
-
- RemoveLegacyTemporalColumnAnnotations(addColumnOperation);
- }
-
- static void RemoveLegacyTemporalColumnAnnotations(MigrationOperation operation)
- {
- operation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal);
- operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName);
- operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema);
- operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName);
- operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName);
- }
-
- static bool CanSkipAlterColumnOperation(ColumnOperation column, ColumnOperation oldColumn)
- => ColumnPropertiesAreTheSame(column, oldColumn) && AnnotationsAreTheSame(column, oldColumn);
-
- // don't compare name, table or schema - they are not being set in the model differ (since they should always be the same)
- static bool ColumnPropertiesAreTheSame(ColumnOperation column, ColumnOperation oldColumn)
- => column.ClrType == oldColumn.ClrType
- && column.Collation == oldColumn.Collation
- && column.ColumnType == oldColumn.ColumnType
- && column.Comment == oldColumn.Comment
- && column.ComputedColumnSql == oldColumn.ComputedColumnSql
- && Equals(column.DefaultValue, oldColumn.DefaultValue)
- && column.DefaultValueSql == oldColumn.DefaultValueSql
- && column.IsDestructiveChange == oldColumn.IsDestructiveChange
- && column.IsFixedLength == oldColumn.IsFixedLength
- && column.IsNullable == oldColumn.IsNullable
- && column.IsReadOnly == oldColumn.IsReadOnly
- && column.IsRowVersion == oldColumn.IsRowVersion
- && column.IsStored == oldColumn.IsStored
- && column.IsUnicode == oldColumn.IsUnicode
- && column.MaxLength == oldColumn.MaxLength
- && column.Precision == oldColumn.Precision
- && column.Scale == oldColumn.Scale;
-
- static bool AnnotationsAreTheSame(ColumnOperation column, ColumnOperation oldColumn)
- {
- var columnAnnotations = column.GetAnnotations().ToList();
- var oldColumnAnnotations = oldColumn.GetAnnotations().ToList();
-
- if (columnAnnotations.Count != oldColumnAnnotations.Count)
- {
- return false;
- }
-
- return columnAnnotations.Zip(oldColumnAnnotations)
- .All(x => x.First.Name == x.Second.Name
- && StructuralComparisons.StructuralEqualityComparer.Equals(x.First.Value, x.Second.Value));
- }
- }
-
- private IReadOnlyList RewriteOperations(
- IReadOnlyList migrationOperations,
- IModel? model,
- MigrationsSqlGenerationOptions options)
- {
- migrationOperations = FixLegacyTemporalAnnotations(migrationOperations);
-
- var operations = new List();
- var availableSchemas = new List();
-
- // we need to know temporal information for all the tables involved in the migration
- // problem is, the temporal information is stored only on table operations and not column operations
- // if migration operation doesn't contain the table operation, or the table operation comes later
- // we don't know what we should do
- // to fix that, we loop through all the operations and extract initial temporal state for relevant tables
- // if we don't encounter any table operations, then we can take information from the model
- // since migration hasn't changed it at all - be we can only know that after looping though all ops
- // once we have the initial state of the table, we can update it each time we encounter a table operation
- // and we can use what we stored when dealing with all other operations (that don't contain temporal annotations themselves)
- var temporalTableInformationMap = new Dictionary<(string TableName, string? Schema), TemporalOperationInformation>();
- var missingTemporalTableInformation = new List<(string TableName, string? Schema)>();
-
- foreach (var operation in migrationOperations)
- {
- switch (operation)
- {
- case CreateTableOperation createTableOperation:
- {
- var tableName = createTableOperation.Name;
- var rawSchema = createTableOperation.Schema;
- var schema = rawSchema ?? model?.GetDefaultSchema();
- if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
- {
- var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, createTableOperation);
- temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
- }
-
- // no need to remove from missingTemporalTableInformation - CreateTable should be first operation for this table
- // so there can't be entry for it in missingTemporalTableInformation (they are added by other/earlier operations on that table)
- // the only possibility is that we had a table before, dropped it and now creating a new table with the same name
- // but in this case we would have generated the necessary information from the DropTableOperation
- // and also removed the missingTemporalTableInformation entry if there was one before
- break;
- }
-
- case DropTableOperation dropTableOperation:
- {
- var tableName = dropTableOperation.Name;
- var rawSchema = dropTableOperation.Schema;
- var schema = rawSchema ?? model?.GetDefaultSchema();
- if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
- {
- var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, dropTableOperation);
- temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
- }
-
- missingTemporalTableInformation.Remove((tableName, rawSchema));
- break;
- }
-
- case RenameTableOperation renameTableOperation:
- {
- var tableName = renameTableOperation.Name;
- var rawSchema = renameTableOperation.Schema;
- var schema = rawSchema ?? model?.GetDefaultSchema();
- var newTableName = renameTableOperation.NewName!;
- var newRawSchema = renameTableOperation.NewSchema;
- var newSchema = newRawSchema ?? model?.GetDefaultSchema();
-
- var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, renameTableOperation);
- if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
- {
- temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
- }
-
- // we still need to check here - table with the new name could have existed before and have been deleted
- // we want to preserve the original temporal info of that deleted table
- if (!temporalTableInformationMap.ContainsKey((newTableName, newRawSchema)))
- {
- temporalTableInformationMap[(newTableName, newRawSchema)] = temporalTableInformation;
- }
-
- missingTemporalTableInformation.Remove((tableName, rawSchema));
- missingTemporalTableInformation.Remove((newTableName, newRawSchema));
-
- break;
- }
-
- case AlterTableOperation alterTableOperation:
- {
- var tableName = alterTableOperation.Name;
- var rawSchema = alterTableOperation.Schema;
- var schema = rawSchema ?? model?.GetDefaultSchema();
- if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
- {
- // we create the temporal info based on the OLD table here - we want the initial state
- var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, alterTableOperation.OldTable);
- temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
- }
-
- missingTemporalTableInformation.Remove((tableName, schema));
- break;
- }
-
- default:
- {
- if (operation is ITableMigrationOperation tableMigrationOperation)
- {
- var tableName = tableMigrationOperation.Table;
- var rawSchema = tableMigrationOperation.Schema;
- if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema))
- && !missingTemporalTableInformation.Contains((tableName, rawSchema)))
- {
- missingTemporalTableInformation.Add((tableName, rawSchema));
- }
- }
-
- break;
- }
- }
- }
-
- // fill the missing temporal information from Relational Model - it's the second best source we have
- // if we can't figure out proper temporal info from table annotations,
- // and we don't have it in relational model (for whatever reason) we assume table is not temporal
- // this last step is purely defensive and shouldn't happen in real situations
- foreach (var missingInfo in missingTemporalTableInformation)
- {
- var table = model?.GetRelationalModel().FindTable(missingInfo.TableName, missingInfo.Schema)!;
- if (table != null)
- {
- var schema = missingInfo.Schema ?? model?.GetDefaultSchema();
-
- var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, table);
- temporalTableInformationMap[(missingInfo.TableName, missingInfo.Schema)] = temporalTableInformation;
- }
- else
- {
- temporalTableInformationMap[(missingInfo.TableName, missingInfo.Schema)] = new TemporalOperationInformation
- {
- IsTemporalTable = false,
- HistoryTableName = null,
- HistoryTableSchema = null,
- PeriodStartColumnName = null,
- PeriodEndColumnName = null
- };
- }
- }
-
- var historyTables = new HashSet<(string Name, string? Schema)>(
- temporalTableInformationMap.Values
- .Where(t => t.IsTemporalTable && t.HistoryTableName != null)
- .Select(t => (t.HistoryTableName!, t.HistoryTableSchema)));
-
- if (model != null)
- {
- foreach (var table in model.GetRelationalModel().Tables)
- {
- if (table[SqlServerAnnotationNames.IsTemporal] as bool? == true
- && table[SqlServerAnnotationNames.TemporalHistoryTableName] is string modelHistoryTableName)
- {
- var modelHistoryTableSchema =
- table[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string;
- historyTables.Add((modelHistoryTableName, modelHistoryTableSchema));
- }
- }
- }
-
- // now we do proper processing - for table operations we look at the annotations on them
- // and continuously update the stored temporal info as the table is being modified
- // for column (and other) operations we don't have annotations on them, so we look into the
- // information we stored in the initial pass and updated in when processing table ops that happened earlier
- foreach (var operation in migrationOperations)
- {
- if (operation is EnsureSchemaOperation ensureSchemaOperation)
- {
- availableSchemas.Add(ensureSchemaOperation.Name);
- }
-
- if (operation is not ITableMigrationOperation tableMigrationOperation)
- {
- operations.Add(operation);
- continue;
- }
-
- var tableName = tableMigrationOperation.Table;
- var rawSchema = tableMigrationOperation.Schema;
-
- var suppressTransaction = IsMemoryOptimized(operation, model, rawSchema, tableName);
-
- var schema = rawSchema ?? model?.GetDefaultSchema();
-
- TemporalOperationInformation temporalInformation;
- if (operation is CreateTableOperation)
- {
- // for create table we always generate new temporal information from the operation itself
- // just in case there was a table with that name before that got deleted/renamed
- // also, temporal state (disabled versioning etc.) should always reset when creating a table
- temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, operation);
- temporalTableInformationMap[(tableName, rawSchema)] = temporalInformation;
- }
- else
- {
- temporalInformation = temporalTableInformationMap[(tableName, rawSchema)];
- }
-
- switch (operation)
- {
- case CreateTableOperation createTableOperation:
- {
- // for create table we always generate new temporal information from the operation itself
- // just in case there was a table with that name before that got deleted/renamed
- // this shouldn't happen as we re-use existing tables rather than drop/recreate
- // but we are being extra defensive here
- // and also, temporal state (disabled versioning etc.) should always reset when creating a table
- temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, createTableOperation);
-
- if (temporalInformation.IsTemporalTable
- && temporalInformation.HistoryTableSchema != schema
- && temporalInformation.HistoryTableSchema != null
- && !availableSchemas.Contains(temporalInformation.HistoryTableSchema))
- {
- operations.Add(new EnsureSchemaOperation { Name = temporalInformation.HistoryTableSchema });
- availableSchemas.Add(temporalInformation.HistoryTableSchema);
- }
-
- operations.Add(operation);
-
- break;
- }
-
- case DropTableOperation dropTableOperation:
- {
- var isTemporalTable = dropTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- if (isTemporalTable)
- {
- // if we don't have temporal information, but we know table is temporal
- // (based on the annotation found on the operation itself)
- // we assume that versioning must be disabled, if we have temporal info we can check properly
- if (temporalInformation is null || !temporalInformation.DisabledVersioning)
- {
- AddDisableVersioningOperation(tableName, schema, suppressTransaction);
- }
-
- if (temporalInformation is not null)
- {
- temporalInformation.ShouldEnableVersioning = false;
- temporalInformation.ShouldEnablePeriod = false;
- }
-
- operations.Add(operation);
-
- var historyTableName = dropTableOperation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
- var historyTableSchema =
- dropTableOperation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
- var dropHistoryTableOperation = new DropTableOperation { Name = historyTableName!, Schema = historyTableSchema };
- operations.Add(dropHistoryTableOperation);
- }
- else
- {
- operations.Add(operation);
- }
-
- // we removed the table, so we no longer need it's temporal information
- // there will be no more operations involving this table
- temporalTableInformationMap.Remove((tableName, schema));
-
- break;
- }
-
- case RenameTableOperation renameTableOperation:
- {
- if (temporalInformation is null)
- {
- temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, renameTableOperation);
- }
-
- var isTemporalTable = renameTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- if (isTemporalTable)
- {
- DisableVersioning(
- tableName,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: true);
- }
-
- operations.Add(operation);
-
- // since table was renamed, update entry in the temporal info map
- temporalTableInformationMap[(renameTableOperation.NewName!, renameTableOperation.NewSchema)] = temporalInformation;
- temporalTableInformationMap.Remove((tableName, schema));
-
- break;
- }
-
- case AlterTableOperation alterTableOperation:
- {
- var isTemporalTable = alterTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- var historyTableName = alterTableOperation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
- var historyTableSchema = alterTableOperation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
- var periodStartColumnName = alterTableOperation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
- var periodEndColumnName = alterTableOperation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
-
- var oldIsTemporalTable = alterTableOperation.OldTable[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- var oldHistoryTableName =
- alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
- var oldHistoryTableSchema =
- alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string
- ?? alterTableOperation.OldTable.Schema
- ?? model?[RelationalAnnotationNames.DefaultSchema] as string;
-
- if (isTemporalTable)
- {
- if (!oldIsTemporalTable)
- {
- // converting from regular table to temporal table - enable period and versioning at the end
- // other temporal information (history table, period columns etc) is added below
- temporalInformation.ShouldEnablePeriod = true;
- temporalInformation.ShouldEnableVersioning = true;
- }
- else
- {
- // changing something within temporal table
- if (oldHistoryTableName != historyTableName
- || oldHistoryTableSchema != historyTableSchema)
- {
- if (historyTableSchema != null
- && !availableSchemas.Contains(historyTableSchema))
- {
- operations.Add(new EnsureSchemaOperation { Name = historyTableSchema });
- availableSchemas.Add(historyTableSchema);
- }
-
- operations.Add(
- new RenameTableOperation
- {
- Name = oldHistoryTableName!,
- Schema = oldHistoryTableSchema,
- NewName = historyTableName,
- NewSchema = historyTableSchema
- });
-
- temporalInformation.HistoryTableName = historyTableName;
- temporalInformation.HistoryTableSchema = historyTableSchema;
- }
- }
- }
- else
- {
- if (oldIsTemporalTable)
- {
- // converting from temporal table to regular table
- var oldPeriodStartColumnName =
- alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
- var oldPeriodEndColumnName =
- alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
-
- DisableVersioning(
- tableName,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: null);
-
- if (!temporalInformation.DisabledPeriod)
- {
- DisablePeriod(tableName, schema, temporalInformation, suppressTransaction);
- }
-
- if (oldHistoryTableName != null)
- {
- operations.Add(new DropTableOperation { Name = oldHistoryTableName, Schema = oldHistoryTableSchema });
- }
-
- // also clear any pending versioning/period, that would be switched on at the end
- // we don't need it now that the table is no longer temporal
- temporalInformation.ShouldEnableVersioning = false;
- temporalInformation.ShouldEnablePeriod = false;
- }
- }
-
- temporalInformation.IsTemporalTable = isTemporalTable;
- temporalInformation.HistoryTableName = historyTableName;
- temporalInformation.HistoryTableSchema = historyTableSchema;
- temporalInformation.PeriodStartColumnName = periodStartColumnName;
- temporalInformation.PeriodEndColumnName = periodEndColumnName;
-
- if (isTemporalTable && historyTableName != null)
- {
- historyTables.Add((historyTableName, historyTableSchema));
- }
-
- operations.Add(operation);
- break;
- }
-
- case AddColumnOperation addColumnOperation:
- {
- // when adding a period column, we need to add it as a normal column first, and only later enable period
- // removing the period information now, so that when we generate SQL that adds the column we won't be making them
- // auto generated as period it won't work, unless period is enabled but we can't enable period without adding the
- // columns first - chicken and egg
- if (temporalInformation.IsTemporalTable)
- {
- addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
- addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
-
- // model differ adds default value, but for period end we need to replace it with the correct one -
- // DateTime.MaxValue
- if (addColumnOperation.Name == temporalInformation.PeriodEndColumnName)
- {
- addColumnOperation.DefaultValue = DateTime.MaxValue;
- }
-
- var isSparse = addColumnOperation[SqlServerAnnotationNames.Sparse] as bool? == true;
- var isComputed = addColumnOperation.ComputedColumnSql != null;
-
- if (isSparse || isComputed)
- {
- DisableVersioning(
- tableName,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: true);
- }
-
- // when adding sparse column to temporal table, we need to disable versioning.
- // This is because it may be the case that HistoryTable is using compression (by default)
- // and the add column operation fails in that situation
- // in order to make it work we need to disable versioning (if we haven't done it already)
- // and de-compress the HistoryTable
- if (isSparse)
- {
- DecompressTable(
- temporalInformation.HistoryTableName!, temporalInformation.HistoryTableSchema, suppressTransaction);
- }
-
- if (addColumnOperation.ComputedColumnSql != null)
- {
- DisableVersioning(
- tableName,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: true);
- }
-
- operations.Add(addColumnOperation);
-
- // when adding (non-period) column to an existing temporal table we need to check if we have disabled versioning
- // due to some other operations in the same migration (e.g. delete column)
- // if so, we need to also add the same column to history table
- if (addColumnOperation.Name != temporalInformation.PeriodStartColumnName
- && addColumnOperation.Name != temporalInformation.PeriodEndColumnName
- && temporalInformation.DisabledVersioning)
- {
- var addHistoryTableColumnOperation = CopyColumnOperation(addColumnOperation);
- addHistoryTableColumnOperation.Table = temporalInformation.HistoryTableName!;
- addHistoryTableColumnOperation.Schema = temporalInformation.HistoryTableSchema;
-
- if (addHistoryTableColumnOperation.ComputedColumnSql != null)
- {
- // computed columns are not allowed inside HistoryTables
- // but the historical computed value will be copied over to the non-computed counterpart,
- // as long as their names and types (including nullability) match
- // so we remove ComputedColumnSql info, so that the column in history table "appears normal"
- addHistoryTableColumnOperation.ComputedColumnSql = null;
- }
-
- // identity columns are not allowed inside HistoryTables
- RemoveIdentityAnnotations(addHistoryTableColumnOperation);
-
- operations.Add(addHistoryTableColumnOperation);
- }
- }
- else
- {
- // identity columns are not allowed inside HistoryTables
- if (historyTables.Contains((tableName, schema)))
- {
- RemoveIdentityAnnotations(addColumnOperation);
- }
-
- operations.Add(addColumnOperation);
- }
-
- break;
- }
-
- case DropColumnOperation dropColumnOperation:
- {
- if (temporalInformation.IsTemporalTable)
- {
- var droppingPeriodColumn = dropColumnOperation.Name == temporalInformation.PeriodStartColumnName
- || dropColumnOperation.Name == temporalInformation.PeriodEndColumnName;
-
- // if we are dropping non-period column, we should enable versioning at the end.
- // When dropping period column there is no need - we are removing the versioning for this table altogether
- DisableVersioning(
- tableName,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: droppingPeriodColumn ? null : true);
-
- if (droppingPeriodColumn && !temporalInformation.DisabledPeriod)
- {
- DisablePeriod(tableName, schema, temporalInformation, suppressTransaction);
-
- // if we remove the period columns, it means we will be dropping the table
- // also or at least convert it back to regular - no need to enable period later
- temporalInformation.ShouldEnablePeriod = false;
- }
-
- operations.Add(operation);
-
- if (!droppingPeriodColumn)
- {
- operations.Add(
- new DropColumnOperation
- {
- Name = dropColumnOperation.Name,
- Table = temporalInformation.HistoryTableName!,
- Schema = temporalInformation.HistoryTableSchema
- });
- }
- }
- else
- {
- operations.Add(operation);
- }
-
- break;
- }
-
- case RenameColumnOperation renameColumnOperation:
- {
- operations.Add(renameColumnOperation);
-
- // if we disabled period for the temporal table and now we are renaming the column,
- // we need to also rename this same column in history table
- if (temporalInformation.IsTemporalTable
- && temporalInformation.DisabledVersioning
- && temporalInformation.ShouldEnableVersioning)
- {
- var renameHistoryTableColumnOperation = new RenameColumnOperation
- {
- IsDestructiveChange = renameColumnOperation.IsDestructiveChange,
- Name = renameColumnOperation.Name,
- NewName = renameColumnOperation.NewName,
- Table = temporalInformation.HistoryTableName!,
- Schema = temporalInformation.HistoryTableSchema
- };
-
- operations.Add(renameHistoryTableColumnOperation);
- }
-
- break;
- }
-
- case AlterColumnOperation alterColumnOperation:
- {
- // we can remove temporal annotations, they don't make a difference when it comes to
- // generating ALTER COLUMN operations and could just muddy the waters
- alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
- alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
- alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
- alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
-
- if (temporalInformation.IsTemporalTable)
- {
- if (alterColumnOperation.OldColumn.ComputedColumnSql != alterColumnOperation.ComputedColumnSql)
- {
- throw new NotSupportedException(
- SqlServerStrings.TemporalMigrationModifyingComputedColumnNotSupported(
- alterColumnOperation.Name,
- alterColumnOperation.Table));
- }
-
- // for alter column operation converting column from nullable to non-nullable in the temporal table
- // we must disable versioning in order to properly handle it
- // specifically, switching values in history table from null to the default value
- var changeToNonNullable = alterColumnOperation.OldColumn.IsNullable
- && !alterColumnOperation.IsNullable;
-
- // for alter column converting to sparse we also need to disable versioning
- // in case HistoryTable is compressed (so that we can de-compress it)
- var changeToSparse = alterColumnOperation.OldColumn[SqlServerAnnotationNames.Sparse] as bool? != true
- && alterColumnOperation[SqlServerAnnotationNames.Sparse] as bool? == true;
-
- // for alter column removing default value we also need to disable versioning
- // because the default constraint needs to be removed from both main and history tables
- var removingDefaultValue = (alterColumnOperation.OldColumn.DefaultValue is not null || alterColumnOperation.OldColumn.DefaultValueSql is not null)
- && alterColumnOperation.DefaultValue is null && alterColumnOperation.DefaultValueSql is null;
-
- if (changeToNonNullable || changeToSparse || removingDefaultValue)
- {
- DisableVersioning(
- tableName!,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: true);
- }
-
- if (changeToSparse)
- {
- DecompressTable(
- temporalInformation.HistoryTableName!, temporalInformation.HistoryTableSchema, suppressTransaction);
- }
-
- operations.Add(alterColumnOperation);
-
- // when modifying a period column, we need to perform the operations as a normal column first, and only later enable period
- // removing the period information now, so that when we generate SQL that modifies the column we won't be making them auto generated as period
- // (making column auto generated is not allowed in ALTER COLUMN statement)
- // in later operation we enable the period and the period columns get set to auto generated automatically
- //
- // if the column is not period we just remove temporal information - it's no longer needed and could affect the generated sql
- // we will generate all the necessary operations involved with temporal tables here
- if (temporalInformation.DisabledVersioning && temporalInformation.ShouldEnableVersioning)
- {
- var alterHistoryTableColumn = CopyColumnOperation(alterColumnOperation);
- alterHistoryTableColumn.Table = temporalInformation.HistoryTableName!;
- alterHistoryTableColumn.Schema = temporalInformation.HistoryTableSchema;
- alterHistoryTableColumn.OldColumn = CopyColumnOperation(alterColumnOperation.OldColumn);
- alterHistoryTableColumn.OldColumn.Table = temporalInformation.HistoryTableName!;
- alterHistoryTableColumn.OldColumn.Schema = temporalInformation.HistoryTableSchema;
-
- // identity columns are not allowed inside HistoryTables
- RemoveIdentityAnnotations(alterHistoryTableColumn);
- RemoveIdentityAnnotations(alterHistoryTableColumn.OldColumn);
-
- operations.Add(alterHistoryTableColumn);
- }
- }
- else
- {
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections;
+using System.Globalization;
+using System.Text;
+using Microsoft.EntityFrameworkCore.SqlServer.Internal;
+using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal;
+using Microsoft.EntityFrameworkCore.SqlServer.Update.Internal;
+
+// ReSharper disable once CheckNamespace
+namespace Microsoft.EntityFrameworkCore.Migrations;
+
+///
+/// SQL Server-specific implementation of .
+///
+///
+///
+/// The service lifetime is . This means that each
+/// instance will use its own instance of this service.
+/// The implementation may depend on other services registered with any lifetime.
+/// The implementation does not need to be thread-safe.
+///
+///
+/// See Database migrations, and
+/// Accessing SQL Server and Azure SQL databases with EF Core
+/// for more information and examples.
+///
+///
+public class SqlServerMigrationsSqlGenerator : MigrationsSqlGenerator
+{
+ private IReadOnlyList _operations = null!;
+ private int _variableCounter = -1;
+
+ private readonly ICommandBatchPreparer _commandBatchPreparer;
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// Parameter object containing dependencies for this service.
+ /// The command batch preparer.
+ public SqlServerMigrationsSqlGenerator(
+ MigrationsSqlGeneratorDependencies dependencies,
+ ICommandBatchPreparer commandBatchPreparer)
+ : base(dependencies)
+ => _commandBatchPreparer = commandBatchPreparer;
+
+ ///
+ /// Generates commands from a list of operations.
+ ///
+ /// The operations.
+ /// The target model which may be if the operations exist without a model.
+ /// The options to use when generating commands.
+ /// The list of commands to be executed or scripted.
+ public override IReadOnlyList Generate(
+ IReadOnlyList operations,
+ IModel? model = null,
+ MigrationsSqlGenerationOptions options = MigrationsSqlGenerationOptions.Default)
+ {
+ _operations = operations;
+ try
+ {
+ return base.Generate(RewriteOperations(operations, model, options), model, options);
+ }
+ finally
+ {
+ _operations = null!;
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ ///
+ /// This method uses a double-dispatch mechanism to call the method
+ /// that is specific to a certain subtype of . Typically database providers
+ /// will override these specific methods rather than this method. However, providers can override
+ /// this methods to handle provider-specific operations.
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ switch (operation)
+ {
+ case SqlServerCreateDatabaseOperation createDatabaseOperation:
+ Generate(createDatabaseOperation, model, builder);
+ break;
+ case SqlServerDropDatabaseOperation dropDatabaseOperation:
+ Generate(dropDatabaseOperation, model, builder);
+ break;
+ default:
+ base.Generate(operation, model, builder);
+ break;
+ }
+ }
+
+ ///
+ protected override void Generate(AddCheckConstraintOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ AddColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate)
+ {
+ if (!terminate
+ && operation.Comment != null)
+ {
+ throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(AddColumnOperation)));
+ }
+
+ if (IsIdentity(operation))
+ {
+ // NB: This gets added to all added non-nullable columns by MigrationsModelDiffer. We need to suppress
+ // it, here because SQL Server can't have both IDENTITY and a DEFAULT constraint on the same column.
+ operation.DefaultValue = null;
+ }
+
+ var needsExec = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent)
+ && operation.ComputedColumnSql != null;
+ if (needsExec)
+ {
+ var subBuilder = new MigrationCommandListBuilder(Dependencies);
+ base.Generate(operation, model, subBuilder, terminate: false);
+ subBuilder.EndCommand();
+
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+ var command = subBuilder.GetCommandList().Single();
+
+ builder
+ .Append("EXEC(")
+ .Append(stringTypeMapping.GenerateSqlLiteral(command.CommandText))
+ .Append(")");
+ }
+ else
+ {
+ base.Generate(operation, model, builder, terminate: false);
+ }
+
+ if (terminate)
+ {
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ if (operation.Comment != null)
+ {
+ AddDescription(
+ builder, operation.Comment,
+ operation.Schema,
+ operation.Table,
+ operation.Name);
+ }
+
+ builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ AddForeignKeyOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ base.Generate(operation, model, builder, terminate: false);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ AddPrimaryKeyOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ base.Generate(operation, model, builder, terminate: false);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ AlterColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ if (operation[RelationalAnnotationNames.ColumnOrder] != operation.OldColumn[RelationalAnnotationNames.ColumnOrder])
+ {
+ Dependencies.MigrationsLogger.ColumnOrderIgnoredWarning(operation);
+ }
+
+ IEnumerable? indexesToRebuild = null;
+ var column = model?.GetRelationalModel().FindTable(operation.Table, operation.Schema)
+ ?.Columns.FirstOrDefault(c => c.Name == operation.Name);
+
+ if (operation.ComputedColumnSql != operation.OldColumn.ComputedColumnSql
+ || operation.IsStored != operation.OldColumn.IsStored)
+ {
+ var dropColumnOperation = new DropColumnOperation
+ {
+ Schema = operation.Schema,
+ Table = operation.Table,
+ Name = operation.Name
+ };
+ if (column != null)
+ {
+ dropColumnOperation.AddAnnotations(column.GetAnnotations());
+ }
+
+ var addColumnOperation = new AddColumnOperation
+ {
+ Schema = operation.Schema,
+ Table = operation.Table,
+ Name = operation.Name,
+ ClrType = operation.ClrType,
+ ColumnType = operation.ColumnType,
+ IsUnicode = operation.IsUnicode,
+ IsFixedLength = operation.IsFixedLength,
+ MaxLength = operation.MaxLength,
+ Precision = operation.Precision,
+ Scale = operation.Scale,
+ IsRowVersion = operation.IsRowVersion,
+ IsNullable = operation.IsNullable,
+ DefaultValue = operation.DefaultValue,
+ DefaultValueSql = operation.DefaultValueSql,
+ ComputedColumnSql = operation.ComputedColumnSql,
+ IsStored = operation.IsStored,
+ Comment = operation.Comment,
+ Collation = operation.Collation
+ };
+ addColumnOperation.AddAnnotations(operation.GetAnnotations());
+
+ // TODO: Use a column rebuild instead
+ indexesToRebuild = GetIndexesToRebuild(column, operation).ToList();
+ DropIndexes(indexesToRebuild, builder);
+ Generate(dropColumnOperation, model, builder, terminate: false);
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ Generate(addColumnOperation, model, builder);
+ CreateIndexes(indexesToRebuild, builder);
+ builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+
+ return;
+ }
+
+ var columnType = operation.ColumnType
+ ?? GetColumnType(
+ operation.Schema,
+ operation.Table,
+ operation.Name,
+ operation,
+ model);
+
+ var narrowed = false;
+ var oldColumnSupported = IsOldColumnSupported(model);
+ if (oldColumnSupported)
+ {
+ if (IsIdentity(operation) != IsIdentity(operation.OldColumn))
+ {
+ throw new InvalidOperationException(SqlServerStrings.AlterIdentityColumn);
+ }
+
+ var oldType = operation.OldColumn.ColumnType
+ ?? GetColumnType(
+ operation.Schema,
+ operation.Table,
+ operation.Name,
+ operation.OldColumn,
+ model);
+ narrowed = columnType != oldType
+ || operation.Collation != operation.OldColumn.Collation
+ || operation is { IsNullable: false, OldColumn.IsNullable: true };
+ }
+
+ if (narrowed)
+ {
+ indexesToRebuild = GetIndexesToRebuild(column, operation).ToList();
+ DropIndexes(indexesToRebuild, builder);
+ }
+
+ // Handle change of identity seed value
+ if (IsIdentity(operation) && oldColumnSupported)
+ {
+ Check.DebugAssert(IsIdentity(operation.OldColumn), "Unsupported column change to identity");
+
+ var oldSeed = 1;
+ if (TryParseIdentitySeedIncrement(operation, out var newSeed, out _)
+ && (operation.OldColumn[SqlServerAnnotationNames.Identity] is null
+ || TryParseIdentitySeedIncrement(operation.OldColumn, out oldSeed, out _))
+ && newSeed != oldSeed)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+ var table = stringTypeMapping.GenerateSqlLiteral(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema));
+
+ builder
+ .Append($"DBCC CHECKIDENT({table}, RESEED, {newSeed})")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+
+ var newAnnotations = operation.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.Identity);
+ var oldAnnotations = operation.OldColumn.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.Identity);
+
+ var alterStatementNeeded = narrowed
+ || !oldColumnSupported
+ || operation.ClrType != operation.OldColumn.ClrType
+ || columnType != operation.OldColumn.ColumnType
+ || operation.IsUnicode != operation.OldColumn.IsUnicode
+ || operation.IsFixedLength != operation.OldColumn.IsFixedLength
+ || operation.MaxLength != operation.OldColumn.MaxLength
+ || operation.Precision != operation.OldColumn.Precision
+ || operation.Scale != operation.OldColumn.Scale
+ || operation.IsRowVersion != operation.OldColumn.IsRowVersion
+ || operation.IsNullable != operation.OldColumn.IsNullable
+ || operation.Collation != operation.OldColumn.Collation
+ || HasDifferences(newAnnotations, oldAnnotations);
+
+ var (oldDefaultValue, oldDefaultValueSql) = (operation.OldColumn.DefaultValue, operation.OldColumn.DefaultValueSql);
+
+ if (alterStatementNeeded
+ || !Equals(operation.DefaultValue, oldDefaultValue)
+ || operation.DefaultValueSql != oldDefaultValueSql)
+ {
+ var oldDefaultConstraintName = operation.OldColumn[RelationalAnnotationNames.DefaultConstraintName] as string;
+
+ DropDefaultConstraint(operation.Schema, operation.Table, operation.Name, oldDefaultConstraintName, builder);
+ (oldDefaultValue, oldDefaultValueSql) = (null, null);
+ }
+
+ // The column is being made non-nullable. Generate an update statement before doing that, to convert any existing null values to
+ // the default value (otherwise SQL Server fails).
+ if (operation is { IsNullable: false, OldColumn.IsNullable: true }
+ && (operation.DefaultValueSql is not null || operation.DefaultValue is not null))
+ {
+ string defaultValueSql;
+ if (operation.DefaultValueSql is not null)
+ {
+ defaultValueSql = operation.DefaultValueSql;
+ }
+ else
+ {
+ Check.DebugAssert(operation.DefaultValue is not null);
+
+ var typeMapping = Dependencies.TypeMappingSource.FindMapping(operation.DefaultValue.GetType(), columnType)
+ ?? Dependencies.TypeMappingSource.GetMappingForValue(operation.DefaultValue);
+
+ defaultValueSql = typeMapping.GenerateSqlLiteral(operation.DefaultValue);
+ }
+
+ var updateBuilder = new StringBuilder()
+ .Append("UPDATE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append(" SET ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" = ")
+ .Append(defaultValueSql)
+ .Append(" WHERE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" IS NULL");
+
+ if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
+ {
+ builder
+ .Append("EXEC(N'")
+ .Append(updateBuilder.ToString().TrimEnd('\n', '\r', ';').Replace("'", "''"))
+ .Append("')");
+ }
+ else
+ {
+ builder.Append(updateBuilder.ToString());
+ }
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ if (alterStatementNeeded)
+ {
+ builder
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append(" ALTER COLUMN ");
+
+ // NB: ComputedColumnSql, IsStored, DefaultValue, DefaultValueSql, Comment, ValueGenerationStrategy, and Identity are
+ // handled elsewhere. Don't copy them here.
+ var definitionOperation = new AlterColumnOperation
+ {
+ Schema = operation.Schema,
+ Table = operation.Table,
+ Name = operation.Name,
+ ClrType = operation.ClrType,
+ ColumnType = operation.ColumnType,
+ IsUnicode = operation.IsUnicode,
+ IsFixedLength = operation.IsFixedLength,
+ MaxLength = operation.MaxLength,
+ Precision = operation.Precision,
+ Scale = operation.Scale,
+ IsRowVersion = operation.IsRowVersion,
+ IsNullable = operation.IsNullable,
+ Collation = operation.Collation,
+ OldColumn = operation.OldColumn
+ };
+ definitionOperation.AddAnnotations(
+ operation.GetAnnotations().Where(a => a.Name != SqlServerAnnotationNames.ValueGenerationStrategy
+ && a.Name != SqlServerAnnotationNames.Identity));
+
+ ColumnDefinition(
+ operation.Schema,
+ operation.Table,
+ operation.Name,
+ definitionOperation,
+ model,
+ builder);
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ if (!Equals(operation.DefaultValue, oldDefaultValue) || operation.DefaultValueSql != oldDefaultValueSql)
+ {
+ var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
+
+ builder
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append(" ADD");
+ DefaultValue(operation.DefaultValue, operation.DefaultValueSql, operation.ColumnType, defaultConstraintName, builder);
+ builder
+ .Append(" FOR ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ if (operation.OldColumn.Comment != operation.Comment)
+ {
+ var dropDescription = operation.OldColumn.Comment != null;
+ if (dropDescription)
+ {
+ DropDescription(
+ builder,
+ operation.Schema,
+ operation.Table,
+ operation.Name);
+ }
+
+ if (operation.Comment != null)
+ {
+ AddDescription(
+ builder, operation.Comment,
+ operation.Schema,
+ operation.Table,
+ operation.Name,
+ omitVariableDeclarations: dropDescription);
+ }
+ }
+
+ if (narrowed)
+ {
+ CreateIndexes(indexesToRebuild!, builder);
+ }
+
+ builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ RenameIndexOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ if (string.IsNullOrEmpty(operation.Table))
+ {
+ throw new InvalidOperationException(SqlServerStrings.IndexTableRequired);
+ }
+
+ Rename(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)
+ + "."
+ + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name),
+ operation.NewName,
+ "INDEX",
+ builder);
+ builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(RenameSequenceOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ var name = operation.Name;
+ if (operation.NewName != null
+ && operation.NewName != name)
+ {
+ Rename(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema),
+ operation.NewName,
+ "OBJECT",
+ builder);
+
+ name = operation.NewName;
+ }
+
+ if (operation.NewSchema != operation.Schema
+ && (operation.NewSchema != null
+ || !HasLegacyRenameOperations(model)))
+ {
+ Transfer(operation.NewSchema, operation.Schema, name, builder);
+ }
+
+ builder.EndCommand();
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// , and then terminates the final command.
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ RestartSequenceOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder
+ .Append("ALTER SEQUENCE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema))
+ .Append(" RESTART");
+
+ if (operation.StartValue.HasValue)
+ {
+ builder
+ .Append(" WITH ")
+ .Append(IntegerConstant(operation.StartValue.Value));
+ }
+
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ EndStatement(builder);
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ CreateTableOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ var hasComments = operation.Comment != null || operation.Columns.Any(c => c.Comment != null);
+
+ if (!terminate && hasComments)
+ {
+ throw new ArgumentException(SqlServerStrings.CannotProduceUnterminatedSQLWithComments(nameof(CreateTableOperation)));
+ }
+
+ var needsExec = false;
+
+ var tableCreationOptions = new List();
+
+ if (operation[SqlServerAnnotationNames.IsTemporal] as bool? == true)
+ {
+ var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string
+ ?? model?.GetDefaultSchema();
+
+ needsExec = historyTableSchema == null;
+ var subBuilder = needsExec
+ ? new MigrationCommandListBuilder(Dependencies)
+ : builder;
+
+ subBuilder
+ .Append("CREATE TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema))
+ .AppendLine(" (");
+
+ using (subBuilder.Indent())
+ {
+ CreateTableColumns(operation, model, subBuilder);
+ CreateTableConstraints(operation, model, subBuilder);
+ subBuilder.AppendLine(",");
+ var startColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
+ var endColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
+ var start = Dependencies.SqlGenerationHelper.DelimitIdentifier(startColumnName!);
+ var end = Dependencies.SqlGenerationHelper.DelimitIdentifier(endColumnName!);
+ subBuilder.AppendLine($"PERIOD FOR SYSTEM_TIME({start}, {end})");
+ }
+
+ subBuilder.Append(")");
+
+ var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
+ string historyTable;
+ if (needsExec)
+ {
+ subBuilder
+ .EndCommand();
+
+ var execBody = subBuilder.GetCommandList().Single().CommandText.Replace("'", "''");
+
+ var schemaVariable = Uniquify("@historyTableSchema");
+ builder
+ .AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME())")
+ .Append("EXEC(N'")
+ .Append(execBody);
+
+ historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!);
+ tableCreationOptions.Add($"SYSTEM_VERSIONING = ON (HISTORY_TABLE = ' + {schemaVariable} + N'.{historyTable})");
+ }
+ else
+ {
+ historyTable = Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName!, historyTableSchema);
+ tableCreationOptions.Add($"SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable})");
+ }
+ }
+ else
+ {
+ base.Generate(operation, model, builder, terminate: false);
+ }
+
+ var memoryOptimized = IsMemoryOptimized(operation);
+ if (memoryOptimized)
+ {
+ tableCreationOptions.Add("MEMORY_OPTIMIZED = ON");
+ }
+
+ if (tableCreationOptions.Count > 0)
+ {
+ builder.Append(" WITH (");
+ if (tableCreationOptions.Count == 1)
+ {
+ builder
+ .Append(tableCreationOptions[0])
+ .Append(")");
+ }
+ else
+ {
+ builder.AppendLine();
+
+ using (builder.Indent())
+ {
+ for (var i = 0; i < tableCreationOptions.Count; i++)
+ {
+ builder.Append(tableCreationOptions[i]);
+
+ if (i < tableCreationOptions.Count - 1)
+ {
+ builder.Append(",");
+ }
+
+ builder.AppendLine();
+ }
+ }
+
+ builder.Append(")");
+ }
+ }
+
+ if (needsExec)
+ {
+ builder.Append("')");
+ }
+
+ if (hasComments)
+ {
+ Check.DebugAssert(terminate, "terminate is false but there are comments");
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ var firstDescription = true;
+ if (operation.Comment != null)
+ {
+ AddDescription(builder, operation.Comment, operation.Schema, operation.Name);
+
+ firstDescription = false;
+ }
+
+ foreach (var column in operation.Columns)
+ {
+ if (column.Comment == null)
+ {
+ continue;
+ }
+
+ AddDescription(
+ builder, column.Comment,
+ operation.Schema,
+ operation.Name,
+ column.Name,
+ omitVariableDeclarations: !firstDescription);
+
+ firstDescription = false;
+ }
+
+ builder.EndCommand(suppressTransaction: memoryOptimized);
+ }
+ else if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: memoryOptimized);
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ RenameTableOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ var name = operation.Name;
+ if (operation.NewName != null
+ && operation.NewName != name)
+ {
+ Rename(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema),
+ operation.NewName,
+ "OBJECT",
+ builder);
+
+ name = operation.NewName;
+ }
+
+ if (operation.NewSchema != operation.Schema
+ && (operation.NewSchema != null
+ || !HasLegacyRenameOperations(model)))
+ {
+ Transfer(operation.NewSchema, operation.Schema, name, builder);
+ }
+
+ builder.EndCommand();
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ DropTableOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ base.Generate(operation, model, builder, terminate: false);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Name));
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ CreateIndexOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ if (operation[SqlServerAnnotationNames.FullTextIndex] is string keyIndex)
+ {
+ GenerateFullTextIndex(keyIndex);
+ return;
+ }
+
+ if (operation[SqlServerAnnotationNames.VectorIndexMetric] is string)
+ {
+ GenerateVectorIndex();
+ return;
+ }
+
+ if (operation[RelationalAnnotationNames.JsonIndex] is RelationalJsonIndex jsonIndex)
+ {
+ GenerateJsonIndex(jsonIndex);
+ return;
+ }
+
+ var table = model?.GetRelationalModel().FindTable(operation.Table, operation.Schema);
+ var hasNullableColumns = operation.Columns.Any(c => table?.FindColumn(c)?.IsNullable != false);
+
+ var memoryOptimized = IsMemoryOptimized(operation, model, operation.Schema, operation.Table);
+ if (memoryOptimized)
+ {
+ builder.Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append(" ADD INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" ");
+
+ if (operation.IsUnique && !hasNullableColumns)
+ {
+ builder.Append("UNIQUE ");
+ }
+
+ IndexTraits(operation, model, builder);
+
+ builder.Append("(");
+ GenerateIndexColumnList(operation, model, builder);
+ builder.Append(")");
+ }
+ else
+ {
+ var needsLegacyFilter = UseLegacyIndexFilters(operation, model);
+ var needsExec = Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent)
+ && (operation.Filter != null
+ || needsLegacyFilter);
+ var subBuilder = needsExec
+ ? new MigrationCommandListBuilder(Dependencies)
+ : builder;
+
+ base.Generate(operation, model, subBuilder, terminate: false);
+
+ if (needsExec)
+ {
+ subBuilder
+ .EndCommand();
+
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+ var command = subBuilder.GetCommandList().Single();
+
+ builder
+ .Append("EXEC(")
+ .Append(stringTypeMapping.GenerateSqlLiteral(command.CommandText))
+ .Append(")");
+ }
+ }
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: memoryOptimized);
+ }
+
+ void GenerateFullTextIndex(string keyIndex)
+ {
+ builder.Append("CREATE FULLTEXT INDEX ON ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append("(");
+
+ var languages = (Dictionary?)operation.FindAnnotation(SqlServerAnnotationNames.FullTextLanguages)?.Value;
+
+ for (var i = 0; i < operation.Columns.Length; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(", ");
+ }
+
+ builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Columns[i]));
+
+ if (languages is not null && languages.TryGetValue(operation.Columns[i], out var language))
+ {
+ builder.Append(" LANGUAGE ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(language));
+ }
+ }
+
+ builder.Append(") KEY INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(keyIndex));
+
+ if (operation[SqlServerAnnotationNames.FullTextCatalog] is string catalog)
+ {
+ builder.Append(" ON ").Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(catalog));
+ }
+
+ if (operation[SqlServerAnnotationNames.FullTextChangeTracking] is FullTextChangeTracking changeTracking)
+ {
+ builder.Append(" WITH CHANGE_TRACKING = ");
+ builder.Append(changeTracking switch
+ {
+ FullTextChangeTracking.Auto => "AUTO",
+ FullTextChangeTracking.Manual => "MANUAL",
+ FullTextChangeTracking.Off => "OFF",
+ FullTextChangeTracking.OffNoPopulation => "OFF, NO POPULATION",
+
+ _ => throw new UnreachableException(),
+ });
+ }
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+ }
+
+ void GenerateVectorIndex()
+ {
+ builder.Append("CREATE VECTOR INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" ON ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append("(");
+ GenerateIndexColumnList(operation, model, builder);
+ builder.Append(")");
+
+ IndexOptions(operation, model, builder);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+ }
+
+ void GenerateJsonIndex(RelationalJsonIndex jsonIndex)
+ {
+ var jsonColumn = jsonIndex.Elements[0].ContainingColumn.Name;
+ builder.Append("CREATE JSON INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" ON ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema))
+ .Append("(")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(jsonColumn))
+ .Append(") FOR (");
+
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+ for (var i = 0; i < jsonIndex.Elements.Count; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(", ");
+ }
+
+ builder.Append(stringTypeMapping.GenerateSqlLiteral(
+ new StructuredJsonPath(jsonIndex.Elements[i].Path, jsonIndex.CollectionIndices?[i])
+ .ToString(useAsteriskForNullIndex: true)));
+ }
+
+ builder.Append(")");
+
+ IndexOptions(operation, model, builder);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ DropPrimaryKeyOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ base.Generate(operation, model, builder, terminate: false);
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(EnsureSchemaOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ if (string.Equals(operation.Name, "dbo", StringComparison.OrdinalIgnoreCase))
+ {
+ return;
+ }
+
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ builder
+ .Append("IF SCHEMA_ID(")
+ .Append(stringTypeMapping.GenerateSqlLiteral(operation.Name))
+ .Append(") IS NULL EXEC(")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ "CREATE SCHEMA "
+ + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)
+ + Dependencies.SqlGenerationHelper.StatementTerminator))
+ .Append(")")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand();
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// , and then terminates the final command.
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ CreateSequenceOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder
+ .Append("CREATE SEQUENCE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name, operation.Schema));
+
+ if (operation.ClrType != typeof(long))
+ {
+ var typeMapping = Dependencies.TypeMappingSource.GetMapping(operation.ClrType);
+
+ builder
+ .Append(" AS ")
+ .Append(typeMapping.StoreTypeNameBase);
+ }
+
+ builder
+ .Append(" START WITH ")
+ .Append(IntegerConstant(operation.StartValue));
+
+ SequenceOptions(operation, model, builder);
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ EndStatement(builder);
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected virtual void Generate(
+ SqlServerCreateDatabaseOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder
+ .Append("CREATE DATABASE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
+
+ if (!string.IsNullOrEmpty(operation.FileName))
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ var fileName = ExpandFileName(operation.FileName);
+ var name = Path.GetFileNameWithoutExtension(fileName);
+
+ var logFileName = Path.ChangeExtension(fileName, ".ldf");
+ var logName = name + "_log";
+
+ // Match default naming behavior of SQL Server
+ logFileName = logFileName.Insert(logFileName.Length - ".ldf".Length, "_log");
+
+ builder
+ .AppendLine()
+ .Append("ON (NAME = ")
+ .Append(stringTypeMapping.GenerateSqlLiteral(name))
+ .Append(", FILENAME = ")
+ .Append(stringTypeMapping.GenerateSqlLiteral(fileName))
+ .Append(")")
+ .AppendLine()
+ .Append("LOG ON (NAME = ")
+ .Append(stringTypeMapping.GenerateSqlLiteral(logName))
+ .Append(", FILENAME = ")
+ .Append(stringTypeMapping.GenerateSqlLiteral(logFileName))
+ .Append(")");
+ }
+
+ if (!string.IsNullOrEmpty(operation.Collation))
+ {
+ builder
+ .AppendLine()
+ .Append("COLLATE ")
+ .Append(operation.Collation);
+ }
+
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true)
+ .AppendLine("IF SERVERPROPERTY('EngineEdition') <> 5")
+ .AppendLine("BEGIN");
+
+ using (builder.Indent())
+ {
+ builder
+ .Append("ALTER DATABASE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" SET READ_COMMITTED_SNAPSHOT ON")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ builder
+ .Append("END")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+
+ private static string ExpandFileName(string fileName)
+ {
+ if (fileName.StartsWith("|DataDirectory|", StringComparison.OrdinalIgnoreCase))
+ {
+ var dataDirectory = AppDomain.CurrentDomain.GetData("DataDirectory") as string;
+ if (string.IsNullOrEmpty(dataDirectory))
+ {
+ dataDirectory = AppDomain.CurrentDomain.BaseDirectory;
+ }
+
+ fileName = Path.Combine(dataDirectory, fileName["|DataDirectory|".Length..]);
+ }
+
+ return Path.GetFullPath(fileName);
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected virtual void Generate(
+ SqlServerDropDatabaseOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder
+ .AppendLine("IF SERVERPROPERTY('EngineEdition') <> 5")
+ .AppendLine("BEGIN");
+
+ using (builder.Indent())
+ {
+ builder
+ .Append("ALTER DATABASE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" SET SINGLE_USER WITH ROLLBACK IMMEDIATE")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ builder
+ .Append("END")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true)
+ .Append("DROP DATABASE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ AlterDatabaseOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ if (operation[SqlServerAnnotationNames.EditionOptions] is string editionOptions)
+ {
+ var dbVariable = Uniquify("@db_name");
+ builder
+ .AppendLine("BEGIN")
+ .AppendLine($"DECLARE {dbVariable} nvarchar(max) = QUOTENAME(DB_NAME());")
+ .AppendLine($"EXEC(N'ALTER DATABASE ' + {dbVariable} + ' MODIFY ( ")
+ .Append(editionOptions.Replace("'", "''"))
+ .AppendLine(" );');")
+ .AppendLine("END")
+ .AppendLine();
+ }
+
+ if (operation.Collation != operation.OldDatabase.Collation)
+ {
+ var dbVariable = Uniquify("@db_name");
+ builder
+ .AppendLine("BEGIN")
+ .AppendLine($"DECLARE {dbVariable} nvarchar(max) = QUOTENAME(DB_NAME());");
+
+ var collation = operation.Collation;
+ if (operation.Collation == null)
+ {
+ var collationVariable = Uniquify("@defaultCollation");
+ builder.AppendLine($"DECLARE {collationVariable} nvarchar(max) = CAST(SERVERPROPERTY('Collation') AS nvarchar(max));");
+ collation = "' + " + collationVariable + " + N'";
+ }
+
+ builder
+ .AppendLine($"EXEC(N'ALTER DATABASE ' + {dbVariable} + ' COLLATE {collation};');")
+ .AppendLine("END")
+ .AppendLine();
+ }
+
+ GenerateFullTextCatalogStatements(operation, builder);
+
+ if (!IsMemoryOptimized(operation))
+ {
+ builder.EndCommand(suppressTransaction: true);
+ return;
+ }
+
+ builder.AppendLine("IF SERVERPROPERTY('IsXTPSupported') = 1 AND SERVERPROPERTY('EngineEdition') <> 5");
+ using (builder.Indent())
+ {
+ builder
+ .AppendLine("BEGIN")
+ .AppendLine("IF NOT EXISTS (");
+ using (builder.Indent())
+ {
+ builder
+ .Append("SELECT 1 FROM [sys].[filegroups] [FG] ")
+ .Append("JOIN [sys].[database_files] [F] ON [FG].[data_space_id] = [F].[data_space_id] ")
+ .AppendLine("WHERE [FG].[type] = N'FX' AND [F].[type] = 2)");
+ }
+
+ using (builder.Indent())
+ {
+ var dbVariable = Uniquify("@db_name");
+ builder
+ .AppendLine("BEGIN")
+ .AppendLine("ALTER DATABASE CURRENT SET AUTO_CLOSE OFF;")
+ .AppendLine($"DECLARE {dbVariable} nvarchar(max) = DB_NAME();")
+ .AppendLine("DECLARE @fg_name nvarchar(max);")
+ .AppendLine("SELECT TOP(1) @fg_name = [name] FROM [sys].[filegroups] WHERE [type] = N'FX';")
+ .AppendLine()
+ .AppendLine("IF @fg_name IS NULL");
+
+ using (builder.Indent())
+ {
+ builder
+ .AppendLine("BEGIN")
+ .AppendLine($"SET @fg_name = QUOTENAME({dbVariable} + N'_MODFG');")
+ .AppendLine("EXEC(N'ALTER DATABASE CURRENT ADD FILEGROUP ' + @fg_name + ' CONTAINS MEMORY_OPTIMIZED_DATA;');")
+ .AppendLine("END");
+ }
+
+ var pathVariable = Uniquify("@path");
+ builder
+ .AppendLine()
+ .AppendLine($"DECLARE {pathVariable} nvarchar(max);")
+ .Append($"SELECT TOP(1) {pathVariable} = [physical_name] FROM [sys].[database_files] ")
+ .AppendLine("WHERE charindex('\\', [physical_name]) > 0 ORDER BY [file_id];")
+ .AppendLine($"IF ({pathVariable} IS NULL)")
+ .IncrementIndent().AppendLine($"SET {pathVariable} = '\\' + {dbVariable};").DecrementIndent()
+ .AppendLine()
+ .AppendLine($"DECLARE @filename nvarchar(max) = right({pathVariable}, charindex('\\', reverse({pathVariable})) - 1);")
+ .AppendLine(
+ "SET @filename = REPLACE(left(@filename, len(@filename) - charindex('.', reverse(@filename))), '''', '''''') + N'_MOD';")
+ .AppendLine(
+ "DECLARE @new_path nvarchar(max) = REPLACE(CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS nvarchar(max)), '''', '''''') + @filename;")
+ .AppendLine()
+ .AppendLine("EXEC(N'");
+
+ using (builder.Indent())
+ {
+ builder
+ .AppendLine("ALTER DATABASE CURRENT")
+ .AppendLine("ADD FILE (NAME=''' + @filename + ''', filename=''' + @new_path + ''')")
+ .AppendLine("TO FILEGROUP ' + @fg_name + ';')");
+ }
+
+ builder.AppendLine("END");
+ }
+
+ builder.AppendLine("END");
+ }
+
+ builder.AppendLine()
+ .AppendLine("IF SERVERPROPERTY('IsXTPSupported') = 1")
+ .AppendLine("EXEC(N'");
+ using (builder.Indent())
+ {
+ builder
+ .AppendLine("ALTER DATABASE CURRENT")
+ .AppendLine("SET MEMORY_OPTIMIZED_ELEVATE_TO_SNAPSHOT ON;')");
+ }
+
+ builder.EndCommand(suppressTransaction: true);
+ }
+
+ private void GenerateFullTextCatalogStatements(
+ AlterDatabaseOperation operation,
+ MigrationCommandListBuilder builder)
+ {
+ var oldCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation.OldDatabase).ToDictionary(c => c.Name, c => c);
+ var newCatalogs = SqlServerFullTextCatalog.GetFullTextCatalogs(operation).ToDictionary(c => c.Name, c => c);
+
+ // Drop removed catalogs
+ foreach (var (name, _) in oldCatalogs)
+ {
+ if (!newCatalogs.ContainsKey(name))
+ {
+ builder
+ .Append("DROP FULLTEXT CATALOG ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .AppendLine();
+ }
+ }
+
+ // Create added catalogs
+ foreach (var (name, catalog) in newCatalogs)
+ {
+ if (!oldCatalogs.ContainsKey(name))
+ {
+ builder.Append("CREATE FULLTEXT CATALOG ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name));
+
+ if (!catalog.IsAccentSensitive)
+ {
+ builder.Append(" WITH ACCENT_SENSITIVITY = OFF");
+ }
+
+ if (catalog.IsDefault)
+ {
+ builder.Append(" AS DEFAULT");
+ }
+
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .AppendLine();
+ }
+ }
+
+ // Alter changed catalogs
+ foreach (var (name, catalog) in newCatalogs)
+ {
+ if (oldCatalogs.TryGetValue(name, out var oldProps))
+ {
+ if (oldProps.IsAccentSensitive != catalog.IsAccentSensitive)
+ {
+ builder
+ .Append("ALTER FULLTEXT CATALOG ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
+ .Append(" REBUILD WITH ACCENT_SENSITIVITY = ")
+ .Append(catalog.IsAccentSensitive ? "ON" : "OFF")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .AppendLine();
+ }
+
+ if (!oldProps.IsDefault && catalog.IsDefault)
+ {
+ builder
+ .Append("ALTER FULLTEXT CATALOG ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
+ .Append(" AS DEFAULT")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .AppendLine();
+ }
+ }
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(AlterTableOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ if (IsMemoryOptimized(operation)
+ ^ IsMemoryOptimized(operation.OldTable))
+ {
+ throw new InvalidOperationException(SqlServerStrings.AlterMemoryOptimizedTable);
+ }
+
+ if (operation.OldTable.Comment != operation.Comment)
+ {
+ var dropDescription = operation.OldTable.Comment != null;
+ if (dropDescription)
+ {
+ DropDescription(builder, operation.Schema, operation.Name);
+ }
+
+ if (operation.Comment != null)
+ {
+ AddDescription(
+ builder,
+ operation.Comment,
+ operation.Schema,
+ operation.Name,
+ omitVariableDeclarations: dropDescription);
+ }
+ }
+
+ builder.EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Name));
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ DropForeignKeyOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ base.Generate(operation, model, builder, terminate: false);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ DropIndexOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate)
+ {
+ if (string.IsNullOrEmpty(operation.Table))
+ {
+ throw new InvalidOperationException(SqlServerStrings.IndexTableRequired);
+ }
+
+ if (operation[SqlServerAnnotationNames.FullTextIndex] is string)
+ {
+ builder
+ .Append("DROP FULLTEXT INDEX ON ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table!, operation.Schema));
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: true);
+ }
+
+ return;
+ }
+
+ var memoryOptimized = IsMemoryOptimized(operation, model, operation.Schema, operation.Table);
+ if (memoryOptimized)
+ {
+ builder
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table!, operation.Schema))
+ .Append(" DROP INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name));
+ }
+ else
+ {
+ builder
+ .Append("DROP INDEX ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name))
+ .Append(" ON ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema));
+ }
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: memoryOptimized);
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ DropColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
+
+ DropDefaultConstraint(operation.Schema, operation.Table, operation.Name, defaultConstraintName, builder);
+ base.Generate(operation, model, builder, terminate: false);
+
+ if (terminate)
+ {
+ builder
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(suppressTransaction: IsMemoryOptimized(operation, model, operation.Schema, operation.Table));
+ }
+ }
+
+ ///
+ /// Builds commands for the given
+ /// by making calls on the given .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(
+ RenameColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ Rename(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)
+ + "."
+ + Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name),
+ operation.NewName,
+ "COLUMN",
+ builder);
+ builder.EndCommand();
+ }
+
+ private enum ParsingState
+ {
+ Normal,
+ InBlockComment,
+ InSquareBrackets,
+ InDoubleQuotes,
+ InQuotes
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// , and then terminates the final command.
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ protected override void Generate(SqlOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ if (Options.HasFlag(MigrationsSqlGenerationOptions.Script))
+ {
+ builder.Append(operation.Sql);
+ if (!operation.Sql.EndsWith('\n'))
+ {
+ builder.AppendLine();
+ }
+
+ EndStatement(builder, operation.SuppressTransaction);
+ return;
+ }
+
+ var preBatched = operation.Sql
+ .Replace("\\\n", "")
+ .Replace("\\\r\n", "")
+ .Split(["\r\n", "\n"], StringSplitOptions.None);
+
+ var state = ParsingState.Normal;
+ var batchBuilder = new StringBuilder();
+ foreach (var line in preBatched)
+ {
+ var trimmed = line.TrimStart();
+
+ if (state == ParsingState.Normal
+ && trimmed.StartsWith("GO", StringComparison.OrdinalIgnoreCase)
+ && (trimmed.Length == 2
+ || char.IsWhiteSpace(trimmed[2])))
+ {
+ var batch = batchBuilder.ToString();
+ batchBuilder.Clear();
+
+ var count = trimmed.Length >= 4
+ && int.TryParse(trimmed.AsSpan(3), out var specifiedCount)
+ ? specifiedCount
+ : 1;
+
+ for (var j = 0; j < count; j++)
+ {
+ AppendBatch(batch);
+ }
+ }
+ else
+ {
+ for (var i = 0; i < trimmed.Length; i++)
+ {
+ var c = trimmed[i];
+ var next = i + 1 < trimmed.Length ? trimmed[i + 1] : '\0';
+
+ if (state == ParsingState.Normal && c == '-' && next == '-')
+ {
+ goto LineEnd;
+ }
+
+ state = state switch
+ {
+ ParsingState.Normal when c == '\'' => ParsingState.InQuotes,
+ ParsingState.Normal when c == '[' => ParsingState.InSquareBrackets,
+ ParsingState.Normal when c == '"' => ParsingState.InDoubleQuotes,
+ ParsingState.Normal when c == '/' && next == '*' => ConsumeAndReturn(ref i, ParsingState.InBlockComment),
+
+ ParsingState.InQuotes when c == '\'' => ParsingState.Normal,
+
+ ParsingState.InSquareBrackets when c == ']' && next == ']' => ConsumeAndReturn(ref i, ParsingState.InSquareBrackets),
+ ParsingState.InSquareBrackets when c == ']' => ParsingState.Normal,
+
+ ParsingState.InDoubleQuotes when c == '"' => ParsingState.Normal,
+
+ ParsingState.InBlockComment when c == '*' && next == '/' => ConsumeAndReturn(ref i, ParsingState.Normal),
+
+ _ => state
+ };
+ }
+
+ LineEnd:
+ batchBuilder.AppendLine(line);
+ }
+ }
+
+ AppendBatch(batchBuilder.ToString());
+
+ ParsingState ConsumeAndReturn(ref int index, ParsingState newState)
+ {
+ index++;
+ return newState;
+ }
+
+ void AppendBatch(string batch)
+ {
+ if (!string.IsNullOrWhiteSpace(batch))
+ {
+ builder.Append(batch);
+ EndStatement(builder, operation.SuppressTransaction);
+ }
+ }
+ }
+
+ ///
+ /// Builds commands for the given by making calls on the given
+ /// .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to build the commands.
+ /// Indicates whether or not to terminate the command after generating SQL for the operation.
+ protected override void Generate(
+ InsertDataOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool terminate = true)
+ {
+ GenerateIdentityInsert(builder, operation, on: true, model);
+
+ var sqlBuilder = new StringBuilder();
+
+ var modificationCommands = GenerateModificationCommands(operation, model).ToList();
+ var updateSqlGenerator = (ISqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator;
+
+ foreach (var batch in _commandBatchPreparer.CreateCommandBatches(modificationCommands, moreCommandSets: true))
+ {
+ updateSqlGenerator.AppendBulkInsertOperation(sqlBuilder, batch.ModificationCommands, commandPosition: 0);
+ }
+
+ if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
+ {
+ builder
+ .Append("EXEC(N'")
+ .Append(sqlBuilder.ToString().TrimEnd('\n', '\r', ';').Replace("'", "''"))
+ .Append("')")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ else
+ {
+ builder.Append(sqlBuilder.ToString());
+ }
+
+ GenerateIdentityInsert(builder, operation, on: false, model);
+
+ if (terminate)
+ {
+ builder.EndCommand();
+ }
+ }
+
+ private void GenerateIdentityInsert(MigrationCommandListBuilder builder, InsertDataOperation operation, bool on, IModel? model)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ builder
+ .Append("IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE")
+ .Append(" [name] IN (")
+ .Append(string.Join(", ", operation.Columns.Select(stringTypeMapping.GenerateSqlLiteral)))
+ .Append(") AND [object_id] = OBJECT_ID(")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema ?? model?.GetDefaultSchema())))
+ .AppendLine("))");
+
+ using (builder.Indent())
+ {
+ builder
+ .Append("SET IDENTITY_INSERT ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema ?? model?.GetDefaultSchema()))
+ .Append(on ? " ON" : " OFF")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+
+ ///
+ protected override void Generate(DeleteDataOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
+
+ ///
+ protected override void Generate(UpdateDataOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ => GenerateExecWhenIdempotent(builder, b => base.Generate(operation, model, b));
+
+ ///
+ /// Generates a SQL fragment for the named default constraint of a column.
+ ///
+ /// The default value for the column.
+ /// The SQL expression to use for the column's default constraint.
+ /// Store/database type of the column.
+ /// The command builder to use to add the SQL fragment.
+ /// The constraint name to use to add the SQL fragment.
+ protected virtual void DefaultValue(
+ object? defaultValue,
+ string? defaultValueSql,
+ string? columnType,
+ string? constraintName,
+ MigrationCommandListBuilder builder)
+ {
+ if (constraintName != null && (defaultValue != null || defaultValueSql != null))
+ {
+ builder
+ .Append(" CONSTRAINT [")
+ .Append(constraintName)
+ .Append("]");
+ }
+
+ base.DefaultValue(defaultValue, defaultValueSql, columnType, builder);
+ }
+
+ ///
+ protected override void SequenceOptions(
+ string? schema,
+ string name,
+ SequenceOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder,
+ bool forAlter)
+ {
+ builder
+ .Append(" INCREMENT BY ")
+ .Append(IntegerConstant(operation.IncrementBy));
+
+ if (operation.MinValue.HasValue)
+ {
+ builder
+ .Append(" MINVALUE ")
+ .Append(IntegerConstant(operation.MinValue.Value));
+ }
+ else if (forAlter)
+ {
+ builder.Append(" NO MINVALUE");
+ }
+
+ if (operation.MaxValue.HasValue)
+ {
+ builder
+ .Append(" MAXVALUE ")
+ .Append(IntegerConstant(operation.MaxValue.Value));
+ }
+ else if (forAlter)
+ {
+ builder.Append(" NO MAXVALUE");
+ }
+
+ builder.Append(operation.IsCyclic ? " CYCLE" : " NO CYCLE");
+ }
+
+ ///
+ /// Generates a SQL fragment for a column definition for the given column metadata.
+ ///
+ /// The schema that contains the table, or to use the default schema.
+ /// The table that contains the column.
+ /// The column name.
+ /// The column metadata.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to add the SQL fragment.
+ protected override void ColumnDefinition(
+ string? schema,
+ string table,
+ string name,
+ ColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ if (operation.ComputedColumnSql != null)
+ {
+ ComputedColumnDefinition(schema, table, name, operation, model, builder);
+
+ return;
+ }
+
+ var columnType = operation.ColumnType ?? GetColumnType(schema, table, name, operation, model);
+ builder
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
+ .Append(" ")
+ .Append(columnType);
+
+ if (operation.Collation != null)
+ {
+ // SQL Server collation docs: https://learn.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support
+
+ // The default behavior in MigrationsSqlGenerator is to quote collation names, but SQL Server does not support that.
+ // Instead, make sure the collation name only contains a restricted set of characters.
+ foreach (var c in operation.Collation)
+ {
+ if (!char.IsLetterOrDigit(c) && c != '_')
+ {
+ throw new InvalidOperationException(SqlServerStrings.InvalidCollationName(operation.Collation));
+ }
+ }
+
+ builder
+ .Append(" COLLATE ")
+ .Append(operation.Collation);
+ }
+
+ if (operation[SqlServerAnnotationNames.Sparse] is bool isSparse && isSparse)
+ {
+ builder.Append(" SPARSE");
+ }
+
+ var isPeriodStartColumn = operation[SqlServerAnnotationNames.TemporalIsPeriodStartColumn] as bool? == true;
+ var isPeriodEndColumn = operation[SqlServerAnnotationNames.TemporalIsPeriodEndColumn] as bool? == true;
+
+ if (isPeriodStartColumn || isPeriodEndColumn)
+ {
+ builder.Append(" GENERATED ALWAYS AS ROW ");
+ builder.Append(isPeriodStartColumn ? "START" : "END");
+ builder.Append(" HIDDEN");
+ }
+
+ builder.Append(operation.IsNullable ? " NULL" : " NOT NULL");
+
+ var defaultConstraintName = operation[RelationalAnnotationNames.DefaultConstraintName] as string;
+
+ if (!string.Equals(columnType, "rowversion", StringComparison.OrdinalIgnoreCase)
+ && !string.Equals(columnType, "timestamp", StringComparison.OrdinalIgnoreCase))
+ {
+ // rowversion/timestamp columns cannot have default values, but also don't need them when adding a new column.
+ DefaultValue(operation.DefaultValue, operation.DefaultValueSql, columnType, defaultConstraintName, builder);
+ }
+
+ var identity = operation[SqlServerAnnotationNames.Identity] as string;
+ if (identity != null
+ || operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
+ == SqlServerValueGenerationStrategy.IdentityColumn)
+ {
+ builder.Append(" IDENTITY");
+
+ if (!string.IsNullOrEmpty(identity)
+ && identity != "1, 1")
+ {
+ builder
+ .Append("(")
+ .Append(identity)
+ .Append(")");
+ }
+ }
+ }
+
+ ///
+ /// Generates a SQL fragment for a computed column definition for the given column metadata.
+ ///
+ /// The schema that contains the table, or to use the default schema.
+ /// The table that contains the column.
+ /// The column name.
+ /// The column metadata.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to add the SQL fragment.
+ protected override void ComputedColumnDefinition(
+ string? schema,
+ string table,
+ string name,
+ ColumnOperation operation,
+ IModel? model,
+ MigrationCommandListBuilder builder)
+ {
+ builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name));
+
+ builder
+ .Append(" AS ")
+ .Append(operation.ComputedColumnSql!);
+
+ if (operation.Collation != null)
+ {
+ builder
+ .Append(" COLLATE ")
+ .Append(operation.Collation);
+ }
+
+ if (operation.IsStored == true)
+ {
+ builder.Append(" PERSISTED");
+ }
+ }
+
+ ///
+ /// Generates a rename.
+ ///
+ /// The old name.
+ /// The new name.
+ /// The command builder to use to build the commands.
+ protected virtual void Rename(
+ string name,
+ string newName,
+ MigrationCommandListBuilder builder)
+ => Rename(name, newName, /*type:*/ null, builder);
+
+ ///
+ /// Generates a rename.
+ ///
+ /// The old name.
+ /// The new name.
+ /// If not , then appends literal for type of object being renamed (e.g. column or index.)
+ /// The command builder to use to build the commands.
+ protected virtual void Rename(
+ string name,
+ string newName,
+ string? type,
+ MigrationCommandListBuilder builder)
+ {
+ // Types come from https://learn.microsoft.com/sql/relational-databases/system-stored-procedures/sp-rename-transact-sql
+ var typeMappingSource = Dependencies.TypeMappingSource;
+ var nameTypeMapping = typeMappingSource.FindMapping(typeof(string), "nvarchar(776)")!;
+
+ builder
+ .Append("EXEC sp_rename ")
+ .Append(nameTypeMapping.GenerateSqlLiteral(name))
+ .Append(", ")
+ .Append(nameTypeMapping.GenerateSqlLiteral(newName));
+
+ if (type != null)
+ {
+ builder
+ .Append(", ")
+ .Append(typeMappingSource.FindMapping(typeof(string), "varchar(13)")!.GenerateSqlLiteral(type));
+ }
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ ///
+ /// Generates a transfer from one schema to another.
+ ///
+ /// The schema to transfer to.
+ /// The schema to transfer from.
+ /// The name of the item to transfer.
+ /// The command builder to use to build the commands.
+ protected virtual void Transfer(
+ string? newSchema,
+ string? schema,
+ string name,
+ MigrationCommandListBuilder builder)
+ {
+ if (newSchema == null)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ var schemaVariable = Uniquify("@defaultSchema");
+ builder
+ .AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME());")
+ .Append("EXEC(")
+ .Append($"N'ALTER SCHEMA ' + {schemaVariable} + ")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ " TRANSFER " + Dependencies.SqlGenerationHelper.DelimitIdentifier(name, schema) + ";"))
+ .AppendLine(");");
+ }
+ else
+ {
+ builder
+ .Append("ALTER SCHEMA ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(newSchema))
+ .Append(" TRANSFER ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name, schema))
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+
+ ///
+ /// Generates a SQL fragment for traits of an index from a ,
+ /// , or .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to add the SQL fragment.
+ protected override void IndexTraits(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ if (operation[SqlServerAnnotationNames.Clustered] is bool clustered)
+ {
+ builder.Append(clustered ? "CLUSTERED " : "NONCLUSTERED ");
+ }
+ }
+
+ ///
+ /// Generates a SQL fragment for extras (filter, included columns, options) of an index from a .
+ ///
+ /// The operation.
+ /// The target model which may be if the operations exist without a model.
+ /// The command builder to use to add the SQL fragment.
+ protected override void IndexOptions(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder)
+ {
+ if (operation[SqlServerAnnotationNames.Include] is IReadOnlyList includeColumns
+ && includeColumns.Count > 0)
+ {
+ builder.Append(" INCLUDE (");
+ for (var i = 0; i < includeColumns.Count; i++)
+ {
+ builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(includeColumns[i]));
+
+ if (i != includeColumns.Count - 1)
+ {
+ builder.Append(", ");
+ }
+ }
+
+ builder.Append(")");
+ }
+
+ if (operation is CreateIndexOperation createIndexOperation)
+ {
+ if (!string.IsNullOrEmpty(createIndexOperation.Filter))
+ {
+ builder
+ .Append(" WHERE ")
+ .Append(createIndexOperation.Filter);
+ }
+ else if (UseLegacyIndexFilters(createIndexOperation, model))
+ {
+ var table = model?.GetRelationalModel().FindTable(createIndexOperation.Table, createIndexOperation.Schema);
+ var nullableColumns = createIndexOperation.Columns
+ .Where(c => table?.FindColumn(c)?.IsNullable != false)
+ .ToList();
+
+ builder.Append(" WHERE ");
+ for (var i = 0; i < nullableColumns.Count; i++)
+ {
+ if (i != 0)
+ {
+ builder.Append(" AND ");
+ }
+
+ builder
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(nullableColumns[i]))
+ .Append(" IS NOT NULL");
+ }
+ }
+ }
+
+ var options = new List();
+
+ if (operation[SqlServerAnnotationNames.FillFactor] is int fillFactor)
+ {
+ options.Add("FILLFACTOR = " + fillFactor);
+ }
+
+ if (operation[SqlServerAnnotationNames.CreatedOnline] is bool isOnline && isOnline)
+ {
+ options.Add("ONLINE = ON");
+ }
+
+ if (operation[SqlServerAnnotationNames.SortInTempDb] is bool sortInTempDb && sortInTempDb)
+ {
+ options.Add("SORT_IN_TEMPDB = ON");
+ }
+
+ if (operation[SqlServerAnnotationNames.DataCompression] is DataCompressionType dataCompressionType)
+ {
+ options.Add("DATA_COMPRESSION = " + dataCompressionType switch
+ {
+ DataCompressionType.None => "NONE",
+ DataCompressionType.Row => "ROW",
+ DataCompressionType.Page => "PAGE",
+
+ _ => throw new UnreachableException(),
+ });
+ }
+
+ // 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)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping("varchar(max)");
+
+ options.Add("METRIC = " + stringTypeMapping.GenerateSqlLiteral(vectorMetric));
+
+ if (operation[SqlServerAnnotationNames.VectorIndexType] is string vectorType)
+ {
+ options.Add("TYPE = " + stringTypeMapping.GenerateSqlLiteral(vectorType));
+ }
+ }
+
+ if (options.Count > 0)
+ {
+ builder
+ .Append(" WITH (")
+ .Append(string.Join(", ", options))
+ .Append(")");
+ }
+ }
+
+ ///
+ /// Generates a SQL fragment for the given referential action.
+ ///
+ /// The referential action.
+ /// The command builder to use to add the SQL fragment.
+ protected override void ForeignKeyAction(ReferentialAction referentialAction, MigrationCommandListBuilder builder)
+ {
+ if (referentialAction == ReferentialAction.Restrict)
+ {
+ builder.Append("NO ACTION");
+ }
+ else
+ {
+ base.ForeignKeyAction(referentialAction, builder);
+ }
+ }
+
+ ///
+ /// Generates a SQL fragment to drop default constraints for a column.
+ ///
+ /// The schema that contains the table.
+ /// The table that contains the column.
+ /// The column.
+ /// The name of the default constraint.
+ /// The command builder to use to add the SQL fragment.
+ protected virtual void DropDefaultConstraint(
+ string? schema,
+ string tableName,
+ string columnName,
+ string? defaultConstraintName,
+ MigrationCommandListBuilder builder)
+ {
+ if (defaultConstraintName != null)
+ {
+ builder
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema))
+ .Append(" DROP CONSTRAINT [")
+ .Append(defaultConstraintName)
+ .Append("]")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ return;
+ }
+
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ var variable = Uniquify("@var");
+
+ builder
+ .Append("DECLARE ")
+ .Append(variable)
+ .AppendLine(" nvarchar(max);")
+ .Append("SELECT ")
+ .Append(variable)
+ .AppendLine(" = QUOTENAME([d].[name])")
+ .AppendLine("FROM [sys].[default_constraints] [d]")
+ .AppendLine(
+ "INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id]")
+ .Append("WHERE ([d].[parent_object_id] = OBJECT_ID(")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)))
+ .Append(") AND [c].[name] = ")
+ .Append(stringTypeMapping.GenerateSqlLiteral(columnName))
+ .AppendLine(");")
+ .Append("IF ")
+ .Append(variable)
+ .Append(" IS NOT NULL EXEC(")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ "ALTER TABLE " + Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema) + " DROP CONSTRAINT "))
+ .Append(" + ")
+ .Append(variable)
+ .Append(" + '")
+ .Append(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .Append("')")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ ///
+ /// Gets the list of indexes that need to be rebuilt when the given column is changing.
+ ///
+ /// The column.
+ /// The operation which may require a rebuild.
+ /// The list of indexes affected.
+ protected virtual IEnumerable GetIndexesToRebuild(
+ IColumn? column,
+ MigrationOperation currentOperation)
+ {
+ if (column == null)
+ {
+ yield break;
+ }
+
+ var table = column.Table;
+ var createIndexOperations = _operations.SkipWhile(o => o != currentOperation).Skip(1)
+ .OfType().Where(o => o.Table == table.Name && o.Schema == table.Schema).ToList();
+ foreach (var index in table.Indexes)
+ {
+ var indexName = index.Name;
+ if (createIndexOperations.Any(o => o.Name == indexName))
+ {
+ continue;
+ }
+
+ if (index.Columns.Any(c => c == column))
+ {
+ yield return index;
+ }
+ else if (index[SqlServerAnnotationNames.Include] is IReadOnlyList includeColumns
+ && includeColumns.Contains(column.Name))
+ {
+ yield return index;
+ }
+ }
+ }
+
+ ///
+ /// Generates SQL to drop the given indexes.
+ ///
+ /// The indexes to drop.
+ /// The command builder to use to build the commands.
+ protected virtual void DropIndexes(
+ IEnumerable indexes,
+ MigrationCommandListBuilder builder)
+ {
+ foreach (var index in indexes)
+ {
+ var table = index.Table;
+ var operation = new DropIndexOperation
+ {
+ Schema = table.Schema,
+ Table = table.Name,
+ Name = index.Name
+ };
+ operation.AddAnnotations(index.GetAnnotations());
+
+ Generate(operation, table.Model.Model, builder, terminate: false);
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+
+ ///
+ /// Generates SQL to create the given indexes.
+ ///
+ /// The indexes to create.
+ /// The command builder to use to build the commands.
+ protected virtual void CreateIndexes(
+ IEnumerable indexes,
+ MigrationCommandListBuilder builder)
+ {
+ foreach (var index in indexes)
+ {
+ Generate(CreateIndexOperation.CreateFrom(index), index.Table.Model.Model, builder, terminate: false);
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+
+ ///
+ /// Generates add commands for descriptions on tables and columns.
+ ///
+ /// The command builder to use to build the commands.
+ /// The new description to be applied.
+ /// The schema of the table.
+ /// The name of the table.
+ /// The name of the column.
+ ///
+ /// Indicates whether the variable declarations should be omitted.
+ ///
+ protected virtual void AddDescription(
+ MigrationCommandListBuilder builder,
+ string description,
+ string? schema,
+ string table,
+ string? column = null,
+ bool omitVariableDeclarations = false)
+ {
+ var schemaLiteral = Uniquify("@defaultSchema", increase: !omitVariableDeclarations);
+ var descriptionVariable = Uniquify("@description", increase: false);
+
+ if (schema == null)
+ {
+ if (!omitVariableDeclarations)
+ {
+ builder.Append($"DECLARE {schemaLiteral} AS sysname")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ builder.Append($"SET {schemaLiteral} = SCHEMA_NAME()")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+ else
+ {
+ schemaLiteral = Literal(schema);
+ }
+
+ if (!omitVariableDeclarations)
+ {
+ builder.Append($"DECLARE {descriptionVariable} AS sql_variant")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ builder.Append($"SET {descriptionVariable} = {Literal(description)}")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ builder
+ .Append("EXEC sp_addextendedproperty 'MS_Description', ")
+ .Append(descriptionVariable)
+ .Append(", 'SCHEMA', ")
+ .Append(schemaLiteral)
+ .Append(", 'TABLE', ")
+ .Append(Literal(table));
+
+ if (column != null)
+ {
+ builder
+ .Append(", 'COLUMN', ")
+ .Append(Literal(column));
+ }
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ string Literal(string s)
+ => SqlLiteral(s);
+
+ static string SqlLiteral(string value)
+ {
+ var builder = new StringBuilder();
+
+ var start = 0;
+ int i;
+ int length;
+ var openApostrophe = false;
+ var lastConcatStartPoint = 0;
+ var concatCount = 1;
+ var concatStartList = new List();
+ for (i = 0; i < value.Length; i++)
+ {
+ var lineFeed = value[i] == '\n';
+ var carriageReturn = value[i] == '\r';
+ var apostrophe = value[i] == '\'';
+ if (lineFeed || carriageReturn || apostrophe)
+ {
+ length = i - start;
+ if (length != 0)
+ {
+ if (!openApostrophe)
+ {
+ AddConcatOperatorIfNeeded();
+ builder.Append("N\'");
+ openApostrophe = true;
+ }
+
+ builder.Append(value.AsSpan().Slice(start, length));
+ }
+
+ if (lineFeed || carriageReturn)
+ {
+ if (openApostrophe)
+ {
+ builder.Append('\'');
+ openApostrophe = false;
+ }
+
+ AddConcatOperatorIfNeeded();
+ builder
+ .Append("NCHAR(")
+ .Append(lineFeed ? "10" : "13")
+ .Append(')');
+ }
+ else if (apostrophe)
+ {
+ if (!openApostrophe)
+ {
+ AddConcatOperatorIfNeeded();
+ builder.Append("N'");
+ openApostrophe = true;
+ }
+
+ builder.Append("''");
+ }
+
+ start = i + 1;
+ }
+ }
+
+ length = i - start;
+ if (length != 0)
+ {
+ if (!openApostrophe)
+ {
+ AddConcatOperatorIfNeeded();
+ builder.Append("N\'");
+ openApostrophe = true;
+ }
+
+ builder.Append(value.AsSpan().Slice(start, length));
+ }
+
+ if (openApostrophe)
+ {
+ builder.Append('\'');
+ }
+
+ for (var j = concatStartList.Count - 1; j >= 0; j--)
+ {
+ builder.Insert(concatStartList[j], "CONCAT(");
+ builder.Append(')');
+ }
+
+ if (builder.Length == 0)
+ {
+ builder.Append("N''");
+ }
+
+ var result = builder.ToString();
+
+ return result;
+
+ void AddConcatOperatorIfNeeded()
+ {
+ if (builder.Length != 0)
+ {
+ builder.Append(", ");
+ concatCount++;
+
+ if (concatCount == 2)
+ {
+ concatStartList.Add(lastConcatStartPoint);
+ }
+
+ if (concatCount == 254)
+ {
+ lastConcatStartPoint = builder.Length;
+ concatCount = 1;
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Generates drop commands for descriptions on tables and columns.
+ ///
+ /// The command builder to use to build the commands.
+ /// The schema of the table.
+ /// The name of the table.
+ /// The name of the column.
+ ///
+ /// Indicates whether the variable declarations should be omitted.
+ ///
+ protected virtual void DropDescription(
+ MigrationCommandListBuilder builder,
+ string? schema,
+ string table,
+ string? column = null,
+ bool omitVariableDeclarations = false)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ var schemaLiteral = Uniquify("@defaultSchema", increase: !omitVariableDeclarations);
+ var descriptionVariable = Uniquify("@description", increase: false);
+ if (schema == null)
+ {
+ if (!omitVariableDeclarations)
+ {
+ builder.Append($"DECLARE {schemaLiteral} AS sysname")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ builder.Append($"SET {schemaLiteral} = SCHEMA_NAME()")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+ }
+ else
+ {
+ schemaLiteral = Literal(schema);
+ }
+
+ if (!omitVariableDeclarations)
+ {
+ builder.Append($"DECLARE {descriptionVariable} AS sql_variant")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+ }
+
+ builder
+ .Append("EXEC sp_dropextendedproperty 'MS_Description', 'SCHEMA', ")
+ .Append(schemaLiteral)
+ .Append(", 'TABLE', ")
+ .Append(Literal(table));
+
+ if (column != null)
+ {
+ builder
+ .Append(", 'COLUMN', ")
+ .Append(Literal(column));
+ }
+
+ builder.AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ string Literal(string s)
+ => stringTypeMapping.GenerateSqlLiteral(s);
+ }
+
+ ///
+ /// Checks whether or not should have a filter generated for it by
+ /// Migrations.
+ ///
+ /// The index creation operation.
+ /// The target model.
+ /// if a filter should be generated.
+ protected virtual bool UseLegacyIndexFilters(CreateIndexOperation operation, IModel? model)
+ => (!TryGetVersion(model, out var version) || VersionComparer.Compare(version, "2.0.0") < 0)
+ && operation.Filter is null
+ && operation.IsUnique
+ && operation[SqlServerAnnotationNames.Clustered] is null or false
+ && model?.GetRelationalModel().FindTable(operation.Table, operation.Schema) is var table
+ && operation.Columns.Any(c => table?.FindColumn(c)?.IsNullable != false);
+
+ private static string IntegerConstant(long value)
+ => string.Format(CultureInfo.InvariantCulture, "{0}", value);
+
+ private static bool IsMemoryOptimized(Annotatable annotatable, IModel? model, string? schema, string tableName)
+ => annotatable[SqlServerAnnotationNames.MemoryOptimized] as bool?
+ ?? model?.GetRelationalModel().FindTable(tableName, schema)?[SqlServerAnnotationNames.MemoryOptimized] as bool? == true;
+
+ private static bool IsMemoryOptimized(Annotatable annotatable)
+ => annotatable[SqlServerAnnotationNames.MemoryOptimized] as bool? == true;
+
+ private static bool IsIdentity(ColumnOperation operation)
+ => operation[SqlServerAnnotationNames.Identity] != null
+ || operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
+ == SqlServerValueGenerationStrategy.IdentityColumn;
+
+ private static void RemoveIdentityAnnotations(ColumnOperation operation)
+ {
+ operation.RemoveAnnotation(SqlServerAnnotationNames.Identity);
+
+ if (operation[SqlServerAnnotationNames.ValueGenerationStrategy] as SqlServerValueGenerationStrategy?
+ == SqlServerValueGenerationStrategy.IdentityColumn)
+ {
+ operation.RemoveAnnotation(SqlServerAnnotationNames.ValueGenerationStrategy);
+ }
+ }
+
+ private static bool TryParseIdentitySeedIncrement(ColumnOperation operation, out int seed, out int increment)
+ {
+ if (operation[SqlServerAnnotationNames.Identity] is string seedIncrement
+ && seedIncrement.Split(",") is [var seedString, var incrementString]
+ && int.TryParse(seedString, out var seedParsed)
+ && int.TryParse(incrementString, out var incrementParsed))
+ {
+ (seed, increment) = (seedParsed, incrementParsed);
+ return true;
+ }
+
+ (seed, increment) = (0, 0);
+ return false;
+ }
+
+ private void GenerateExecWhenIdempotent(
+ MigrationCommandListBuilder builder,
+ Action generate)
+ {
+ if (Options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
+ {
+ var subBuilder = new MigrationCommandListBuilder(Dependencies);
+ generate(subBuilder);
+
+ var command = subBuilder.GetCommandList().Single();
+ builder
+ .Append("EXEC(N'")
+ .Append(command.CommandText.TrimEnd('\n', '\r', ';').Replace("'", "''"))
+ .Append("')")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator)
+ .EndCommand(command.TransactionSuppressed);
+
+ return;
+ }
+
+ generate(builder);
+ }
+
+ private static bool HasDifferences(IEnumerable source, IEnumerable target)
+ {
+ var targetAnnotations = target.ToDictionary(a => a.Name);
+
+ var count = 0;
+ foreach (var sourceAnnotation in source)
+ {
+ if (!targetAnnotations.TryGetValue(sourceAnnotation.Name, out var targetAnnotation)
+ || !Equals(sourceAnnotation.Value, targetAnnotation.Value))
+ {
+ return true;
+ }
+
+ count++;
+ }
+
+ return count != targetAnnotations.Count;
+ }
+
+ private string Uniquify(string variableName, bool increase = true)
+ {
+ if (increase)
+ {
+ _variableCounter++;
+ }
+
+ return _variableCounter == 0 ? variableName : variableName + _variableCounter;
+ }
+
+ private IReadOnlyList FixLegacyTemporalAnnotations(IReadOnlyList migrationOperations)
+ {
+ // short-circuit for non-temporal migrations (which is the majority)
+ if (migrationOperations.All(o => o[SqlServerAnnotationNames.IsTemporal] as bool? != true))
+ {
+ return migrationOperations;
+ }
+
+ var resultOperations = new List(migrationOperations.Count);
+ foreach (var migrationOperation in migrationOperations)
+ {
+ var isTemporal = migrationOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ if (!isTemporal)
+ {
+ resultOperations.Add(migrationOperation);
+ continue;
+ }
+
+ switch (migrationOperation)
+ {
+ case CreateTableOperation createTableOperation:
+
+ foreach (var column in createTableOperation.Columns)
+ {
+ NormalizeTemporalAnnotationsForAddColumnOperation(column);
+ }
+
+ resultOperations.Add(migrationOperation);
+ break;
+
+ case AddColumnOperation addColumnOperation:
+ NormalizeTemporalAnnotationsForAddColumnOperation(addColumnOperation);
+ resultOperations.Add(addColumnOperation);
+ break;
+
+ case AlterColumnOperation alterColumnOperation:
+ RemoveLegacyTemporalColumnAnnotations(alterColumnOperation);
+ RemoveLegacyTemporalColumnAnnotations(alterColumnOperation.OldColumn);
+ if (!CanSkipAlterColumnOperation(alterColumnOperation, alterColumnOperation.OldColumn))
+ {
+ resultOperations.Add(alterColumnOperation);
+ }
+
+ break;
+
+ case DropColumnOperation dropColumnOperation:
+ RemoveLegacyTemporalColumnAnnotations(dropColumnOperation);
+ resultOperations.Add(dropColumnOperation);
+ break;
+
+ case RenameColumnOperation renameColumnOperation:
+ RemoveLegacyTemporalColumnAnnotations(renameColumnOperation);
+ resultOperations.Add(renameColumnOperation);
+ break;
+
+ default:
+ resultOperations.Add(migrationOperation);
+ break;
+ }
+ }
+
+ return resultOperations;
+
+ static void NormalizeTemporalAnnotationsForAddColumnOperation(AddColumnOperation addColumnOperation)
+ {
+ var periodStartColumnName = addColumnOperation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
+ var periodEndColumnName = addColumnOperation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
+ if (periodStartColumnName == addColumnOperation.Name)
+ {
+ addColumnOperation.AddAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn, true);
+ }
+ else if (periodEndColumnName == addColumnOperation.Name)
+ {
+ addColumnOperation.AddAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn, true);
+ }
+
+ RemoveLegacyTemporalColumnAnnotations(addColumnOperation);
+ }
+
+ static void RemoveLegacyTemporalColumnAnnotations(MigrationOperation operation)
+ {
+ operation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal);
+ operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName);
+ operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema);
+ operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName);
+ operation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName);
+ }
+
+ static bool CanSkipAlterColumnOperation(ColumnOperation column, ColumnOperation oldColumn)
+ => ColumnPropertiesAreTheSame(column, oldColumn) && AnnotationsAreTheSame(column, oldColumn);
+
+ // don't compare name, table or schema - they are not being set in the model differ (since they should always be the same)
+ static bool ColumnPropertiesAreTheSame(ColumnOperation column, ColumnOperation oldColumn)
+ => column.ClrType == oldColumn.ClrType
+ && column.Collation == oldColumn.Collation
+ && column.ColumnType == oldColumn.ColumnType
+ && column.Comment == oldColumn.Comment
+ && column.ComputedColumnSql == oldColumn.ComputedColumnSql
+ && Equals(column.DefaultValue, oldColumn.DefaultValue)
+ && column.DefaultValueSql == oldColumn.DefaultValueSql
+ && column.IsDestructiveChange == oldColumn.IsDestructiveChange
+ && column.IsFixedLength == oldColumn.IsFixedLength
+ && column.IsNullable == oldColumn.IsNullable
+ && column.IsReadOnly == oldColumn.IsReadOnly
+ && column.IsRowVersion == oldColumn.IsRowVersion
+ && column.IsStored == oldColumn.IsStored
+ && column.IsUnicode == oldColumn.IsUnicode
+ && column.MaxLength == oldColumn.MaxLength
+ && column.Precision == oldColumn.Precision
+ && column.Scale == oldColumn.Scale;
+
+ static bool AnnotationsAreTheSame(ColumnOperation column, ColumnOperation oldColumn)
+ {
+ var columnAnnotations = column.GetAnnotations().ToList();
+ var oldColumnAnnotations = oldColumn.GetAnnotations().ToList();
+
+ if (columnAnnotations.Count != oldColumnAnnotations.Count)
+ {
+ return false;
+ }
+
+ return columnAnnotations.Zip(oldColumnAnnotations)
+ .All(x => x.First.Name == x.Second.Name
+ && StructuralComparisons.StructuralEqualityComparer.Equals(x.First.Value, x.Second.Value));
+ }
+ }
+
+ private IReadOnlyList RewriteOperations(
+ IReadOnlyList migrationOperations,
+ IModel? model,
+ MigrationsSqlGenerationOptions options)
+ {
+ migrationOperations = FixLegacyTemporalAnnotations(migrationOperations);
+
+ var operations = new List();
+ var availableSchemas = new List();
+
+ // we need to know temporal information for all the tables involved in the migration
+ // problem is, the temporal information is stored only on table operations and not column operations
+ // if migration operation doesn't contain the table operation, or the table operation comes later
+ // we don't know what we should do
+ // to fix that, we loop through all the operations and extract initial temporal state for relevant tables
+ // if we don't encounter any table operations, then we can take information from the model
+ // since migration hasn't changed it at all - be we can only know that after looping though all ops
+ // once we have the initial state of the table, we can update it each time we encounter a table operation
+ // and we can use what we stored when dealing with all other operations (that don't contain temporal annotations themselves)
+ var temporalTableInformationMap = new Dictionary<(string TableName, string? Schema), TemporalOperationInformation>();
+ var missingTemporalTableInformation = new List<(string TableName, string? Schema)>();
+
+ foreach (var operation in migrationOperations)
+ {
+ switch (operation)
+ {
+ case CreateTableOperation createTableOperation:
+ {
+ var tableName = createTableOperation.Name;
+ var rawSchema = createTableOperation.Schema;
+ var schema = rawSchema ?? model?.GetDefaultSchema();
+ if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
+ {
+ var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, createTableOperation);
+ temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
+ }
+
+ // no need to remove from missingTemporalTableInformation - CreateTable should be first operation for this table
+ // so there can't be entry for it in missingTemporalTableInformation (they are added by other/earlier operations on that table)
+ // the only possibility is that we had a table before, dropped it and now creating a new table with the same name
+ // but in this case we would have generated the necessary information from the DropTableOperation
+ // and also removed the missingTemporalTableInformation entry if there was one before
+ break;
+ }
+
+ case DropTableOperation dropTableOperation:
+ {
+ var tableName = dropTableOperation.Name;
+ var rawSchema = dropTableOperation.Schema;
+ var schema = rawSchema ?? model?.GetDefaultSchema();
+ if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
+ {
+ var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, dropTableOperation);
+ temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
+ }
+
+ missingTemporalTableInformation.Remove((tableName, rawSchema));
+ break;
+ }
+
+ case RenameTableOperation renameTableOperation:
+ {
+ var tableName = renameTableOperation.Name;
+ var rawSchema = renameTableOperation.Schema;
+ var schema = rawSchema ?? model?.GetDefaultSchema();
+ var newTableName = renameTableOperation.NewName!;
+ var newRawSchema = renameTableOperation.NewSchema;
+ var newSchema = newRawSchema ?? model?.GetDefaultSchema();
+
+ var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, renameTableOperation);
+ if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
+ {
+ temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
+ }
+
+ // we still need to check here - table with the new name could have existed before and have been deleted
+ // we want to preserve the original temporal info of that deleted table
+ if (!temporalTableInformationMap.ContainsKey((newTableName, newRawSchema)))
+ {
+ temporalTableInformationMap[(newTableName, newRawSchema)] = temporalTableInformation;
+ }
+
+ missingTemporalTableInformation.Remove((tableName, rawSchema));
+ missingTemporalTableInformation.Remove((newTableName, newRawSchema));
+
+ break;
+ }
+
+ case AlterTableOperation alterTableOperation:
+ {
+ var tableName = alterTableOperation.Name;
+ var rawSchema = alterTableOperation.Schema;
+ var schema = rawSchema ?? model?.GetDefaultSchema();
+ if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema)))
+ {
+ // we create the temporal info based on the OLD table here - we want the initial state
+ var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, alterTableOperation.OldTable);
+ temporalTableInformationMap[(tableName, rawSchema)] = temporalTableInformation;
+ }
+
+ missingTemporalTableInformation.Remove((tableName, schema));
+ break;
+ }
+
+ default:
+ {
+ if (operation is ITableMigrationOperation tableMigrationOperation)
+ {
+ var tableName = tableMigrationOperation.Table;
+ var rawSchema = tableMigrationOperation.Schema;
+ if (!temporalTableInformationMap.ContainsKey((tableName, rawSchema))
+ && !missingTemporalTableInformation.Contains((tableName, rawSchema)))
+ {
+ missingTemporalTableInformation.Add((tableName, rawSchema));
+ }
+ }
+
+ break;
+ }
+ }
+ }
+
+ // fill the missing temporal information from Relational Model - it's the second best source we have
+ // if we can't figure out proper temporal info from table annotations,
+ // and we don't have it in relational model (for whatever reason) we assume table is not temporal
+ // this last step is purely defensive and shouldn't happen in real situations
+ foreach (var missingInfo in missingTemporalTableInformation)
+ {
+ var table = model?.GetRelationalModel().FindTable(missingInfo.TableName, missingInfo.Schema)!;
+ if (table != null)
+ {
+ var schema = missingInfo.Schema ?? model?.GetDefaultSchema();
+
+ var temporalTableInformation = BuildTemporalInformationFromMigrationOperation(schema, table);
+ temporalTableInformationMap[(missingInfo.TableName, missingInfo.Schema)] = temporalTableInformation;
+ }
+ else
+ {
+ temporalTableInformationMap[(missingInfo.TableName, missingInfo.Schema)] = new TemporalOperationInformation
+ {
+ IsTemporalTable = false,
+ HistoryTableName = null,
+ HistoryTableSchema = null,
+ PeriodStartColumnName = null,
+ PeriodEndColumnName = null
+ };
+ }
+ }
+
+ var historyTables = new HashSet<(string Name, string? Schema)>(
+ temporalTableInformationMap.Values
+ .Where(t => t.IsTemporalTable && t.HistoryTableName != null)
+ .Select(t => (t.HistoryTableName!, t.HistoryTableSchema)));
+
+ if (model != null)
+ {
+ foreach (var table in model.GetRelationalModel().Tables)
+ {
+ if (table[SqlServerAnnotationNames.IsTemporal] as bool? == true
+ && table[SqlServerAnnotationNames.TemporalHistoryTableName] is string modelHistoryTableName)
+ {
+ var modelHistoryTableSchema =
+ table[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string;
+ historyTables.Add((modelHistoryTableName, modelHistoryTableSchema));
+ }
+ }
+ }
+
+ // now we do proper processing - for table operations we look at the annotations on them
+ // and continuously update the stored temporal info as the table is being modified
+ // for column (and other) operations we don't have annotations on them, so we look into the
+ // information we stored in the initial pass and updated in when processing table ops that happened earlier
+ foreach (var operation in migrationOperations)
+ {
+ if (operation is EnsureSchemaOperation ensureSchemaOperation)
+ {
+ availableSchemas.Add(ensureSchemaOperation.Name);
+ }
+
+ if (operation is not ITableMigrationOperation tableMigrationOperation)
+ {
+ operations.Add(operation);
+ continue;
+ }
+
+ var tableName = tableMigrationOperation.Table;
+ var rawSchema = tableMigrationOperation.Schema;
+
+ var suppressTransaction = IsMemoryOptimized(operation, model, rawSchema, tableName);
+
+ var schema = rawSchema ?? model?.GetDefaultSchema();
+
+ TemporalOperationInformation temporalInformation;
+ if (operation is CreateTableOperation)
+ {
+ // for create table we always generate new temporal information from the operation itself
+ // just in case there was a table with that name before that got deleted/renamed
+ // also, temporal state (disabled versioning etc.) should always reset when creating a table
+ temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, operation);
+ temporalTableInformationMap[(tableName, rawSchema)] = temporalInformation;
+ }
+ else
+ {
+ temporalInformation = temporalTableInformationMap[(tableName, rawSchema)];
+ }
+
+ switch (operation)
+ {
+ case CreateTableOperation createTableOperation:
+ {
+ // for create table we always generate new temporal information from the operation itself
+ // just in case there was a table with that name before that got deleted/renamed
+ // this shouldn't happen as we re-use existing tables rather than drop/recreate
+ // but we are being extra defensive here
+ // and also, temporal state (disabled versioning etc.) should always reset when creating a table
+ temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, createTableOperation);
+
+ if (temporalInformation.IsTemporalTable
+ && temporalInformation.HistoryTableSchema != schema
+ && temporalInformation.HistoryTableSchema != null
+ && !availableSchemas.Contains(temporalInformation.HistoryTableSchema))
+ {
+ operations.Add(new EnsureSchemaOperation { Name = temporalInformation.HistoryTableSchema });
+ availableSchemas.Add(temporalInformation.HistoryTableSchema);
+ }
+
+ operations.Add(operation);
+
+ break;
+ }
+
+ case DropTableOperation dropTableOperation:
+ {
+ var isTemporalTable = dropTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ if (isTemporalTable)
+ {
+ // if we don't have temporal information, but we know table is temporal
+ // (based on the annotation found on the operation itself)
+ // we assume that versioning must be disabled, if we have temporal info we can check properly
+ if (temporalInformation is null || !temporalInformation.DisabledVersioning)
+ {
+ AddDisableVersioningOperation(tableName, schema, suppressTransaction);
+ }
+
+ if (temporalInformation is not null)
+ {
+ temporalInformation.ShouldEnableVersioning = false;
+ temporalInformation.ShouldEnablePeriod = false;
+ }
+
+ operations.Add(operation);
+
+ var historyTableName = dropTableOperation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
+ var historyTableSchema =
+ dropTableOperation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
+ var dropHistoryTableOperation = new DropTableOperation { Name = historyTableName!, Schema = historyTableSchema };
+ operations.Add(dropHistoryTableOperation);
+ }
+ else
+ {
+ operations.Add(operation);
+ }
+
+ // we removed the table, so we no longer need it's temporal information
+ // there will be no more operations involving this table
+ temporalTableInformationMap.Remove((tableName, schema));
+
+ break;
+ }
+
+ case RenameTableOperation renameTableOperation:
+ {
+ if (temporalInformation is null)
+ {
+ temporalInformation = BuildTemporalInformationFromMigrationOperation(schema, renameTableOperation);
+ }
+
+ var isTemporalTable = renameTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ if (isTemporalTable)
+ {
+ DisableVersioning(
+ tableName,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: true);
+ }
+
+ operations.Add(operation);
+
+ // since table was renamed, update entry in the temporal info map
+ temporalTableInformationMap[(renameTableOperation.NewName!, renameTableOperation.NewSchema)] = temporalInformation;
+ temporalTableInformationMap.Remove((tableName, schema));
+
+ break;
+ }
+
+ case AlterTableOperation alterTableOperation:
+ {
+ var isTemporalTable = alterTableOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ var historyTableName = alterTableOperation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
+ var historyTableSchema = alterTableOperation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
+ var periodStartColumnName = alterTableOperation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
+ var periodEndColumnName = alterTableOperation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
+
+ var oldIsTemporalTable = alterTableOperation.OldTable[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ var oldHistoryTableName =
+ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
+ var oldHistoryTableSchema =
+ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string
+ ?? alterTableOperation.OldTable.Schema
+ ?? model?[RelationalAnnotationNames.DefaultSchema] as string;
+
+ if (isTemporalTable)
+ {
+ if (!oldIsTemporalTable)
+ {
+ // converting from regular table to temporal table - enable period and versioning at the end
+ // other temporal information (history table, period columns etc) is added below
+ temporalInformation.ShouldEnablePeriod = true;
+ temporalInformation.ShouldEnableVersioning = true;
+ }
+ else
+ {
+ // changing something within temporal table
+ if (oldHistoryTableName != historyTableName
+ || oldHistoryTableSchema != historyTableSchema)
+ {
+ if (historyTableSchema != null
+ && !availableSchemas.Contains(historyTableSchema))
+ {
+ operations.Add(new EnsureSchemaOperation { Name = historyTableSchema });
+ availableSchemas.Add(historyTableSchema);
+ }
+
+ operations.Add(
+ new RenameTableOperation
+ {
+ Name = oldHistoryTableName!,
+ Schema = oldHistoryTableSchema,
+ NewName = historyTableName,
+ NewSchema = historyTableSchema
+ });
+
+ temporalInformation.HistoryTableName = historyTableName;
+ temporalInformation.HistoryTableSchema = historyTableSchema;
+ }
+ }
+ }
+ else
+ {
+ if (oldIsTemporalTable)
+ {
+ // converting from temporal table to regular table
+ var oldPeriodStartColumnName =
+ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
+ var oldPeriodEndColumnName =
+ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
+
+ DisableVersioning(
+ tableName,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: null);
+
+ if (!temporalInformation.DisabledPeriod)
+ {
+ DisablePeriod(tableName, schema, temporalInformation, suppressTransaction);
+ }
+
+ if (oldHistoryTableName != null)
+ {
+ operations.Add(new DropTableOperation { Name = oldHistoryTableName, Schema = oldHistoryTableSchema });
+ }
+
+ // also clear any pending versioning/period, that would be switched on at the end
+ // we don't need it now that the table is no longer temporal
+ temporalInformation.ShouldEnableVersioning = false;
+ temporalInformation.ShouldEnablePeriod = false;
+ }
+ }
+
+ temporalInformation.IsTemporalTable = isTemporalTable;
+ temporalInformation.HistoryTableName = historyTableName;
+ temporalInformation.HistoryTableSchema = historyTableSchema;
+ temporalInformation.PeriodStartColumnName = periodStartColumnName;
+ temporalInformation.PeriodEndColumnName = periodEndColumnName;
+
+ if (isTemporalTable && historyTableName != null)
+ {
+ historyTables.Add((historyTableName, historyTableSchema));
+ }
+
+ operations.Add(operation);
+ break;
+ }
+
+ case AddColumnOperation addColumnOperation:
+ {
+ // when adding a period column, we need to add it as a normal column first, and only later enable period
+ // removing the period information now, so that when we generate SQL that adds the column we won't be making them
+ // auto generated as period it won't work, unless period is enabled but we can't enable period without adding the
+ // columns first - chicken and egg
+ if (temporalInformation.IsTemporalTable)
+ {
+ addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
+ addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
+
+ // model differ adds default value, but for period end we need to replace it with the correct one -
+ // DateTime.MaxValue
+ if (addColumnOperation.Name == temporalInformation.PeriodEndColumnName)
+ {
+ addColumnOperation.DefaultValue = DateTime.MaxValue;
+ }
+
+ var isSparse = addColumnOperation[SqlServerAnnotationNames.Sparse] as bool? == true;
+ var isComputed = addColumnOperation.ComputedColumnSql != null;
+
+ if (isSparse || isComputed)
+ {
+ DisableVersioning(
+ tableName,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: true);
+ }
+
+ // when adding sparse column to temporal table, we need to disable versioning.
+ // This is because it may be the case that HistoryTable is using compression (by default)
+ // and the add column operation fails in that situation
+ // in order to make it work we need to disable versioning (if we haven't done it already)
+ // and de-compress the HistoryTable
+ if (isSparse)
+ {
+ DecompressTable(
+ temporalInformation.HistoryTableName!, temporalInformation.HistoryTableSchema, suppressTransaction);
+ }
+
+ if (addColumnOperation.ComputedColumnSql != null)
+ {
+ DisableVersioning(
+ tableName,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: true);
+ }
+
+ operations.Add(addColumnOperation);
+
+ // when adding (non-period) column to an existing temporal table we need to check if we have disabled versioning
+ // due to some other operations in the same migration (e.g. delete column)
+ // if so, we need to also add the same column to history table
+ if (addColumnOperation.Name != temporalInformation.PeriodStartColumnName
+ && addColumnOperation.Name != temporalInformation.PeriodEndColumnName
+ && temporalInformation.DisabledVersioning)
+ {
+ var addHistoryTableColumnOperation = CopyColumnOperation(addColumnOperation);
+ addHistoryTableColumnOperation.Table = temporalInformation.HistoryTableName!;
+ addHistoryTableColumnOperation.Schema = temporalInformation.HistoryTableSchema;
+
+ if (addHistoryTableColumnOperation.ComputedColumnSql != null)
+ {
+ // computed columns are not allowed inside HistoryTables
+ // but the historical computed value will be copied over to the non-computed counterpart,
+ // as long as their names and types (including nullability) match
+ // so we remove ComputedColumnSql info, so that the column in history table "appears normal"
+ addHistoryTableColumnOperation.ComputedColumnSql = null;
+ }
+
+ // identity columns are not allowed inside HistoryTables
+ RemoveIdentityAnnotations(addHistoryTableColumnOperation);
+
+ operations.Add(addHistoryTableColumnOperation);
+ }
+ }
+ else
+ {
+ // identity columns are not allowed inside HistoryTables
+ if (historyTables.Contains((tableName, schema)))
+ {
+ RemoveIdentityAnnotations(addColumnOperation);
+ }
+
+ operations.Add(addColumnOperation);
+ }
+
+ break;
+ }
+
+ case DropColumnOperation dropColumnOperation:
+ {
+ if (temporalInformation.IsTemporalTable)
+ {
+ var droppingPeriodColumn = dropColumnOperation.Name == temporalInformation.PeriodStartColumnName
+ || dropColumnOperation.Name == temporalInformation.PeriodEndColumnName;
+
+ // if we are dropping non-period column, we should enable versioning at the end.
+ // When dropping period column there is no need - we are removing the versioning for this table altogether
+ DisableVersioning(
+ tableName,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: droppingPeriodColumn ? null : true);
+
+ if (droppingPeriodColumn && !temporalInformation.DisabledPeriod)
+ {
+ DisablePeriod(tableName, schema, temporalInformation, suppressTransaction);
+
+ // if we remove the period columns, it means we will be dropping the table
+ // also or at least convert it back to regular - no need to enable period later
+ temporalInformation.ShouldEnablePeriod = false;
+ }
+
+ operations.Add(operation);
+
+ if (!droppingPeriodColumn)
+ {
+ operations.Add(
+ new DropColumnOperation
+ {
+ Name = dropColumnOperation.Name,
+ Table = temporalInformation.HistoryTableName!,
+ Schema = temporalInformation.HistoryTableSchema
+ });
+ }
+ }
+ else
+ {
+ operations.Add(operation);
+ }
+
+ break;
+ }
+
+ case RenameColumnOperation renameColumnOperation:
+ {
+ operations.Add(renameColumnOperation);
+
+ // if we disabled period for the temporal table and now we are renaming the column,
+ // we need to also rename this same column in history table
+ if (temporalInformation.IsTemporalTable
+ && temporalInformation.DisabledVersioning
+ && temporalInformation.ShouldEnableVersioning)
+ {
+ var renameHistoryTableColumnOperation = new RenameColumnOperation
+ {
+ IsDestructiveChange = renameColumnOperation.IsDestructiveChange,
+ Name = renameColumnOperation.Name,
+ NewName = renameColumnOperation.NewName,
+ Table = temporalInformation.HistoryTableName!,
+ Schema = temporalInformation.HistoryTableSchema
+ };
+
+ operations.Add(renameHistoryTableColumnOperation);
+ }
+
+ break;
+ }
+
+ case AlterColumnOperation alterColumnOperation:
+ {
+ // we can remove temporal annotations, they don't make a difference when it comes to
+ // generating ALTER COLUMN operations and could just muddy the waters
+ alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
+ alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
+ alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodStartColumn);
+ alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalIsPeriodEndColumn);
+
+ if (temporalInformation.IsTemporalTable)
+ {
+ if (alterColumnOperation.OldColumn.ComputedColumnSql != alterColumnOperation.ComputedColumnSql)
+ {
+ throw new NotSupportedException(
+ SqlServerStrings.TemporalMigrationModifyingComputedColumnNotSupported(
+ alterColumnOperation.Name,
+ alterColumnOperation.Table));
+ }
+
+ // for alter column operation converting column from nullable to non-nullable in the temporal table
+ // we must disable versioning in order to properly handle it
+ // specifically, switching values in history table from null to the default value
+ var changeToNonNullable = alterColumnOperation.OldColumn.IsNullable
+ && !alterColumnOperation.IsNullable;
+
+ // for alter column converting to sparse we also need to disable versioning
+ // in case HistoryTable is compressed (so that we can de-compress it)
+ var changeToSparse = alterColumnOperation.OldColumn[SqlServerAnnotationNames.Sparse] as bool? != true
+ && alterColumnOperation[SqlServerAnnotationNames.Sparse] as bool? == true;
+
+ // for alter column removing default value we also need to disable versioning
+ // because the default constraint needs to be removed from both main and history tables
+ var removingDefaultValue = (alterColumnOperation.OldColumn.DefaultValue is not null || alterColumnOperation.OldColumn.DefaultValueSql is not null)
+ && alterColumnOperation.DefaultValue is null && alterColumnOperation.DefaultValueSql is null;
+
+ if (changeToNonNullable || changeToSparse || removingDefaultValue)
+ {
+ DisableVersioning(
+ tableName!,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: true);
+ }
+
+ if (changeToSparse)
+ {
+ DecompressTable(
+ temporalInformation.HistoryTableName!, temporalInformation.HistoryTableSchema, suppressTransaction);
+ }
+
+ operations.Add(alterColumnOperation);
+
+ // when modifying a period column, we need to perform the operations as a normal column first, and only later enable period
+ // removing the period information now, so that when we generate SQL that modifies the column we won't be making them auto generated as period
+ // (making column auto generated is not allowed in ALTER COLUMN statement)
+ // in later operation we enable the period and the period columns get set to auto generated automatically
+ //
+ // if the column is not period we just remove temporal information - it's no longer needed and could affect the generated sql
+ // we will generate all the necessary operations involved with temporal tables here
+ if (temporalInformation.DisabledVersioning && temporalInformation.ShouldEnableVersioning)
+ {
+ var alterHistoryTableColumn = CopyColumnOperation(alterColumnOperation);
+ alterHistoryTableColumn.Table = temporalInformation.HistoryTableName!;
+ alterHistoryTableColumn.Schema = temporalInformation.HistoryTableSchema;
+ alterHistoryTableColumn.OldColumn = CopyColumnOperation(alterColumnOperation.OldColumn);
+ alterHistoryTableColumn.OldColumn.Table = temporalInformation.HistoryTableName!;
+ alterHistoryTableColumn.OldColumn.Schema = temporalInformation.HistoryTableSchema;
+
+ // identity columns are not allowed inside HistoryTables
+ RemoveIdentityAnnotations(alterHistoryTableColumn);
+ RemoveIdentityAnnotations(alterHistoryTableColumn.OldColumn);
+
+ operations.Add(alterHistoryTableColumn);
+ }
+ }
+ else
+ {
// identity columns are not allowed inside HistoryTables
if (historyTables.Contains((tableName, schema)))
{
RemoveIdentityAnnotations(alterColumnOperation);
RemoveIdentityAnnotations(alterColumnOperation.OldColumn);
}
-
- operations.Add(alterColumnOperation);
- }
-
- break;
- }
-
- case DropPrimaryKeyOperation:
- case AddPrimaryKeyOperation:
- if (temporalInformation.IsTemporalTable)
- {
- DisableVersioning(
- tableName!,
- schema,
- temporalInformation,
- suppressTransaction,
- shouldEnableVersioning: true);
- }
-
- operations.Add(operation);
- break;
-
- default:
- operations.Add(operation);
- break;
- }
- }
-
- foreach (var temporalInformation in temporalTableInformationMap.Where(x => x.Value.ShouldEnablePeriod))
- {
- EnablePeriod(
- temporalInformation.Key.TableName,
- temporalInformation.Key.Schema,
- temporalInformation.Value.PeriodStartColumnName!,
- temporalInformation.Value.PeriodEndColumnName!,
- temporalInformation.Value.SuppressTransaction);
- }
-
- foreach (var temporalInformation in temporalTableInformationMap.Where(x => x.Value.ShouldEnableVersioning))
- {
- EnableVersioning(
- temporalInformation.Key.TableName,
- temporalInformation.Key.Schema,
- temporalInformation.Value.HistoryTableName!,
- temporalInformation.Value.HistoryTableSchema,
- temporalInformation.Value.SuppressTransaction);
- }
-
- return operations;
-
- static TemporalOperationInformation BuildTemporalInformationFromMigrationOperation(
- string? schema,
- IAnnotatable operation)
- {
- var isTemporalTable = operation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
- var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
- var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
- var periodStartColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
- var periodEndColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
-
- return new TemporalOperationInformation
- {
- IsTemporalTable = isTemporalTable,
- HistoryTableName = historyTableName,
- HistoryTableSchema = historyTableSchema,
- PeriodStartColumnName = periodStartColumnName,
- PeriodEndColumnName = periodEndColumnName
- };
- }
-
- void DisableVersioning(
- string tableName,
- string? schema,
- TemporalOperationInformation temporalInformation,
- bool suppressTransaction,
- bool? shouldEnableVersioning)
- {
- if (!temporalInformation.DisabledVersioning
- && !temporalInformation.ShouldEnableVersioning)
- {
- temporalInformation.DisabledVersioning = true;
-
- AddDisableVersioningOperation(tableName, schema, suppressTransaction);
-
- if (shouldEnableVersioning != null)
- {
- temporalInformation.ShouldEnableVersioning = shouldEnableVersioning.Value;
- if (shouldEnableVersioning.Value)
- {
- temporalInformation.SuppressTransaction = suppressTransaction;
- }
- }
- }
- }
-
- void AddDisableVersioningOperation(string tableName, string? schema, bool suppressTransaction)
- => operations.Add(
- new SqlOperation
- {
- Sql = new StringBuilder()
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema))
- .AppendLine(" SET (SYSTEM_VERSIONING = OFF)")
- .ToString(),
- SuppressTransaction = suppressTransaction
- });
-
- void EnableVersioning(string table, string? schema, string historyTableName, string? historyTableSchema, bool suppressTransaction)
- {
- var stringBuilder = new StringBuilder();
-
- string? schemaVariable = null;
- if (historyTableSchema == null)
- {
- schemaVariable = Uniquify("@historyTableSchema");
- // need to run command using EXEC to inject default schema
- stringBuilder.AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME())");
- stringBuilder.Append("EXEC(N'");
- }
-
- var historyTable = historyTableSchema != null
- ? Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName, historyTableSchema)
- : Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName);
-
- stringBuilder
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema));
-
- if (historyTableSchema != null)
- {
- stringBuilder.AppendLine($" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable}))");
- }
- else
- {
- stringBuilder.AppendLine(
- $" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = ' + {schemaVariable} + '.{historyTable}))')");
- }
-
- operations.Add(
- new SqlOperation { Sql = stringBuilder.ToString(), SuppressTransaction = suppressTransaction });
- }
-
- void DisablePeriod(
- string table,
- string? schema,
- TemporalOperationInformation temporalInformation,
- bool suppressTransaction)
- {
- temporalInformation.DisabledPeriod = true;
-
- operations.Add(
- new SqlOperation
- {
- Sql = new StringBuilder()
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
- .AppendLine(" DROP PERIOD FOR SYSTEM_TIME")
- .ToString(),
- SuppressTransaction = suppressTransaction
- });
- }
-
- void EnablePeriod(string table, string? schema, string periodStartColumnName, string periodEndColumnName, bool suppressTransaction)
- {
- var addPeriodSql = new StringBuilder()
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
- .Append(" ADD PERIOD FOR SYSTEM_TIME (")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodStartColumnName))
- .Append(", ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodEndColumnName))
- .Append(')')
- .ToString();
-
- if (options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
- {
- addPeriodSql = new StringBuilder()
- .Append("EXEC(N'")
- .Append(addPeriodSql.Replace("'", "''"))
- .Append("')")
- .ToString();
- }
-
- operations.Add(
- new SqlOperation { Sql = addPeriodSql, SuppressTransaction = suppressTransaction });
-
- operations.Add(
- new SqlOperation
- {
- Sql = new StringBuilder()
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
- .Append(" ALTER COLUMN ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodStartColumnName))
- .Append(" ADD HIDDEN")
- .ToString(),
- SuppressTransaction = suppressTransaction
- });
-
- operations.Add(
- new SqlOperation
- {
- Sql = new StringBuilder()
- .Append("ALTER TABLE ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
- .Append(" ALTER COLUMN ")
- .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodEndColumnName))
- .Append(" ADD HIDDEN")
- .ToString(),
- SuppressTransaction = suppressTransaction
- });
- }
-
- void DecompressTable(string tableName, string? schema, bool suppressTransaction)
- {
- var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
-
- var decompressTableCommand = new StringBuilder()
- .Append("IF EXISTS (")
- .Append("SELECT 1 FROM [sys].[tables] [t] ")
- .Append("INNER JOIN [sys].[partitions] [p] ON [t].[object_id] = [p].[object_id] ")
- .Append($"WHERE [t].[name] = '{tableName}' ");
-
- if (schema != null)
- {
- decompressTableCommand.Append($"AND [t].[schema_id] = schema_id('{schema}') ");
- }
-
- decompressTableCommand.AppendLine("AND data_compression <> 0)")
- .Append("EXEC(")
- .Append(
- stringTypeMapping.GenerateSqlLiteral(
- "ALTER TABLE "
- + Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)
- + " REBUILD PARTITION = ALL WITH (DATA_COMPRESSION = NONE)"
- + Dependencies.SqlGenerationHelper.StatementTerminator))
- .Append(")")
- .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
-
- operations.Add(
- new SqlOperation { Sql = decompressTableCommand.ToString(), SuppressTransaction = suppressTransaction });
- }
-
- static TOperation CopyColumnOperation(ColumnOperation source)
- where TOperation : ColumnOperation, new()
- {
- var result = new TOperation
- {
- ClrType = source.ClrType,
- Collation = source.Collation,
- ColumnType = source.ColumnType,
- Comment = source.Comment,
- ComputedColumnSql = source.ComputedColumnSql,
- DefaultValue = source.DefaultValue,
- DefaultValueSql = source.DefaultValueSql,
- IsDestructiveChange = source.IsDestructiveChange,
- IsFixedLength = source.IsFixedLength,
- IsNullable = source.IsNullable,
- IsRowVersion = source.IsRowVersion,
- IsStored = source.IsStored,
- IsUnicode = source.IsUnicode,
- MaxLength = source.MaxLength,
- Name = source.Name,
- Precision = source.Precision,
- Scale = source.Scale,
- Table = source.Table,
- Schema = source.Schema
- };
-
- foreach (var annotation in source.GetAnnotations())
- {
- result.AddAnnotation(annotation.Name, annotation.Value);
- }
-
- return result;
- }
- }
-
- private sealed class TemporalOperationInformation
- {
- public bool IsTemporalTable { get; set; }
- public string? HistoryTableName { get; set; }
- public string? HistoryTableSchema { get; set; }
- public string? PeriodStartColumnName { get; set; }
- public string? PeriodEndColumnName { get; set; }
-
- public bool DisabledVersioning { get; set; }
- public bool DisabledPeriod { get; set; }
-
- public bool ShouldEnableVersioning { get; set; }
- public bool ShouldEnablePeriod { get; set; }
- public bool SuppressTransaction { get; set; }
- }
-}
+
+ operations.Add(alterColumnOperation);
+ }
+
+ break;
+ }
+
+ case DropPrimaryKeyOperation:
+ case AddPrimaryKeyOperation:
+ if (temporalInformation.IsTemporalTable)
+ {
+ DisableVersioning(
+ tableName!,
+ schema,
+ temporalInformation,
+ suppressTransaction,
+ shouldEnableVersioning: true);
+ }
+
+ operations.Add(operation);
+ break;
+
+ default:
+ operations.Add(operation);
+ break;
+ }
+ }
+
+ foreach (var temporalInformation in temporalTableInformationMap.Where(x => x.Value.ShouldEnablePeriod))
+ {
+ EnablePeriod(
+ temporalInformation.Key.TableName,
+ temporalInformation.Key.Schema,
+ temporalInformation.Value.PeriodStartColumnName!,
+ temporalInformation.Value.PeriodEndColumnName!,
+ temporalInformation.Value.SuppressTransaction);
+ }
+
+ foreach (var temporalInformation in temporalTableInformationMap.Where(x => x.Value.ShouldEnableVersioning))
+ {
+ EnableVersioning(
+ temporalInformation.Key.TableName,
+ temporalInformation.Key.Schema,
+ temporalInformation.Value.HistoryTableName!,
+ temporalInformation.Value.HistoryTableSchema,
+ temporalInformation.Value.SuppressTransaction);
+ }
+
+ return operations;
+
+ static TemporalOperationInformation BuildTemporalInformationFromMigrationOperation(
+ string? schema,
+ IAnnotatable operation)
+ {
+ var isTemporalTable = operation[SqlServerAnnotationNames.IsTemporal] as bool? == true;
+ var historyTableName = operation[SqlServerAnnotationNames.TemporalHistoryTableName] as string;
+ var historyTableSchema = operation[SqlServerAnnotationNames.TemporalHistoryTableSchema] as string ?? schema;
+ var periodStartColumnName = operation[SqlServerAnnotationNames.TemporalPeriodStartColumnName] as string;
+ var periodEndColumnName = operation[SqlServerAnnotationNames.TemporalPeriodEndColumnName] as string;
+
+ return new TemporalOperationInformation
+ {
+ IsTemporalTable = isTemporalTable,
+ HistoryTableName = historyTableName,
+ HistoryTableSchema = historyTableSchema,
+ PeriodStartColumnName = periodStartColumnName,
+ PeriodEndColumnName = periodEndColumnName
+ };
+ }
+
+ void DisableVersioning(
+ string tableName,
+ string? schema,
+ TemporalOperationInformation temporalInformation,
+ bool suppressTransaction,
+ bool? shouldEnableVersioning)
+ {
+ if (!temporalInformation.DisabledVersioning
+ && !temporalInformation.ShouldEnableVersioning)
+ {
+ temporalInformation.DisabledVersioning = true;
+
+ AddDisableVersioningOperation(tableName, schema, suppressTransaction);
+
+ if (shouldEnableVersioning != null)
+ {
+ temporalInformation.ShouldEnableVersioning = shouldEnableVersioning.Value;
+ if (shouldEnableVersioning.Value)
+ {
+ temporalInformation.SuppressTransaction = suppressTransaction;
+ }
+ }
+ }
+ }
+
+ void AddDisableVersioningOperation(string tableName, string? schema, bool suppressTransaction)
+ => operations.Add(
+ new SqlOperation
+ {
+ Sql = new StringBuilder()
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema))
+ .AppendLine(" SET (SYSTEM_VERSIONING = OFF)")
+ .ToString(),
+ SuppressTransaction = suppressTransaction
+ });
+
+ void EnableVersioning(string table, string? schema, string historyTableName, string? historyTableSchema, bool suppressTransaction)
+ {
+ var stringBuilder = new StringBuilder();
+
+ string? schemaVariable = null;
+ if (historyTableSchema == null)
+ {
+ schemaVariable = Uniquify("@historyTableSchema");
+ // need to run command using EXEC to inject default schema
+ stringBuilder.AppendLine($"DECLARE {schemaVariable} nvarchar(max) = QUOTENAME(SCHEMA_NAME())");
+ stringBuilder.Append("EXEC(N'");
+ }
+
+ var historyTable = historyTableSchema != null
+ ? Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName, historyTableSchema)
+ : Dependencies.SqlGenerationHelper.DelimitIdentifier(historyTableName);
+
+ stringBuilder
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema));
+
+ if (historyTableSchema != null)
+ {
+ stringBuilder.AppendLine($" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = {historyTable}))");
+ }
+ else
+ {
+ stringBuilder.AppendLine(
+ $" SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = ' + {schemaVariable} + '.{historyTable}))')");
+ }
+
+ operations.Add(
+ new SqlOperation { Sql = stringBuilder.ToString(), SuppressTransaction = suppressTransaction });
+ }
+
+ void DisablePeriod(
+ string table,
+ string? schema,
+ TemporalOperationInformation temporalInformation,
+ bool suppressTransaction)
+ {
+ temporalInformation.DisabledPeriod = true;
+
+ operations.Add(
+ new SqlOperation
+ {
+ Sql = new StringBuilder()
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
+ .AppendLine(" DROP PERIOD FOR SYSTEM_TIME")
+ .ToString(),
+ SuppressTransaction = suppressTransaction
+ });
+ }
+
+ void EnablePeriod(string table, string? schema, string periodStartColumnName, string periodEndColumnName, bool suppressTransaction)
+ {
+ var addPeriodSql = new StringBuilder()
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
+ .Append(" ADD PERIOD FOR SYSTEM_TIME (")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodStartColumnName))
+ .Append(", ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodEndColumnName))
+ .Append(')')
+ .ToString();
+
+ if (options.HasFlag(MigrationsSqlGenerationOptions.Idempotent))
+ {
+ addPeriodSql = new StringBuilder()
+ .Append("EXEC(N'")
+ .Append(addPeriodSql.Replace("'", "''"))
+ .Append("')")
+ .ToString();
+ }
+
+ operations.Add(
+ new SqlOperation { Sql = addPeriodSql, SuppressTransaction = suppressTransaction });
+
+ operations.Add(
+ new SqlOperation
+ {
+ Sql = new StringBuilder()
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
+ .Append(" ALTER COLUMN ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodStartColumnName))
+ .Append(" ADD HIDDEN")
+ .ToString(),
+ SuppressTransaction = suppressTransaction
+ });
+
+ operations.Add(
+ new SqlOperation
+ {
+ Sql = new StringBuilder()
+ .Append("ALTER TABLE ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table, schema))
+ .Append(" ALTER COLUMN ")
+ .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(periodEndColumnName))
+ .Append(" ADD HIDDEN")
+ .ToString(),
+ SuppressTransaction = suppressTransaction
+ });
+ }
+
+ void DecompressTable(string tableName, string? schema, bool suppressTransaction)
+ {
+ var stringTypeMapping = Dependencies.TypeMappingSource.GetMapping(typeof(string));
+
+ var decompressTableCommand = new StringBuilder()
+ .Append("IF EXISTS (")
+ .Append("SELECT 1 FROM [sys].[tables] [t] ")
+ .Append("INNER JOIN [sys].[partitions] [p] ON [t].[object_id] = [p].[object_id] ")
+ .Append($"WHERE [t].[name] = '{tableName}' ");
+
+ if (schema != null)
+ {
+ decompressTableCommand.Append($"AND [t].[schema_id] = schema_id('{schema}') ");
+ }
+
+ decompressTableCommand.AppendLine("AND data_compression <> 0)")
+ .Append("EXEC(")
+ .Append(
+ stringTypeMapping.GenerateSqlLiteral(
+ "ALTER TABLE "
+ + Dependencies.SqlGenerationHelper.DelimitIdentifier(tableName, schema)
+ + " REBUILD PARTITION = ALL WITH (DATA_COMPRESSION = NONE)"
+ + Dependencies.SqlGenerationHelper.StatementTerminator))
+ .Append(")")
+ .AppendLine(Dependencies.SqlGenerationHelper.StatementTerminator);
+
+ operations.Add(
+ new SqlOperation { Sql = decompressTableCommand.ToString(), SuppressTransaction = suppressTransaction });
+ }
+
+ static TOperation CopyColumnOperation(ColumnOperation source)
+ where TOperation : ColumnOperation, new()
+ {
+ var result = new TOperation
+ {
+ ClrType = source.ClrType,
+ Collation = source.Collation,
+ ColumnType = source.ColumnType,
+ Comment = source.Comment,
+ ComputedColumnSql = source.ComputedColumnSql,
+ DefaultValue = source.DefaultValue,
+ DefaultValueSql = source.DefaultValueSql,
+ IsDestructiveChange = source.IsDestructiveChange,
+ IsFixedLength = source.IsFixedLength,
+ IsNullable = source.IsNullable,
+ IsRowVersion = source.IsRowVersion,
+ IsStored = source.IsStored,
+ IsUnicode = source.IsUnicode,
+ MaxLength = source.MaxLength,
+ Name = source.Name,
+ Precision = source.Precision,
+ Scale = source.Scale,
+ Table = source.Table,
+ Schema = source.Schema
+ };
+
+ foreach (var annotation in source.GetAnnotations())
+ {
+ result.AddAnnotation(annotation.Name, annotation.Value);
+ }
+
+ return result;
+ }
+ }
+
+ private sealed class TemporalOperationInformation
+ {
+ public bool IsTemporalTable { get; set; }
+ public string? HistoryTableName { get; set; }
+ public string? HistoryTableSchema { get; set; }
+ public string? PeriodStartColumnName { get; set; }
+ public string? PeriodEndColumnName { get; set; }
+
+ public bool DisabledVersioning { get; set; }
+ public bool DisabledPeriod { get; set; }
+
+ public bool ShouldEnableVersioning { get; set; }
+ public bool ShouldEnablePeriod { get; set; }
+ public bool SuppressTransaction { get; set; }
+ }
+}
diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
index 59cb22aabed..83ab414129e 100644
--- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
+++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs
@@ -267,6 +267,12 @@ public static string InvalidCollationName(object? collation)
GetString("InvalidCollationName", nameof(collation)),
collation);
+ ///
+ /// The expression passed to the 'propertyReference' parameter of the 'FreeText' method is not a valid reference to a property. The expression must represent a reference to a full-text indexed property on the object referenced in the from clause: 'from e in context.Entities where EF.Functions.FreeText(e.SomeProperty, textToSearchFor) select e'
+ ///
+ public static string InvalidColumnNameForFreeText
+ => GetString("InvalidColumnNameForFreeText");
+
///
/// The datepart '{datepart}' is invalid for the {function} function; datepart values may only contain letters and underscores.
///
@@ -275,12 +281,6 @@ public static string InvalidDatePart(object? datepart, object? function)
GetString("InvalidDatePart", nameof(datepart), nameof(function)),
datepart, function);
- ///
- /// The expression passed to the 'propertyReference' parameter of the 'FreeText' method is not a valid reference to a property. The expression must represent a reference to a full-text indexed property on the object referenced in the from clause: 'from e in context.Entities where EF.Functions.FreeText(e.SomeProperty, textToSearchFor) select e'
- ///
- public static string InvalidColumnNameForFreeText
- => GetString("InvalidColumnNameForFreeText");
-
///
/// Engine type was not configured. Use one of {methods} to configure it.
///
@@ -477,12 +477,6 @@ public static string TemporalSetOperationOnMismatchedSources(object? entityType)
GetString("TemporalSetOperationOnMismatchedSources", nameof(entityType)),
entityType);
- ///
- /// SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported.
- ///
- public static string TimeSpanOffsetPrecisionNotSupported
- => GetString("TimeSpanOffsetPrecisionNotSupported");
-
///
/// The provided time zone offset '{offset}' is outside the valid range for SQL Server. Time zone offsets must be between -14:00 and +14:00.
///
@@ -491,6 +485,12 @@ public static string TimeSpanOffsetOutOfRange(object? offset)
GetString("TimeSpanOffsetOutOfRange", nameof(offset)),
offset);
+ ///
+ /// SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported.
+ ///
+ public static string TimeSpanOffsetPrecisionNotSupported
+ => GetString("TimeSpanOffsetPrecisionNotSupported");
+
///
/// An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
///
@@ -557,18 +557,18 @@ public static string VectorPropertiesNotSupportedInJson(object? propertyName, ob
public static string VectorSearchRequiresColumn
=> GetString("VectorSearchRequiresColumn");
- ///
- /// WithApproximate() must be called after Take() to specify the number of results.
- ///
- public static string WithApproximateRequiresTake
- => GetString("WithApproximateRequiresTake");
-
///
/// WithApproximate() after Skip().Take() is not supported. Use Take().WithApproximate().Skip() instead, or remove Skip().
///
public static string WithApproximateNotSupportedWithSkipAndTake
=> GetString("WithApproximateNotSupportedWithSkipAndTake");
+ ///
+ /// WithApproximate() must be called after Take() to specify the number of results.
+ ///
+ public static string WithApproximateRequiresTake
+ => GetString("WithApproximateRequiresTake");
+
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name)!;
diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
index e2b012113a1..264d3c6af9e 100644
--- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
+++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx
@@ -1,17 +1,17 @@
-
@@ -213,12 +213,12 @@
Collation name '{collation}' is invalid; collation names may only contain alphanumeric characters and underscores.
-
- The datepart '{datepart}' is invalid for the {function} function; datepart values may only contain letters and underscores.
-
The expression passed to the 'propertyReference' parameter of the 'FreeText' method is not a valid reference to a property. The expression must represent a reference to a full-text indexed property on the object referenced in the from clause: 'from e in context.Entities where EF.Functions.FreeText(e.SomeProperty, textToSearchFor) select e'
+
+ The datepart '{datepart}' is invalid for the {function} function; datepart values may only contain letters and underscores.
+
Engine type was not configured. Use one of {methods} to configure it.
@@ -397,12 +397,12 @@
Set operation can't be applied on entity '{entityType}' because temporal operations on both arguments don't match.
-
- SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported.
-
The provided time zone offset '{offset}' is outside the valid range for SQL Server. Time zone offsets must be between -14:00 and +14:00.
+
+ SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported.
+
An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
@@ -430,10 +430,10 @@
VectorSearch() requires a valid vector column.
-
- WithApproximate() must be called after Take() to specify the number of results.
-
WithApproximate() after Skip().Take() is not supported. Use Take().WithApproximate().Skip() instead, or remove Skip().
+
+ WithApproximate() must be called after Take() to specify the number of results.
+
\ No newline at end of file
diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs
index 53abc79140b..5f0104c2e22 100644
--- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs
+++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs
@@ -662,6 +662,11 @@ FROM [sys].[views] AS [v]
GetFullTextIndexes(connection, tables, tableFilterSql);
}
+ if (SupportsJsonIndexes)
+ {
+ GetJsonIndexPaths(connection, tables, tableFilterSql);
+ }
+
GetForeignKeys(connection, tables, tableFilterSql);
if (SupportsTriggers)
@@ -1276,6 +1281,92 @@ private void GetFullTextCatalogs(DbConnection connection, DatabaseModel database
}
}
+ private void GetJsonIndexPaths(DbConnection connection, IReadOnlyList tables, string tableFilter)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = $"""
+SELECT
+ SCHEMA_NAME([t].[schema_id]) AS [table_schema],
+ [t].[name] AS [table_name],
+ [i].[name] AS [index_name],
+ [i].[is_unique],
+ [i].[has_filter],
+ [i].[filter_definition],
+ [i].[fill_factor],
+ COL_NAME([ic].[object_id], [ic].[column_id]) AS [column_name],
+ [jip].[path]
+FROM [sys].[json_index_paths] AS [jip]
+JOIN [sys].[indexes] AS [i] ON [jip].[object_id] = [i].[object_id] AND [jip].[index_id] = [i].[index_id]
+JOIN [sys].[tables] AS [t] ON [i].[object_id] = [t].[object_id]
+JOIN [sys].[index_columns] AS [ic] ON [i].[object_id] = [ic].[object_id] AND [i].[index_id] = [ic].[index_id]
+WHERE {tableFilter}
+ORDER BY [table_schema], [table_name], [index_name], [jip].[path];
+""";
+
+ using var reader = command.ExecuteReader();
+ var indexGroups = reader.Cast()
+ .GroupBy(r => (
+ tableSchema: r.GetValueOrDefault("table_schema"),
+ tableName: r.GetFieldValue("table_name"),
+ indexName: r.GetFieldValue("index_name")))
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ foreach (var ((tableSchema, tableName, indexName), records) in indexGroups)
+ {
+ var table = tables.SingleOrDefault(t => t.Schema == tableSchema && t.Name == tableName);
+ if (table is null)
+ {
+ continue;
+ }
+
+ var index = table.Indexes.SingleOrDefault(i => i.Name == indexName);
+ var firstRecord = records[0];
+ var jsonColumn = firstRecord.GetValueOrDefault("column_name");
+ var paths = records.Select(r => r.GetFieldValue("path")).ToArray();
+ if (jsonColumn is null || table.Columns.FirstOrDefault(c => c.Name == jsonColumn) is not { } column)
+ {
+ continue;
+ }
+
+ var isUnique = firstRecord.GetFieldValue("is_unique");
+ var filter = firstRecord.GetFieldValue("has_filter")
+ ? firstRecord.GetValueOrDefault("filter_definition")
+ : null;
+ var fillFactor = firstRecord.GetValueOrDefault("fill_factor") is var fillFactorRaw && fillFactorRaw is > 0 and <= 100
+ ? (int?)fillFactorRaw
+ : null;
+
+ if (index is null)
+ {
+ // JSON indexes aren't surfaced by the generic GetIndexes query: although they do
+ // have rows in sys.index_columns, those rows reference column ids that don't
+ // resolve through the inner join to sys.columns, so the join drops them.
+ // Synthesize a DatabaseIndex carrying the JSON container column and the path annotation.
+ index = new DatabaseIndex
+ {
+ Table = table,
+ Name = indexName,
+ IsUnique = isUnique,
+ Filter = filter
+ };
+ index.Columns.Add(column);
+ table.Indexes.Add(index);
+ }
+ else
+ {
+ index.IsUnique = isUnique;
+ index.Filter = filter;
+ }
+
+ if (fillFactor is not null)
+ {
+ index[SqlServerAnnotationNames.FillFactor] = fillFactor.Value;
+ }
+
+ index[RelationalAnnotationNames.JsonIndexPaths] = (jsonColumn, paths);
+ }
+ }
+
private void GetFullTextIndexes(DbConnection connection, IReadOnlyList tables, string tableFilter)
{
using var command = connection.CreateCommand();
@@ -1593,6 +1684,9 @@ private bool SupportsFullTextSearch
private bool SupportsVectorIndexes
=> _compatibilityLevel >= 170 && IsFullFeaturedEngineEdition;
+ private bool SupportsJsonIndexes
+ => _compatibilityLevel >= 170 && IsFullFeaturedEngineEdition;
+
private bool SupportsViews
=> _engineEdition != EngineEdition.DynamicsCrm;
diff --git a/src/EFCore/EFCore.baseline.json b/src/EFCore/EFCore.baseline.json
index 523bc1d7eaa..a9e74024980 100644
--- a/src/EFCore/EFCore.baseline.json
+++ b/src/EFCore/EFCore.baseline.json
@@ -3850,6 +3850,9 @@
{
"Member": "static string ConflictingKeylessAndPrimaryKeyAttributes(object? entity);"
},
+ {
+ "Member": "static string ConflictingNamedIndex(object? indexName, object? entityType, object? propertyList);"
+ },
{
"Member": "static string ConflictingPropertyOrNavigationOnBaseType(object? member, object? type, object? conflictingMemberKind, object? conflictingType);"
},
@@ -4157,6 +4160,9 @@
{
"Member": "static string InvalidAlternateKeyValue(object? entityType, object? keyProperty);"
},
+ {
+ "Member": "static string InvalidCollectionIndicesEntryLength(object? property, object? indexProperties, object? actualCount, object? expectedCount);"
+ },
{
"Member": "static string InvalidComplexType(object? type);"
},
@@ -4187,6 +4193,9 @@
{
"Member": "static string InvalidNavigationWithInverseProperty(object? property, object? entityType, object? referencedProperty, object? referencedEntityType);"
},
+ {
+ "Member": "static string InvalidNumberOfIndexCollectionIndices(object? indexProperties, object? numValues, object? numProperties);"
+ },
{
"Member": "static string InvalidNumberOfIndexSortOrderValues(object? indexProperties, object? numValues, object? numProperties);"
},
@@ -9218,6 +9227,9 @@
{
"Member": "virtual Microsoft.EntityFrameworkCore.Metadata.IConventionIndex? CreateIndex(System.Collections.Generic.IReadOnlyList properties, bool unique, Microsoft.EntityFrameworkCore.Metadata.Builders.IConventionEntityTypeBuilder entityTypeBuilder);"
},
+ {
+ "Member": "static bool IsNonComplexCollectionIndex(Microsoft.EntityFrameworkCore.Metadata.IConventionIndex index);"
+ },
{
"Member": "virtual void ProcessEntityTypeBaseTypeChanged(Microsoft.EntityFrameworkCore.Metadata.Builders.IConventionEntityTypeBuilder entityTypeBuilder, Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType? newBaseType, Microsoft.EntityFrameworkCore.Metadata.IConventionEntityType? oldBaseType, Microsoft.EntityFrameworkCore.Metadata.Conventions.IConventionContext context);"
},
@@ -13684,12 +13696,18 @@
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableIndex AddIndex(System.Collections.Generic.IReadOnlyList properties);"
},
+ {
+ "Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableIndex AddIndex(System.Collections.Generic.IReadOnlyList properties, System.Collections.Generic.IReadOnlyList?>? collectionIndices);"
+ },
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableIndex AddIndex(Microsoft.EntityFrameworkCore.Metadata.IMutablePropertyBase property, string name);"
},
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableIndex AddIndex(System.Collections.Generic.IReadOnlyList properties, string name);"
},
+ {
+ "Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableIndex AddIndex(System.Collections.Generic.IReadOnlyList properties, System.Collections.Generic.IReadOnlyList?>? collectionIndices, string name);"
+ },
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableKey AddKey(Microsoft.EntityFrameworkCore.Metadata.IMutableProperty property);"
},
@@ -13986,6 +14004,9 @@
{
"Type": "interface Microsoft.EntityFrameworkCore.Metadata.IMutableIndex : Microsoft.EntityFrameworkCore.Metadata.IReadOnlyIndex, Microsoft.EntityFrameworkCore.Infrastructure.IReadOnlyAnnotatable, Microsoft.EntityFrameworkCore.Metadata.IMutableAnnotatable",
"Properties": [
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList?>? CollectionIndices { get; }"
+ },
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IMutableEntityType DeclaringEntityType { get; }"
},
@@ -15637,6 +15658,9 @@
}
],
"Properties": [
+ {
+ "Member": "System.Collections.Generic.IReadOnlyList?>? CollectionIndices { get; }"
+ },
{
"Member": "Microsoft.EntityFrameworkCore.Metadata.IReadOnlyEntityType DeclaringEntityType { get; }"
},
@@ -18794,6 +18818,9 @@
{
"Member": "virtual void ValidateIndexOnComplexProperty(Microsoft.EntityFrameworkCore.Metadata.IIndex index, System.Collections.Generic.IReadOnlyList complexProperties, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
},
+ {
+ "Member": "virtual void ValidateIndexProperty(Microsoft.EntityFrameworkCore.Metadata.IIndex index, Microsoft.EntityFrameworkCore.Metadata.IPropertyBase property, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
+ },
{
"Member": "virtual void ValidateInheritanceMapping(Microsoft.EntityFrameworkCore.Metadata.IEntityType entityType, Microsoft.EntityFrameworkCore.Diagnostics.IDiagnosticsLogger logger);"
},
@@ -22381,7 +22408,7 @@
"Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RuntimeForeignKey AddForeignKey(System.Collections.Generic.IReadOnlyList properties, Microsoft.EntityFrameworkCore.Metadata.RuntimeKey principalKey, Microsoft.EntityFrameworkCore.Metadata.RuntimeEntityType principalEntityType, Microsoft.EntityFrameworkCore.DeleteBehavior deleteBehavior = Microsoft.EntityFrameworkCore.DeleteBehavior.ClientSetNull, bool unique = false, bool required = false, bool requiredDependent = false, bool ownership = false);"
},
{
- "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RuntimeIndex AddIndex(System.Collections.Generic.IReadOnlyList properties, string? name = null, bool unique = false);"
+ "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RuntimeIndex AddIndex(System.Collections.Generic.IReadOnlyList properties, string? name = null, bool unique = false, System.Collections.Generic.IReadOnlyList?>? collectionIndices = null);"
},
{
"Member": "virtual Microsoft.EntityFrameworkCore.Metadata.RuntimeKey AddKey(System.Collections.Generic.IReadOnlyList properties);"
diff --git a/src/EFCore/Extensions/Internal/ExpressionExtensions.cs b/src/EFCore/Extensions/Internal/ExpressionExtensions.cs
index 668508aaf04..b0382f39e9b 100644
--- a/src/EFCore/Extensions/Internal/ExpressionExtensions.cs
+++ b/src/EFCore/Extensions/Internal/ExpressionExtensions.cs
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
+using Microsoft.EntityFrameworkCore.Metadata.Internal;
+
// ReSharper disable once CheckNamespace
namespace Microsoft.EntityFrameworkCore.Internal;
@@ -76,7 +78,7 @@ public static Expression MakeHasSentinel(
/// 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 static IReadOnlyList>? MatchMemberAccessChainList(
+ public static IReadOnlyList>? MatchMemberAccessChainList(
this LambdaExpression lambdaExpression)
{
Check.DebugAssert(lambdaExpression.Body != null, "lambdaExpression.Body is null");
@@ -84,28 +86,25 @@ public static Expression MakeHasSentinel(
lambdaExpression.Parameters.Count == 1,
"lambdaExpression.Parameters.Count is " + lambdaExpression.Parameters.Count + ". Should be 1.");
- var parameterExpression = lambdaExpression.Parameters[0];
+ var parameter = lambdaExpression.Parameters[0];
+ var body = RemoveConvert(lambdaExpression.Body);
+ var paths = body is NewExpression newExpression
+ ? newExpression.Arguments
+ : (IReadOnlyList)[lambdaExpression.Body];
- if (RemoveConvert(lambdaExpression.Body) is NewExpression newExpression)
+ var chains = new List>(paths.Count);
+ foreach (var path in paths)
{
- var chains = new List>(newExpression.Arguments.Count);
- foreach (var argument in newExpression.Arguments)
+ var parsed = MatchComplexMemberAccess(path, parameter);
+ if (parsed is null)
{
- var chain = MatchMemberAccess(parameterExpression, argument);
- if (chain == null)
- {
- return null;
- }
-
- chains.Add(chain);
+ return null;
}
- return chains;
+ chains.Add(parsed.Value.Members);
}
- var memberPath = MatchMemberAccess(parameterExpression, lambdaExpression.Body);
-
- return memberPath != null ? new[] { memberPath } : null;
+ return chains;
}
///
@@ -114,7 +113,7 @@ public static Expression MakeHasSentinel(
/// 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 static IReadOnlyList> GetMemberAccessChainList(
+ public static IReadOnlyList> GetMemberAccessChainList(
this LambdaExpression expression)
=> expression.MatchMemberAccessChainList()
?? throw new ArgumentException(
@@ -177,14 +176,15 @@ var memberInfos
/// 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 static List? MatchMemberAccessChain(
+ public static IReadOnlyList? MatchMemberAccessChain(
this LambdaExpression lambdaExpression)
{
Check.DebugAssert(
lambdaExpression.Parameters.Count == 1,
$"Parameters.Count is {lambdaExpression.Parameters.Count}");
- return MatchMemberAccess(lambdaExpression.Parameters[0], lambdaExpression.Body);
+ var parsed = MatchComplexMemberAccess(lambdaExpression.Body, lambdaExpression.Parameters[0]);
+ return parsed?.Members;
}
///
@@ -193,7 +193,7 @@ var memberInfos
/// 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 static List GetMemberAccessChain(
+ public static IReadOnlyList GetMemberAccessChain(
this LambdaExpression expression,
string parameterName)
=> expression.MatchMemberAccessChain()
@@ -201,6 +201,212 @@ public static List GetMemberAccessChain(
CoreStrings.InvalidMemberAccessChainExpression(expression),
parameterName);
+ ///
+ /// 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.
+ ///
+ ///
+ ///
+ /// Parses a lambda whose body may traverse complex collections via
+ ///
+ /// or constant indexers.
+ ///
+ ///
+ /// Returns one entry per indexed member (an anonymous-type body produces
+ /// multiple entries; any other body produces one entry). Members contains the resolved member
+ /// chain (skipping over Select / indexer boundaries). IsCollection runs parallel to
+ /// Members (length equal to Members.Count): at a given position
+ /// means the corresponding member was reached as a complex-collection traversal (a Select
+ /// projection or a constant indexer). CollectionIndices has one entry per traversed
+ /// complex-collection segment — ordered to match the entries in
+ /// IsCollection; means "all elements" (a Select projection)
+ /// and a non- means the fixed element index.
+ ///
+ ///
+ /// The top-level IsCollection is only when every parsed chain is
+ /// a single non-collection member; the per-chain inner CollectionIndices is
+ /// when that chain traverses no complex collection, and the top-level
+ /// CollectionIndices is when no chain traverses any complex collection.
+ ///
+ ///
+ /// Throws if any leaf cannot be parsed as a recognised member-access chain.
+ ///
+ ///
+ public static (IReadOnlyList> Members,
+ IReadOnlyList>? IsCollection,
+ IReadOnlyList?>? CollectionIndices)
+ MatchComplexMemberAccessList(this LambdaExpression lambdaExpression, string parameterName)
+ {
+ Check.DebugAssert(lambdaExpression.Body != null, "lambdaExpression.Body is null");
+ Check.DebugAssert(
+ lambdaExpression.Parameters.Count == 1,
+ "lambdaExpression.Parameters.Count is " + lambdaExpression.Parameters.Count + ". Should be 1.");
+
+ var parameter = lambdaExpression.Parameters[0];
+ var body = RemoveConvert(lambdaExpression.Body);
+
+ var paths = body is NewExpression newExpression ? newExpression.Arguments : (IReadOnlyList)[lambdaExpression.Body];
+ var members = new List>(paths.Count);
+ var isCollection = new List>(paths.Count);
+ var indices = new List?>(paths.Count);
+ var anyIndices = false;
+ var anyComplexChain = false;
+
+ foreach (var path in paths)
+ {
+ var parsed = MatchComplexMemberAccess(path, parameter) ?? throw new ArgumentException(
+ CoreStrings.InvalidMemberAccessChainExpression(lambdaExpression), parameterName);
+
+ members.Add(parsed.Members);
+ isCollection.Add(parsed.IsCollection);
+ indices.Add(parsed.CollectionIndices);
+ if (InternalTypeBaseBuilder.ContainsMultipleOrTrue(parsed.IsCollection))
+ {
+ anyComplexChain = true;
+ }
+
+ if (parsed.CollectionIndices is not null)
+ {
+ anyIndices = true;
+ }
+ }
+
+ return (members, anyComplexChain ? isCollection : null, anyIndices ? indices : null);
+ }
+
+ private static (IReadOnlyList Members, IReadOnlyList IsCollection, IReadOnlyList? CollectionIndices)?
+ MatchComplexMemberAccess(
+ Expression expression,
+ ParameterExpression parameter)
+ {
+ var members = new List();
+ var indices = new List();
+ var collectionPositions = new HashSet();
+ if (!VisitMemberAccess(expression, parameter, members, indices, collectionPositions))
+ {
+ return null;
+ }
+
+ // Build a per-member is-collection list (length = members.Count). A position is marked true when
+ // the corresponding member was reached through a complex-collection traversal (Select or indexer).
+ bool[] isCollection;
+ if (members.Count == 0)
+ {
+ isCollection = [];
+ }
+ else
+ {
+ isCollection = new bool[members.Count];
+ foreach (var pos in collectionPositions)
+ {
+ isCollection[pos] = true;
+ }
+ }
+
+ return (members, isCollection, indices.Count == 0 ? null : indices);
+ }
+
+ private static bool VisitMemberAccess(
+ Expression expression,
+ ParameterExpression parameter,
+ List members,
+ List indices,
+ HashSet collectionPositions)
+ {
+ // Members and indices are populated in order from the outermost of the chain (closest to the parameter)
+ // to the innermost (the leaf). This method appends to them; recursive calls process the part of the
+ // chain that is closer to the parameter and then we add the post-boundary members/index on top.
+ var current = RemoveTypeAs(RemoveConvert(expression));
+
+ // Collect a tail run of MemberExpressions (post-boundary).
+ var tailMembers = new List();
+ while (current is MemberExpression me)
+ {
+ tailMembers.Add(me.Member);
+ current = RemoveTypeAs(RemoveConvert(me.Expression));
+ }
+
+ tailMembers.Reverse();
+
+ // Reached the parameter directly: no boundary, just a member chain.
+ if (current == parameter)
+ {
+ members.AddRange(tailMembers);
+ return true;
+ }
+
+ // Enumerable.Select(source, lambda) — the inner lambda's body becomes the tail.
+ if (current is MethodCallExpression call)
+ {
+ if (call.Method.IsStatic
+ && (call.Method.DeclaringType == typeof(Enumerable) || call.Method.DeclaringType == typeof(Queryable))
+ && call.Method.Name == nameof(Enumerable.Select)
+ && call.Arguments.Count == 2
+ && tailMembers.Count == 0)
+ {
+ var selectorOperand = call.Arguments[1];
+ if (selectorOperand is UnaryExpression { NodeType: ExpressionType.Quote } quoted)
+ {
+ selectorOperand = quoted.Operand;
+ }
+
+ if (selectorOperand is LambdaExpression innerLambda
+ && innerLambda.Parameters.Count == 1
+ && VisitMemberAccess(call.Arguments[0], parameter, members, indices, collectionPositions))
+ {
+ indices.Add(null);
+ collectionPositions.Add(members.Count - 1);
+ return VisitMemberAccess(innerLambda.Body, innerLambda.Parameters[0], members, indices, collectionPositions);
+ }
+
+ return false;
+ }
+
+ // List.get_Item / IList indexer with constant int.
+ if (!call.Method.IsStatic
+ && call.Method.Name == "get_Item"
+ && call.Arguments.Count == 1
+ && call.Object is not null
+ && TryGetConstantIntIndex(call.Arguments[0], out var indexerValue)
+ && VisitMemberAccess(call.Object, parameter, members, indices, collectionPositions))
+ {
+ indices.Add(indexerValue);
+ collectionPositions.Add(members.Count - 1);
+ members.AddRange(tailMembers);
+ return true;
+ }
+
+ return false;
+ }
+
+ // T[] indexer.
+ if (current is BinaryExpression { NodeType: ExpressionType.ArrayIndex } arrayIndex
+ && TryGetConstantIntIndex(arrayIndex.Right, out var arrayIndexValue)
+ && VisitMemberAccess(arrayIndex.Left, parameter, members, indices, collectionPositions))
+ {
+ indices.Add(arrayIndexValue);
+ collectionPositions.Add(members.Count - 1);
+ members.AddRange(tailMembers);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryGetConstantIntIndex(Expression expression, out int value)
+ {
+ if (RemoveConvert(expression) is ConstantExpression { Value: int i } && i >= 0)
+ {
+ value = i;
+ return true;
+ }
+
+ value = 0;
+ return false;
+ }
+
private static List? MatchMemberAccess(
this Expression parameterExpression,
Expression memberAccessExpression)
diff --git a/src/EFCore/Infrastructure/ModelValidator.cs b/src/EFCore/Infrastructure/ModelValidator.cs
index dd97b5b14a8..d5e137e20b8 100644
--- a/src/EFCore/Infrastructure/ModelValidator.cs
+++ b/src/EFCore/Infrastructure/ModelValidator.cs
@@ -261,8 +261,9 @@ protected virtual void ValidateIndex(
IDiagnosticsLogger logger)
{
List? complexProperties = null;
- foreach (var property in index.Properties)
+ for (var i = 0; i < index.Properties.Count; i++)
{
+ var property = index.Properties[i];
if (property is IComplexProperty complexProperty)
{
(complexProperties ??= []).Add(complexProperty);
@@ -280,12 +281,7 @@ protected virtual void ValidateIndex(
continue;
}
- ValidateComplexPropertyChainForKeyOrIndex(
- property,
- static (props, type, propName) => CoreStrings.IndexOnComplexCollection(props, type, propName),
- nullableErrorFactory: null,
- index.Properties.Format(),
- index.DeclaringEntityType.DisplayName());
+ ValidateIndexProperty(index, property, logger);
}
if (complexProperties != null)
@@ -294,6 +290,21 @@ protected virtual void ValidateIndex(
}
}
+ ///
+ /// Validates a property contained in an index.
+ ///
+ /// The index to validate.
+ /// The property contained in the index.
+ /// The logger to use.
+ protected virtual void ValidateIndexProperty(
+ IIndex index,
+ IPropertyBase property,
+ IDiagnosticsLogger logger)
+ => ValidateComplexPropertyChainForKeyOrIndex(
+ property,
+ propName => CoreStrings.IndexOnComplexCollection(index.Properties.Format(), index.DeclaringEntityType.DisplayName(), propName),
+ nullableErrorFactory: null);
+
///
/// Validates an index that contains a complex property.
///
@@ -331,19 +342,15 @@ protected virtual void ValidateKey(
ValidateComplexPropertyChainForKeyOrIndex(
property,
- static (props, type, propName) => CoreStrings.KeyOnComplexCollection(props, type, propName),
- static (props, type, propName) => CoreStrings.KeyOnNullableComplexProperty(props, type, propName),
- key.Properties.Format(),
- key.DeclaringEntityType.DisplayName());
+ propName => CoreStrings.KeyOnComplexCollection(key.Properties.Format(), key.DeclaringEntityType.DisplayName(), propName),
+ propName => CoreStrings.KeyOnNullableComplexProperty(key.Properties.Format(), key.DeclaringEntityType.DisplayName(), propName));
}
}
private static void ValidateComplexPropertyChainForKeyOrIndex(
IPropertyBase property,
- Func collectionErrorFactory,
- Func? nullableErrorFactory,
- string propertyListFormatted,
- string entityTypeName)
+ Func collectionErrorFactory,
+ Func? nullableErrorFactory)
{
var typeBase = property.DeclaringType;
while (typeBase is IComplexType complexType)
@@ -353,14 +360,14 @@ private static void ValidateComplexPropertyChainForKeyOrIndex(
if (complexProperty.IsCollection)
{
throw new InvalidOperationException(
- collectionErrorFactory(propertyListFormatted, entityTypeName, complexProperty.Name));
+ collectionErrorFactory(complexProperty.Name));
}
if (nullableErrorFactory != null
&& complexProperty.IsNullable)
{
throw new InvalidOperationException(
- nullableErrorFactory(propertyListFormatted, entityTypeName, complexProperty.Name));
+ nullableErrorFactory(complexProperty.Name));
}
typeBase = complexProperty.DeclaringType;
diff --git a/src/EFCore/Metadata/Builders/EntityTypeBuilder`.cs b/src/EFCore/Metadata/Builders/EntityTypeBuilder`.cs
index bc2dd6419b8..612289e57e8 100644
--- a/src/EFCore/Metadata/Builders/EntityTypeBuilder`.cs
+++ b/src/EFCore/Metadata/Builders/EntityTypeBuilder`.cs
@@ -868,13 +868,25 @@ public virtual EntityTypeBuilder HasQueryFilter(string filterKey, Expre
/// If the index is made up of multiple properties then specify an anonymous type including the
/// properties (post => new { post.Title, post.BlogId }).
///
+ ///
+ /// Properties of complex types are also supported by chaining member accesses (e.g.
+ /// order => order.ShippingAddress.City). For properties reached through a complex
+ /// collection, use Select projection over the whole collection
+ /// (blog => blog.Posts.Select(p => p.Title)) or a constant indexer to target a single
+ /// element (blog => blog.Posts[0].Title).
+ ///
///
/// An object that can be used to configure the index.
public virtual IndexBuilder HasIndex(Expression> indexExpression)
- => new(
- Builder.HasIndex(
- Check.NotNull(indexExpression).GetMemberAccessChainList(),
- ConfigurationSource.Explicit)!.Metadata);
+ {
+ Check.NotNull(indexExpression);
+
+ var (members, isCollection, collectionIndices) = indexExpression.MatchComplexMemberAccessList(nameof(indexExpression));
+ var properties = Builder.GetOrCreateProperties(members, isCollection, ConfigurationSource.Explicit)!;
+
+ return new IndexBuilder(
+ Builder.HasIndex(properties, collectionIndices, name: null, ConfigurationSource.Explicit)!.Metadata);
+ }
///
/// Configures an index on the specified properties with the given name.
@@ -890,17 +902,29 @@ public virtual IndexBuilder HasIndex(Expression>
/// If the index is made up of multiple properties then specify an anonymous type including the
/// properties (post => new { post.Title, post.BlogId }).
///
+ ///
+ /// Properties of complex types are also supported by chaining member accesses (e.g.
+ /// order => order.ShippingAddress.City). For properties reached through a complex
+ /// collection, use Select projection over the whole collection
+ /// (blog => blog.Posts.Select(p => p.Title)) or a constant indexer to target a single
+ /// element (blog => blog.Posts[0].Title).
+ ///
///
/// The name to assign to the index.
/// An object that can be used to configure the index.
public virtual IndexBuilder HasIndex(
Expression> indexExpression,
string name)
- => new(
- Builder.HasIndex(
- Check.NotNull(indexExpression).GetMemberAccessChainList(),
- name,
- ConfigurationSource.Explicit)!.Metadata);
+ {
+ Check.NotNull(indexExpression);
+ Check.NotEmpty(name);
+
+ var (members, isCollection, collectionIndices) = indexExpression.MatchComplexMemberAccessList(nameof(indexExpression));
+ var properties = Builder.GetOrCreateProperties(members, isCollection, ConfigurationSource.Explicit)!;
+
+ return new IndexBuilder(
+ Builder.HasIndex(properties, collectionIndices, name, ConfigurationSource.Explicit)!.Metadata);
+ }
///
/// Configures an unnamed index on the specified properties.
diff --git a/src/EFCore/Metadata/Conventions/ForeignKeyIndexConvention.cs b/src/EFCore/Metadata/Conventions/ForeignKeyIndexConvention.cs
index 102778e150c..3e8a785c6d2 100644
--- a/src/EFCore/Metadata/Conventions/ForeignKeyIndexConvention.cs
+++ b/src/EFCore/Metadata/Conventions/ForeignKeyIndexConvention.cs
@@ -127,7 +127,7 @@ public virtual void ProcessKeyAdded(IConventionKeyBuilder keyBuilder, IConventio
var key = keyBuilder.Metadata;
foreach (var index in key.DeclaringEntityType.GetDerivedTypesInclusive()
.SelectMany(t => t.GetDeclaredIndexes())
- .Where(i => AreIndexedBy(i.Properties, i.IsUnique, key.Properties, true)).ToList())
+ .Where(i => IsNonComplexCollectionIndex(i) && AreIndexedBy(i.Properties, i.IsUnique, key.Properties, true)).ToList())
{
RemoveIndex(index);
}
@@ -176,7 +176,7 @@ public virtual void ProcessEntityTypeBaseTypeChanged(
}
var baseKeys = newBaseType?.GetKeys().ToList();
- var baseIndexes = newBaseType?.GetIndexes().ToList();
+ var baseIndexes = newBaseType?.GetIndexes().Where(IsNonComplexCollectionIndex).ToList();
foreach (var foreignKey in entityTypeBuilder.Metadata.GetDeclaredForeignKeys()
.Concat(entityTypeBuilder.Metadata.GetDerivedForeignKeys()))
{
@@ -214,9 +214,18 @@ public virtual void ProcessEntityTypeBaseTypeChanged(
public virtual void ProcessIndexAdded(IConventionIndexBuilder indexBuilder, IConventionContext context)
{
var index = indexBuilder.Metadata;
+
+ // Indexes that traverse complex properties neither cover nor are covered by others.
+ if (!IsNonComplexCollectionIndex(index))
+ {
+ return;
+ }
+
foreach (var otherIndex in index.DeclaringEntityType.GetDerivedTypesInclusive()
.SelectMany(t => t.GetDeclaredIndexes())
- .Where(i => i != index && AreIndexedBy(i.Properties, i.IsUnique, index.Properties, index.IsUnique)).ToList())
+ .Where(i => i != index
+ && IsNonComplexCollectionIndex(i)
+ && AreIndexedBy(i.Properties, i.IsUnique, index.Properties, index.IsUnique)).ToList())
{
RemoveIndex(otherIndex);
}
@@ -238,6 +247,12 @@ public virtual void ProcessIndexRemoved(
return;
}
+ // A removed complex index never covered any FK index, so nothing to re-create.
+ if (!IsNonComplexCollectionIndex(index))
+ {
+ return;
+ }
+
foreach (var foreignKey in index.DeclaringEntityType.GetDerivedTypesInclusive()
.SelectMany(t => t.GetDeclaredForeignKeys())
.Where(fk => AreIndexedBy(fk.Properties, fk.IsUnique, index.Properties, index.IsUnique)))
@@ -277,7 +292,8 @@ public virtual void ProcessForeignKeyUniquenessChanged(
}
var coveringIndex = foreignKey.DeclaringEntityType.GetIndexes()
- .FirstOrDefault(i => AreIndexedBy(foreignKey.Properties, false, i.Properties, i.IsUnique));
+ .FirstOrDefault(i => IsNonComplexCollectionIndex(i)
+ && AreIndexedBy(foreignKey.Properties, false, i.Properties, i.IsUnique));
if (coveringIndex != null)
{
RemoveIndex(index);
@@ -299,11 +315,18 @@ public virtual void ProcessIndexUniquenessChanged(
IConventionContext context)
{
var index = indexBuilder.Metadata;
+ if (!IsNonComplexCollectionIndex(index))
+ {
+ return;
+ }
+
if (index.IsUnique)
{
foreach (var otherIndex in index.DeclaringEntityType.GetDerivedTypesInclusive()
.SelectMany(t => t.GetDeclaredIndexes())
- .Where(i => i != index && AreIndexedBy(i.Properties, i.IsUnique, index.Properties, coveringIndexUnique: true))
+ .Where(i => i != index
+ && IsNonComplexCollectionIndex(i)
+ && AreIndexedBy(i.Properties, i.IsUnique, index.Properties, coveringIndexUnique: true))
.ToList())
{
RemoveIndex(otherIndex);
@@ -343,7 +366,8 @@ public virtual void ProcessIndexUniquenessChanged(
foreach (var existingIndex in entityTypeBuilder.Metadata.GetIndexes())
{
- if (AreIndexedBy(properties, unique, existingIndex.Properties, existingIndex.IsUnique))
+ if (IsNonComplexCollectionIndex(existingIndex)
+ && AreIndexedBy(properties, unique, existingIndex.Properties, existingIndex.IsUnique))
{
return null;
}
@@ -377,6 +401,16 @@ protected virtual bool AreIndexedBy(
private static void RemoveIndex(IConventionIndex index)
=> index.DeclaringEntityType.Builder.HasNoIndex(index);
+ ///
+ /// Returns whether the given index participates in the FK / index coverage logic. JSON-path indexes
+ /// (those with ) target paths inside JSON columns and
+ /// neither cover nor are covered by other indexes.
+ ///
+ /// The index to test.
+ /// if the index participates in FK / index coverage logic.
+ protected static bool IsNonComplexCollectionIndex(IConventionIndex index)
+ => index.CollectionIndices is null;
+
///
public virtual void ProcessModelFinalizing(
IConventionModelBuilder modelBuilder,
@@ -407,7 +441,8 @@ public virtual void ProcessModelFinalizing(
foreach (var existingIndex in entityType.GetIndexes())
{
- if (AreIndexedBy(
+ if (IsNonComplexCollectionIndex(existingIndex)
+ && AreIndexedBy(
declaredForeignKey.Properties, declaredForeignKey.IsUnique, existingIndex.Properties,
existingIndex.IsUnique))
{
diff --git a/src/EFCore/Metadata/IMutableEntityType.cs b/src/EFCore/Metadata/IMutableEntityType.cs
index f78a28861a3..825633e7ed5 100644
--- a/src/EFCore/Metadata/IMutableEntityType.cs
+++ b/src/EFCore/Metadata/IMutableEntityType.cs
@@ -583,7 +583,22 @@ IMutableIndex AddIndex(IMutablePropertyBase property)
///
/// The properties that are to be indexed.
/// The newly created index.
- IMutableIndex AddIndex(IReadOnlyList properties);
+ IMutableIndex AddIndex(IReadOnlyList properties)
+ => AddIndex(properties, (IReadOnlyList?>?)null);
+
+ ///
+ /// Adds an unnamed index to this entity type, optionally specifying complex-collection indices
+ /// that are part of the index identity. See .
+ ///
+ /// The properties that are to be indexed.
+ ///
+ /// The complex-collection indices traversed to reach each indexed property, or
+ /// if the index does not traverse any complex collection.
+ ///
+ /// The newly created index.
+ IMutableIndex AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices);
///
/// Adds a named index to this entity type.
@@ -600,7 +615,24 @@ IMutableIndex AddIndex(IMutablePropertyBase property, string name)
/// The properties that are to be indexed.
/// The name of the index.
/// The newly created index.
- IMutableIndex AddIndex(IReadOnlyList properties, string name);
+ IMutableIndex AddIndex(IReadOnlyList properties, string name)
+ => AddIndex(properties, null, name);
+
+ ///
+ /// Adds a named index to this entity type, optionally specifying complex-collection indices
+ /// that are part of the index identity. See .
+ ///
+ /// The properties that are to be indexed.
+ ///
+ /// The complex-collection indices traversed to reach each indexed property, or
+ /// if the index does not traverse any complex collection.
+ ///
+ /// The name of the index.
+ /// The newly created index.
+ IMutableIndex AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ string name);
///
/// Gets the index defined on the given property. Returns if no index is defined.
diff --git a/src/EFCore/Metadata/IMutableIndex.cs b/src/EFCore/Metadata/IMutableIndex.cs
index 42b3bbcda92..8ddb03241f7 100644
--- a/src/EFCore/Metadata/IMutableIndex.cs
+++ b/src/EFCore/Metadata/IMutableIndex.cs
@@ -28,6 +28,18 @@ public interface IMutableIndex : IReadOnlyIndex, IMutableAnnotatable
///
new IReadOnlyList? IsDescending { get; set; }
+ ///
+ /// Gets the complex-collection indices traversed to reach each indexed property.
+ /// See for the structure of the value.
+ ///
+ ///
+ /// Collection indices are part of the index identity and are fixed at construction time;
+ /// to define an index with different collection indices, create a new index via
+ /// (or an
+ /// overload taking collection indices).
+ ///
+ new IReadOnlyList?>? CollectionIndices { get; }
+
///
/// Gets the properties that this index is defined on.
///
diff --git a/src/EFCore/Metadata/IReadOnlyIndex.cs b/src/EFCore/Metadata/IReadOnlyIndex.cs
index 00dd1721849..b343b206996 100644
--- a/src/EFCore/Metadata/IReadOnlyIndex.cs
+++ b/src/EFCore/Metadata/IReadOnlyIndex.cs
@@ -33,6 +33,38 @@ public interface IReadOnlyIndex : IReadOnlyAnnotatable
///
IReadOnlyList? IsDescending { get; }
+ ///
+ /// Gets the complex-collection indices traversed to reach each indexed property.
+ ///
+ ///
+ ///
+ /// When non-, this list has the same length as .
+ /// Each entry corresponds to the property at the same position and is either:
+ ///
+ ///
+ ///
+ /// -
+ ///
+ /// , indicating the property is not reached through any complex collection.
+ ///
+ ///
+ /// -
+ ///
+ /// A list with one entry per complex-collection segment between the entity root and the property,
+ /// ordered outermost-first (the entry at index 0 resolves the complex collection closest to the
+ /// entity root). A entry means the index applies to all elements of that
+ /// collection (e.g. Posts.Select(p => p.Title)); a non- entry means
+ /// the index applies only to the element at that fixed position (e.g. Posts[0].Title).
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// A top-level value means no property in this index traverses any complex collection.
+ ///
+ ///
+ IReadOnlyList?>? CollectionIndices { get; }
+
///
/// Gets the entity type the index is defined on. This may be different from the type that
/// are defined on when the index is defined a derived type in an inheritance hierarchy (since the properties
diff --git a/src/EFCore/Metadata/Internal/EntityType.cs b/src/EFCore/Metadata/Internal/EntityType.cs
index 5ea8f1cc458..1ea4ee33048 100644
--- a/src/EFCore/Metadata/Internal/EntityType.cs
+++ b/src/EFCore/Metadata/Internal/EntityType.cs
@@ -28,8 +28,8 @@ private readonly SortedDictionary _skipNavigations
private readonly SortedDictionary _serviceProperties
= new(StringComparer.Ordinal);
- private readonly SortedDictionary, Index> _unnamedIndexes
- = new(PropertyListComparer.Instance);
+ private readonly SortedDictionary _unnamedIndexes
+ = new(UnnamedIndexKey.Comparer);
private readonly SortedDictionary _namedIndexes
= new(StringComparer.Ordinal);
@@ -1975,20 +1975,33 @@ public virtual IEnumerable GetDerivedReferencingSkipNavigations(
public virtual Index? AddIndex(
IReadOnlyList properties,
ConfigurationSource configurationSource)
+ => AddIndex(properties, collectionIndices: null, configurationSource);
+
+ ///
+ /// 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 virtual Index? AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ ConfigurationSource configurationSource)
{
Check.NotEmpty(properties);
Check.HasNoNulls(properties);
EnsureMutable();
- var duplicateIndex = FindIndexesInHierarchy(properties).FirstOrDefault();
+ var duplicateIndex = FindIndexesInHierarchy(properties)
+ .FirstOrDefault(i => i.Name == null && Index.CollectionIndicesEqual(i.CollectionIndices, collectionIndices));
if (duplicateIndex != null)
{
throw new InvalidOperationException(
CoreStrings.DuplicateIndex(properties.Format(), DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName()));
}
- var index = new Index(properties, this, configurationSource);
- _unnamedIndexes.Add(properties, index);
+ var index = new Index(properties, collectionIndices, this, configurationSource);
+ _unnamedIndexes.Add(new UnnamedIndexKey(index.Properties, index.CollectionIndices), index);
UpdatePropertyIndexes(properties, index);
@@ -2005,6 +2018,19 @@ public virtual IEnumerable GetDerivedReferencingSkipNavigations(
IReadOnlyList properties,
string name,
ConfigurationSource configurationSource)
+ => AddIndex(properties, collectionIndices: null, name, configurationSource);
+
+ ///
+ /// 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 virtual Index? AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ string name,
+ ConfigurationSource configurationSource)
{
Check.NotEmpty(properties);
Check.HasNoNulls(properties);
@@ -2022,7 +2048,7 @@ public virtual IEnumerable GetDerivedReferencingSkipNavigations(
duplicateIndex.DeclaringEntityType.DisplayName()));
}
- var index = new Index(properties, name, this, configurationSource);
+ var index = new Index(properties, collectionIndices, name, this, configurationSource);
_namedIndexes.Add(name, index);
UpdatePropertyIndexes(properties, index);
@@ -2068,6 +2094,22 @@ private static void UpdatePropertyIndexes(IReadOnlyList properties
return FindDeclaredIndex(properties) ?? BaseType?.FindIndex(properties);
}
+ ///
+ /// 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 virtual Index? FindIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices)
+ {
+ Check.HasNoNulls(properties);
+ Check.NotEmpty(properties);
+
+ return FindDeclaredIndex(properties, collectionIndices) ?? BaseType?.FindIndex(properties, collectionIndices);
+ }
+
///
/// 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
@@ -2110,7 +2152,18 @@ public virtual IEnumerable GetDerivedIndexes()
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public virtual Index? FindDeclaredIndex(IReadOnlyList properties)
- => _unnamedIndexes.GetValueOrDefault(Check.NotEmpty(properties));
+ => _unnamedIndexes.GetValueOrDefault(new UnnamedIndexKey(Check.NotEmpty(properties)));
+
+ ///
+ /// 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 virtual Index? FindDeclaredIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices)
+ => _unnamedIndexes.GetValueOrDefault(new UnnamedIndexKey(Check.NotEmpty(properties), collectionIndices));
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -2133,6 +2186,21 @@ public virtual IEnumerable FindDerivedIndexes(IReadOnlyList)GetDerivedTypes()
.Select(et => et.FindDeclaredIndex(properties)).Where(i => i != null);
+ ///
+ /// 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 virtual IEnumerable FindDerivedIndexes(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices)
+ => DirectlyDerivedTypes.Count == 0
+ ? []
+ : (IEnumerable)GetDerivedTypes()
+ .Select(et => et.FindDeclaredIndex(properties, collectionIndices))
+ .Where(i => i != null);
+
///
/// 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
@@ -2213,7 +2281,7 @@ public virtual IEnumerable FindIndexesInHierarchy(string name)
if (index.Name == null)
{
- if (!_unnamedIndexes.Remove(index.Properties))
+ if (!_unnamedIndexes.Remove(new UnnamedIndexKey(index.Properties, index.CollectionIndices)))
{
throw new InvalidOperationException(
CoreStrings.IndexWrongType(index.DisplayName(), DisplayName(), index.DeclaringEntityType.DisplayName()));
@@ -3929,6 +3997,38 @@ IMutableIndex IMutableEntityType.AddIndex(IReadOnlyList pr
IMutableIndex IMutableEntityType.AddIndex(IReadOnlyList properties, string name)
=> AddIndex(properties as IReadOnlyList ?? properties.Cast().ToList(), name, ConfigurationSource.Explicit)!;
+ ///
+ /// 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.
+ ///
+ [DebuggerStepThrough]
+ IMutableIndex IMutableEntityType.AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices)
+ => AddIndex(
+ properties as IReadOnlyList ?? properties.Cast().ToList(),
+ collectionIndices,
+ ConfigurationSource.Explicit)!;
+
+ ///
+ /// 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.
+ ///
+ [DebuggerStepThrough]
+ IMutableIndex IMutableEntityType.AddIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ string name)
+ => AddIndex(
+ properties as IReadOnlyList ?? properties.Cast().ToList(),
+ collectionIndices,
+ name,
+ ConfigurationSource.Explicit)!;
+
///
/// 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
diff --git a/src/EFCore/Metadata/Internal/Index.cs b/src/EFCore/Metadata/Internal/Index.cs
index 9202f980563..ac3c45f603f 100644
--- a/src/EFCore/Metadata/Internal/Index.cs
+++ b/src/EFCore/Metadata/Internal/Index.cs
@@ -16,6 +16,7 @@ public class Index : ConventionAnnotatable, IMutableIndex, IConventionIndex, IIn
{
private bool? _isUnique;
private IReadOnlyList? _isDescending;
+ private readonly IReadOnlyList?>? _collectionIndices;
private InternalIndexBuilder? _builder;
@@ -65,6 +66,77 @@ public Index(
_builder = new InternalIndexBuilder(this, declaringEntityType.Model.Builder);
}
+ ///
+ /// 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 Index(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ EntityType declaringEntityType,
+ ConfigurationSource configurationSource)
+ : this(properties, declaringEntityType, configurationSource)
+ => _collectionIndices = NormalizeCollectionIndices(properties, collectionIndices);
+
+ private static IReadOnlyList?>? NormalizeCollectionIndices(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices)
+ {
+ if (collectionIndices is null)
+ {
+ return null;
+ }
+
+ if (collectionIndices.Count != properties.Count)
+ {
+ throw new ArgumentException(
+ CoreStrings.InvalidNumberOfIndexCollectionIndices(
+ properties.Format(), collectionIndices.Count, properties.Count),
+ nameof(collectionIndices));
+ }
+
+ for (var i = 0; i < properties.Count; i++)
+ {
+ var entry = collectionIndices[i];
+ var expectedCount = CountComplexCollectionsInPath(properties[i]);
+ var actualCount = entry?.Count ?? 0;
+ if (actualCount != expectedCount)
+ {
+ throw new ArgumentException(
+ CoreStrings.InvalidCollectionIndicesEntryLength(
+ properties[i].Name, properties.Format(), actualCount, expectedCount),
+ nameof(collectionIndices));
+ }
+ }
+
+ // Normalize all-null entries to a null top-level value.
+ return collectionIndices.All(static entry => entry is null) ? null : collectionIndices;
+ }
+
+ private static int CountComplexCollectionsInPath(IReadOnlyPropertyBase property)
+ {
+ var count = 0;
+ if (property is IReadOnlyComplexProperty { IsCollection: true })
+ {
+ count++;
+ }
+
+ var declaringType = property.DeclaringType;
+ while (declaringType is IReadOnlyComplexType complexType)
+ {
+ if (complexType.ComplexProperty.IsCollection)
+ {
+ count++;
+ }
+
+ declaringType = complexType.ComplexProperty.DeclaringType;
+ }
+
+ return count;
+ }
+
///
/// 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
@@ -79,6 +151,21 @@ public Index(
: this(properties, declaringEntityType, configurationSource)
=> Name = name;
+ ///
+ /// 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 Index(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ string name,
+ EntityType declaringEntityType,
+ ConfigurationSource configurationSource)
+ : this(properties, collectionIndices, declaringEntityType, configurationSource)
+ => Name = name;
+
///
/// 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
@@ -296,6 +383,29 @@ private static readonly bool[]? DefaultIsDescending
private void UpdateIsDescendingConfigurationSource(ConfigurationSource configurationSource)
=> _isDescendingConfigurationSource = configurationSource.Max(_isDescendingConfigurationSource);
+ ///
+ /// 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 virtual IReadOnlyList?>? CollectionIndices
+ {
+ [DebuggerStepThrough]
+ get => _collectionIndices;
+ }
+
+ ///
+ /// 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 static bool CollectionIndicesEqual(
+ IReadOnlyList?>? left,
+ IReadOnlyList?>? right)
+ => UnnamedIndexKey.CollectionIndicesEqual(left, right);
+
///
/// Runs the conventions when an annotation was set or removed.
///
diff --git a/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs b/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs
index a167820f377..c44ed65cd61 100644
--- a/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs
+++ b/src/EFCore/Metadata/Internal/InternalEntityTypeBuilder.cs
@@ -1641,10 +1641,11 @@ public virtual bool CanSetQueryFilter(QueryFilter queryFilter)
var shouldBeDetached = false;
foreach (var property in index.Properties)
{
- if (property is Property primitive
- && removedInheritedProperties.Contains(primitive))
+ if (property is Property scalarProperty
+ && property.DeclaringType is EntityType
+ && removedInheritedProperties.Contains(scalarProperty))
{
- removedInheritedPropertiesToDuplicate.Add(primitive);
+ removedInheritedPropertiesToDuplicate.Add(scalarProperty);
shouldBeDetached = true;
}
}
@@ -2103,7 +2104,7 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public virtual InternalIndexBuilder? HasIndex(IReadOnlyList propertyNames, ConfigurationSource configurationSource)
- => HasIndex(ToPropertyBaseList(GetOrCreateProperties(propertyNames, configurationSource)), configurationSource);
+ => HasIndex(propertyNames, name: null, configurationSource);
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -2113,9 +2114,21 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
///
public virtual InternalIndexBuilder? HasIndex(
IReadOnlyList propertyNames,
- string name,
+ string? name,
ConfigurationSource configurationSource)
- => HasIndex(ToPropertyBaseList(GetOrCreateProperties(propertyNames, configurationSource)), name, configurationSource);
+ {
+ var parsed = MatchComplexPathList(propertyNames);
+ if (parsed is null)
+ {
+ return null;
+ }
+
+ var (names, isCollection, collectionIndices) = parsed.Value;
+ var properties = GetOrCreateProperties(names, isCollection, configurationSource);
+ return properties is null
+ ? null
+ : HasIndex(properties, collectionIndices, name, configurationSource);
+ }
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -2128,17 +2141,6 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
ConfigurationSource configurationSource)
=> HasIndex(ToPropertyBaseList(GetOrCreateProperties(clrMembers, configurationSource)), configurationSource);
- ///
- /// 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 virtual InternalIndexBuilder? HasIndex(
- IReadOnlyList> memberChains,
- ConfigurationSource configurationSource)
- => HasIndex(GetOrCreatePropertyBases(memberChains, configurationSource), configurationSource);
-
///
/// 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
@@ -2158,10 +2160,9 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
/// doing so can result in application failures when updating to a new Entity Framework Core release.
///
public virtual InternalIndexBuilder? HasIndex(
- IReadOnlyList> memberChains,
- string name,
+ IReadOnlyList? properties,
ConfigurationSource configurationSource)
- => HasIndex(GetOrCreatePropertyBases(memberChains, configurationSource), name, configurationSource);
+ => HasIndex(properties, name: null, configurationSource);
///
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -2171,70 +2172,38 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
///
public virtual InternalIndexBuilder? HasIndex(
IReadOnlyList? properties,
+ string? name,
ConfigurationSource configurationSource)
{
- if (properties == null)
+ if (name is not null)
{
- return null;
+ Check.NotEmpty(name);
}
- List? detachedIndexes = null;
- var existingIndex = Metadata.FindIndex(properties);
- if (existingIndex == null)
- {
- detachedIndexes = Metadata.FindDerivedIndexes(properties).ToList().Select(DetachIndex).ToList();
- }
- else if (existingIndex.DeclaringEntityType != Metadata)
- {
- return existingIndex.DeclaringEntityType.Builder.HasIndex(existingIndex, properties, null, configurationSource);
- }
-
- var indexBuilder = HasIndex(existingIndex, properties, null, configurationSource);
-
- if (detachedIndexes != null)
- {
- foreach (var detachedIndex in detachedIndexes)
- {
- detachedIndex.Attach(detachedIndex.Metadata.DeclaringEntityType.Builder);
- }
- }
-
- return indexBuilder;
- }
-
- ///
- /// 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 virtual InternalIndexBuilder? HasIndex(
- IReadOnlyList? properties,
- string name,
- ConfigurationSource configurationSource)
- {
- Check.NotEmpty(name);
-
if (properties == null)
{
return null;
}
- List? detachedIndexes = null;
+ var existingIndex = name is null
+ ? Metadata.FindIndex(properties)
+ : Metadata.FindIndex(name);
- var existingIndex = Metadata.FindIndex(name);
- if (existingIndex != null
+ if (existingIndex is not null
+ && name is not null
&& !existingIndex.Properties.SequenceEqual(properties))
{
- // use existing index only if properties match
+ // use existing named index only if properties match
existingIndex = null;
}
+ List? detachedIndexes = null;
if (existingIndex == null)
{
- detachedIndexes = Metadata.FindDerivedIndexes(name)
- .Where(i => i.Properties.SequenceEqual(properties))
- .ToList().Select(DetachIndex).ToList();
+ var derived = name is null
+ ? Metadata.FindDerivedIndexes(properties)
+ : Metadata.FindDerivedIndexes(name).Where(i => i.Properties.SequenceEqual(properties));
+ detachedIndexes = derived.ToList().Select(DetachIndex).ToList();
}
else if (existingIndex.DeclaringEntityType != Metadata)
{
@@ -2300,6 +2269,75 @@ private void RemoveKeyIfUnused(Key key, ConfigurationSource configurationSource
ConfigurationSource configurationSource)
=> HasIndex(ToPropertyBaseList(properties), name, configurationSource);
+ ///
+ /// 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.
+ ///
+ ///
+ /// Configures an index whose leaves may traverse complex properties (including complex collections).
+ /// runs parallel to , holding the
+ /// constant indexer values for each complex-collection segment on the path to each leaf.
+ ///
+ public virtual InternalIndexBuilder? HasIndex(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices,
+ string? name,
+ ConfigurationSource configurationSource)
+ {
+ // Walk the hierarchy looking for an existing index that exactly matches (properties, CollectionIndices),
+ // which together form the unnamed-index identity.
+ var existingIndex = name is null
+ ? Metadata.FindIndex(properties, collectionIndices)
+ : Metadata.FindIndex(name);
+
+ if (existingIndex is not null
+ && name is not null
+ && (!existingIndex.Properties.SequenceEqual(properties)
+ || !Index.CollectionIndicesEqual(existingIndex.CollectionIndices, collectionIndices)))
+ {
+ throw new InvalidOperationException(
+ CoreStrings.ConflictingNamedIndex(
+ name,
+ Metadata.DisplayName(),
+ properties.Format()));
+ }
+
+ if (existingIndex is not null)
+ {
+ existingIndex.UpdateConfigurationSource(configurationSource);
+ return existingIndex.Builder;
+ }
+
+ // No matching index in the hierarchy. Detach equivalent indexes on derived types so they can be
+ // promoted to this type.
+ List? detachedIndexes = null;
+ var derivedCandidates = name is null
+ ? Metadata.FindDerivedIndexes(properties, collectionIndices)
+ : Metadata.FindDerivedIndexes(name).Where(i => i.Properties.SequenceEqual(properties)
+ && Index.CollectionIndicesEqual(i.CollectionIndices, collectionIndices));
+ var derivedToDetach = derivedCandidates.ToList();
+ if (derivedToDetach.Count > 0)
+ {
+ detachedIndexes = derivedToDetach.Select(DetachIndex).ToList();
+ }
+
+ var index = name is null
+ ? Metadata.AddIndex(properties, collectionIndices, configurationSource)
+ : Metadata.AddIndex(properties, collectionIndices, name, configurationSource);
+
+ if (detachedIndexes is not null)
+ {
+ foreach (var detachedIndex in detachedIndexes)
+ {
+ detachedIndex.Attach(detachedIndex.Metadata.DeclaringEntityType.Builder);
+ }
+ }
+
+ return index?.Builder;
+ }
+
///
/// 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
@@ -2341,9 +2379,7 @@ public virtual bool CanHaveIndex(
return null;
}
- var removedIndex = index.Name == null
- ? Metadata.RemoveIndex(index.Properties)
- : Metadata.RemoveIndex(index.Name);
+ var removedIndex = Metadata.RemoveIndex(index);
Check.DebugAssert(removedIndex == index, "removedIndex != index");
RemoveUnusedImplicitProperties(index.Properties.OfType().ToList());
diff --git a/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs b/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs
index 089488a89eb..616b4e91aa8 100644
--- a/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs
+++ b/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs
@@ -85,15 +85,34 @@ public virtual bool CanSetIsDescending(IReadOnlyList? descending, Configur
///
public virtual InternalIndexBuilder? Attach(InternalEntityTypeBuilder entityTypeBuilder)
{
- var properties = entityTypeBuilder.GetActualProperties(Metadata.Properties, null);
- if (properties == null)
+ var configurationSource = Metadata.GetConfigurationSource();
+ InternalIndexBuilder? newIndexBuilder;
+
+ // If the index targets complex / chained properties or carries collection indices, we can't
+ // simply re-resolve PropertyBase instances at the entity-type level — the leaves may live
+ // inside complex types that were themselves rebuilt during detach. Reconstruct the segment
+ // lists plus per-leaf collection indices from the original Index and go through the
+ // HasIndex overload that resolves the chain against the current model.
+ if (RequiresComplexReattach(Metadata, out var namesPerLeaf, out var isCollection, out var collectionIndices))
{
- return null;
+ var properties = entityTypeBuilder.GetOrCreateProperties(namesPerLeaf, isCollection, configurationSource);
+ newIndexBuilder = properties is null
+ ? null
+ : entityTypeBuilder.HasIndex(properties, collectionIndices, Metadata.Name, configurationSource);
+ }
+ else
+ {
+ var properties = entityTypeBuilder.GetActualProperties(Metadata.Properties, null);
+ if (properties == null)
+ {
+ return null;
+ }
+
+ newIndexBuilder = Metadata.Name == null
+ ? entityTypeBuilder.HasIndex(properties, configurationSource)
+ : entityTypeBuilder.HasIndex(properties, Metadata.Name, configurationSource);
}
- var newIndexBuilder = Metadata.Name == null
- ? entityTypeBuilder.HasIndex(properties, Metadata.GetConfigurationSource())
- : entityTypeBuilder.HasIndex(properties, Metadata.Name, Metadata.GetConfigurationSource());
newIndexBuilder?.MergeAnnotationsFrom(Metadata);
var isUniqueConfigurationSource = Metadata.GetIsUniqueConfigurationSource();
@@ -105,6 +124,77 @@ public virtual bool CanSetIsDescending(IReadOnlyList? descending, Configur
return newIndexBuilder;
}
+ private static bool RequiresComplexReattach(
+ Index index,
+ out IReadOnlyList> namesPerLeaf,
+ out IReadOnlyList>? isCollection,
+ out IReadOnlyList?>? collectionIndices)
+ {
+ var indexCollectionIndices = index.CollectionIndices;
+ var properties = index.Properties;
+ var propertyCount = properties.Count;
+
+ var anyComplexChain = false;
+ for (var i = 0; i < propertyCount; i++)
+ {
+ if (properties[i].DeclaringType is ComplexType)
+ {
+ anyComplexChain = true;
+ break;
+ }
+ }
+
+ if (!anyComplexChain && indexCollectionIndices is null)
+ {
+ namesPerLeaf = [];
+ isCollection = null;
+ collectionIndices = null;
+ return false;
+ }
+
+ var chains = new IReadOnlyList[propertyCount];
+ var allFlags = new IReadOnlyList[propertyCount];
+
+ for (var i = 0; i < propertyCount; i++)
+ {
+ var property = properties[i];
+
+ // Measure the chain depth first so we can size arrays exactly and fill in reverse order,
+ // avoiding the cost of List<>.Reverse() and List<> capacity doubling.
+ var depth = 0;
+ var declaringType = property.DeclaringType;
+ while (declaringType is ComplexType walking)
+ {
+ depth++;
+ declaringType = walking.ComplexProperty.DeclaringType;
+ }
+
+ var chainNames = new string[depth + 1];
+ var chainFlags = new bool[depth + 1];
+ chainNames[depth] = property.Name;
+ // The leaf entry of chainFlags stays false: the indexed leaf is the property itself,
+ // not a collection-traversal step on the way to it.
+ declaringType = property.DeclaringType;
+ for (var pos = depth - 1; pos >= 0; pos--)
+ {
+ var complexType = (ComplexType)declaringType!;
+ chainNames[pos] = complexType.ComplexProperty.Name;
+ chainFlags[pos] = complexType.ComplexProperty.IsCollection;
+ declaringType = complexType.ComplexProperty.DeclaringType;
+ }
+
+ chains[i] = chainNames;
+ allFlags[i] = chainFlags;
+ }
+
+ namesPerLeaf = chains;
+ // We're already on the slow path (anyComplexChain is true), so always emit the flag list so the
+ // consumer can reconstruct each chain with the correct collection / non-collection structure.
+ isCollection = allFlags;
+ collectionIndices = indexCollectionIndices;
+ return true;
+ }
+
///
/// 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
diff --git a/src/EFCore/Metadata/Internal/InternalTypeBaseBuilder.cs b/src/EFCore/Metadata/Internal/InternalTypeBaseBuilder.cs
index dffa4522d5b..f289897cd41 100644
--- a/src/EFCore/Metadata/Internal/InternalTypeBaseBuilder.cs
+++ b/src/EFCore/Metadata/Internal/InternalTypeBaseBuilder.cs
@@ -672,37 +672,319 @@ public virtual (bool, IReadOnlyList?) TryCreateUniqueProperties(
/// 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 virtual IReadOnlyList? GetOrCreatePropertyBases(
- IReadOnlyList>? memberChains,
+ ///
+ /// Resolves one per chain, walking intermediate complex properties
+ /// (creating them when missing) and resolving the leaf as either an existing complex property or
+ /// a get-or-created scalar property.
+ /// describes, for each chain, whether each member of the chain
+ /// (including the leaf) is reached as a collection; it is
+ /// when no chain traverses a complex collection.
+ ///
+ public virtual IReadOnlyList? GetOrCreateProperties(
+ IReadOnlyList> memberChains,
+ IReadOnlyList>? isCollection,
ConfigurationSource? configurationSource)
{
- if (memberChains == null)
+ var properties = new List(memberChains.Count);
+ for (var memberIndex = 0; memberIndex < memberChains.Count; memberIndex++)
+ {
+ var chain = memberChains[memberIndex];
+ if (chain.Count == 0)
+ {
+ return null;
+ }
+
+ var chainIsCollection = isCollection?[memberIndex];
+ Check.DebugAssert(
+ chainIsCollection is null || chainIsCollection.Count == chain.Count,
+ $"isCollection length {chainIsCollection?.Count} doesn't match chain length {chain.Count}.");
+
+ var currentBuilder = this;
+ for (var i = 0; i < chain.Count - 1; i++)
+ {
+ var complexBuilder = currentBuilder.ComplexProperty(
+ chain[i].ResolveMemberForType(currentBuilder.Metadata.ClrType), complexTypeName: null,
+ collection: chainIsCollection?[i] ?? false, configurationSource);
+ if (complexBuilder is null)
+ {
+ return null;
+ }
+
+ currentBuilder = complexBuilder.Metadata.ComplexType.Builder;
+ }
+
+ var leafMember = chain[^1].ResolveMemberForType(currentBuilder.Metadata.ClrType);
+ var existing = currentBuilder.Metadata.FindMember(leafMember.GetSimpleMemberName());
+ if (existing is ComplexProperty complexProperty)
+ {
+ properties.Add(complexProperty);
+ continue;
+ }
+
+ var propertyBuilder = currentBuilder.Property(leafMember, configurationSource);
+ if (propertyBuilder is null)
+ {
+ return null;
+ }
+
+ properties.Add(propertyBuilder.Metadata);
+ }
+
+ return properties;
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Resolves one per name chain. Behaves like the
+ /// -based overload but resolves intermediate members by name.
+ ///
+ public virtual IReadOnlyList? GetOrCreateProperties(
+ IReadOnlyList> propertyPaths,
+ IReadOnlyList>? isCollection,
+ ConfigurationSource? configurationSource)
+ {
+ var properties = new List(propertyPaths.Count);
+ for (var leafIndex = 0; leafIndex < propertyPaths.Count; leafIndex++)
+ {
+ var names = propertyPaths[leafIndex];
+ if (names.Count == 0)
+ {
+ return null;
+ }
+
+ var chainIsCollection = isCollection?[leafIndex];
+ Check.DebugAssert(
+ chainIsCollection is null || chainIsCollection.Count == names.Count,
+ $"isCollection length {chainIsCollection?.Count} doesn't match chain length {names.Count}.");
+
+ var currentBuilder = this;
+ for (var i = 0; i < names.Count - 1; i++)
+ {
+ // Use FindMember (this type + base types) rather than FindMembersInHierarchy: a property
+ // declared on a derived type isn't reachable from `this` and can't be used in an index/key
+ // defined on `this`. Falls back to reflection on the CLR type when there is no model member
+ // yet (e.g. shared-type / shadow entities), then materializes the complex property.
+ var existingMember = currentBuilder.Metadata.FindMember(names[i]);
+ if (existingMember is ComplexProperty existingComplex)
+ {
+ currentBuilder = existingComplex.ComplexType.Builder;
+ continue;
+ }
+
+ var memberInfo = currentBuilder.Metadata.ClrType.GetMembersInHierarchy(names[i]).FirstOrDefault();
+ if (memberInfo is null)
+ {
+ return null;
+ }
+
+ var complexBuilder = currentBuilder.ComplexProperty(
+ propertyType: null, names[i], memberInfo, complexTypeName: null,
+ complexType: null, collection: chainIsCollection?[i] ?? false, configurationSource);
+ if (complexBuilder is null)
+ {
+ return null;
+ }
+
+ currentBuilder = complexBuilder.Metadata.ComplexType.Builder;
+ }
+
+ var leafName = names[^1];
+ var existing = currentBuilder.Metadata.FindMember(leafName);
+ if (existing is ComplexProperty leafComplex)
+ {
+ properties.Add(leafComplex);
+ continue;
+ }
+
+ var leafProperty = currentBuilder.Property(leafName, configurationSource);
+ if (leafProperty is null)
+ {
+ return null;
+ }
+
+ properties.Add(leafProperty.Metadata);
+ }
+
+ return properties;
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ ///
+ /// Parses a dotted property path that may include complex-collection indexer tokens, e.g.
+ /// "Posts[].Title" / "Posts[*].Title" (all elements) or "Posts[0].Title"
+ /// (fixed element); a leaf may also carry a bracket (e.g. "Posts[]") to indicate the leaf
+ /// itself is a complex collection.
+ ///
+ ///
+ /// MemberNames contains one entry per dotted segment. IsCollection runs parallel to
+ /// MemberNames (length equal to MemberNames.Count): at a
+ /// given position means the corresponding member was reached as a complex-collection traversal
+ /// (i.e. carried a bracket token). CollectionIndices has one entry per bracket token in
+ /// the path — ordered to match the entries in IsCollection;
+ /// means "all elements" ([] or [*]) and a non-
+ /// means the fixed element index. The top-level
+ /// CollectionIndices is itself when no segment uses a bracket.
+ ///
+ ///
+ /// Returns when the path is empty, whitespace, or otherwise malformed.
+ ///
+ ///
+ public static (IReadOnlyList MemberNames,
+ IReadOnlyList IsCollection,
+ IReadOnlyList? CollectionIndices)?
+ MatchComplexPath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
{
return null;
}
- var list = new List(memberChains.Count);
- foreach (var memberChain in memberChains)
+ var names = new List();
+ var collectionFlags = new List();
+ var indices = new List();
+ var hasBrackets = false;
+
+ foreach (var rawSegment in path.Split('.'))
{
- var (ownerBuilder, finalMember) = ResolveComplexChain(memberChain);
- var existing = ownerBuilder.Metadata.FindMembersInHierarchy(finalMember.GetSimpleMemberName())
- .FirstOrDefault();
- if (existing is ComplexProperty complexProperty)
+ var segment = rawSegment.Trim();
+ var bracketStart = segment.IndexOf('[');
+ if (bracketStart < 0)
{
- list.Add(complexProperty);
+ if (segment.Length == 0)
+ {
+ return null;
+ }
+
+ names.Add(segment);
+ collectionFlags.Add(false);
continue;
}
- var propertyBuilder = ownerBuilder.Property(finalMember, configurationSource);
- if (propertyBuilder == null)
+ if (!segment.EndsWith(']') || bracketStart == 0)
{
return null;
}
- list.Add(propertyBuilder.Metadata);
+ var memberName = segment.Substring(0, bracketStart).Trim();
+ if (memberName.Length == 0)
+ {
+ return null;
+ }
+
+ // Only a single trailing `[...]` is supported per dotted segment: nested forms such as
+ // `Name[0][1]` fall through to the int.TryParse below (on "0][1") and are rejected there.
+ var inner = segment.Substring(bracketStart + 1, segment.Length - bracketStart - 2).Trim();
+ int? index;
+ if (inner.Length == 0 || inner == "*")
+ {
+ index = null;
+ }
+ else if (int.TryParse(inner, NumberStyles.Integer, CultureInfo.InvariantCulture, out var b) && b >= 0)
+ {
+ index = b;
+ }
+ else
+ {
+ return null;
+ }
+
+ names.Add(memberName);
+ collectionFlags.Add(true);
+ indices.Add(index);
+ hasBrackets = true;
}
- return list;
+ return (names, collectionFlags, hasBrackets ? indices : null);
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Parses each string path via and aggregates the per-chain results.
+ /// Returns if any path is malformed. Each per-chain entry follows the shape
+ /// described on . The top-level IsCollection is
+ /// only when every parsed chain is a single non-collection member; the
+ /// top-level CollectionIndices is when no path uses a bracket token.
+ ///
+ public static (IReadOnlyList> Names,
+ IReadOnlyList>? IsCollection,
+ IReadOnlyList?>? CollectionIndices)?
+ MatchComplexPathList(IReadOnlyList propertyNames)
+ {
+ var names = new List>(propertyNames.Count);
+ var isCollection = new List>(propertyNames.Count);
+ var indices = new List?>(propertyNames.Count);
+ var anyIndices = false;
+ var anyComplexChain = false;
+
+ foreach (var path in propertyNames)
+ {
+ var parsed = MatchComplexPath(path);
+ if (parsed is null)
+ {
+ return null;
+ }
+
+ var (chainNames, chainIsCollection, chainIndices) = parsed.Value;
+ names.Add(chainNames);
+ isCollection.Add(chainIsCollection);
+ indices.Add(chainIndices);
+ if (ContainsMultipleOrTrue(chainIsCollection))
+ {
+ anyComplexChain = true;
+ }
+
+ if (chainIndices is not null)
+ {
+ anyIndices = true;
+ }
+ }
+
+ return (names, anyComplexChain ? isCollection : null, anyIndices ? indices : null);
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Returns whether the given per-member IsCollection flags describe a chain with complex-property
+ /// structure — i.e. more than one member, or a single member that is itself a complex collection.
+ /// Single non-collection members are entity-level scalar leaves whose flag list can be omitted.
+ ///
+ public static bool ContainsMultipleOrTrue(IReadOnlyList flags)
+ {
+ if (flags.Count > 1)
+ {
+ return true;
+ }
+
+ for (var i = 0; i < flags.Count; i++)
+ {
+ if (flags[i])
+ {
+ return true;
+ }
+ }
+
+ return false;
}
///
diff --git a/src/EFCore/Metadata/Internal/UnnamedIndexKey.cs b/src/EFCore/Metadata/Internal/UnnamedIndexKey.cs
new file mode 100644
index 00000000000..b48d16f8883
--- /dev/null
+++ b/src/EFCore/Metadata/Internal/UnnamedIndexKey.cs
@@ -0,0 +1,215 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.EntityFrameworkCore.Metadata.Internal;
+
+///
+/// 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 readonly struct UnnamedIndexKey : IEquatable
+{
+ ///
+ /// 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 UnnamedIndexKey(
+ IReadOnlyList properties,
+ IReadOnlyList?>? collectionIndices = null)
+ {
+ Properties = properties;
+ CollectionIndices = collectionIndices;
+ }
+
+ ///
+ /// 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 IReadOnlyList Properties { get; }
+
+ ///
+ /// 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 IReadOnlyList?>? CollectionIndices { get; }
+
+ ///
+ public bool Equals(UnnamedIndexKey other)
+ => Comparer.Compare(this, other) == 0;
+
+ ///
+ public override bool Equals(object? obj)
+ => obj is UnnamedIndexKey other && Equals(other);
+
+ ///
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ foreach (var property in Properties)
+ {
+ hash.Add(property);
+ }
+
+ if (CollectionIndices is null)
+ {
+ return hash.ToHashCode();
+ }
+
+ foreach (var entry in CollectionIndices)
+ {
+ if (entry is null)
+ {
+ hash.Add(0);
+ continue;
+ }
+
+ foreach (var value in entry)
+ {
+ hash.Add(value);
+ }
+ }
+
+ return hash.ToHashCode();
+ }
+
+ ///
+ /// 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 static readonly UnnamedIndexKeyComparer Comparer = new();
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Returns a negative value when sorts before , zero when
+ /// they are equal, and a positive value otherwise. sorts before any non-null
+ /// value so that "plain" indexes (no collection traversal) come first.
+ ///
+ public static int CompareCollectionIndices(
+ IReadOnlyList?>? x,
+ IReadOnlyList?>? y)
+ {
+ if (ReferenceEquals(x, y))
+ {
+ return 0;
+ }
+
+ if (x is null)
+ {
+ return -1;
+ }
+
+ if (y is null)
+ {
+ return 1;
+ }
+
+ var countDiff = x.Count - y.Count;
+ if (countDiff != 0)
+ {
+ return countDiff;
+ }
+
+ for (var i = 0; i < x.Count; i++)
+ {
+ var innerResult = CompareInner(x[i], y[i]);
+ if (innerResult != 0)
+ {
+ return innerResult;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Equivalent to (left, right) == 0; provided as a
+ /// dedicated entry point for hot paths that only need an equality answer.
+ ///
+ public static bool CollectionIndicesEqual(
+ IReadOnlyList?>? left,
+ IReadOnlyList?>? right)
+ => CompareCollectionIndices(left, right) == 0;
+
+ private static int CompareInner(IReadOnlyList? x, IReadOnlyList? y)
+ {
+ if (ReferenceEquals(x, y))
+ {
+ return 0;
+ }
+
+ if (x is null)
+ {
+ return -1;
+ }
+
+ if (y is null)
+ {
+ return 1;
+ }
+
+ var countDiff = x.Count - y.Count;
+ if (countDiff != 0)
+ {
+ return countDiff;
+ }
+
+ for (var i = 0; i < x.Count; i++)
+ {
+ var a = x[i];
+ var b = y[i];
+ if (a is null != b is null)
+ {
+ return a is null ? -1 : 1;
+ }
+
+ if (a is not null && b is not null && a.Value != b.Value)
+ {
+ return a.Value < b.Value ? -1 : 1;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// 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 sealed class UnnamedIndexKeyComparer : IComparer
+ {
+ ///
+ public int Compare(UnnamedIndexKey x, UnnamedIndexKey y)
+ {
+ var result = PropertyListComparer.Instance.Compare(x.Properties, y.Properties);
+ if (result != 0)
+ {
+ return result;
+ }
+
+ return CompareCollectionIndices(x.CollectionIndices, y.CollectionIndices);
+ }
+ }
+}
diff --git a/src/EFCore/Metadata/RuntimeEntityType.cs b/src/EFCore/Metadata/RuntimeEntityType.cs
index 9bdf6e7e41c..17d16a1d17d 100644
--- a/src/EFCore/Metadata/RuntimeEntityType.cs
+++ b/src/EFCore/Metadata/RuntimeEntityType.cs
@@ -530,13 +530,18 @@ public virtual IEnumerable FindSkipNavigationsInHierarchy
/// The properties that are to be indexed.
/// The name of the index.
/// A value indicating whether the values assigned to the indexed properties are unique.
+ ///
+ /// The complex-collection indices traversed to reach each indexed property, or
+ /// if the index does not traverse any complex collection. See .
+ ///
/// The newly created index.
public virtual RuntimeIndex AddIndex(
IReadOnlyList properties,
string? name = null,
- bool unique = false)
+ bool unique = false,
+ IReadOnlyList?>? collectionIndices = null)
{
- var index = new RuntimeIndex(properties, this, name, unique);
+ var index = new RuntimeIndex(properties, this, name, unique, collectionIndices);
if (name != null)
{
(_namedIndexes ??= new Utilities.OrderedDictionary(StringComparer.Ordinal)).Add(name, index);
diff --git a/src/EFCore/Metadata/RuntimeIndex.cs b/src/EFCore/Metadata/RuntimeIndex.cs
index 5fc710b6c92..7977f4d56a9 100644
--- a/src/EFCore/Metadata/RuntimeIndex.cs
+++ b/src/EFCore/Metadata/RuntimeIndex.cs
@@ -15,6 +15,7 @@ namespace Microsoft.EntityFrameworkCore.Metadata;
public class RuntimeIndex : RuntimeAnnotatableBase, IIndex
{
private readonly bool _isUnique;
+ private readonly IReadOnlyList?>? _collectionIndices;
// Warning: Never access these fields directly as access needs to be thread-safe
private object? _nullableValueFactory;
@@ -30,12 +31,14 @@ public RuntimeIndex(
IReadOnlyList properties,
RuntimeEntityType declaringEntityType,
string? name,
- bool unique)
+ bool unique,
+ IReadOnlyList?>? collectionIndices)
{
Properties = properties;
Name = name;
DeclaringEntityType = declaringEntityType;
_isUnique = unique;
+ _collectionIndices = collectionIndices;
}
///
@@ -64,6 +67,15 @@ IReadOnlyList IReadOnlyIndex.IsDescending
get => throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData);
}
+ ///
+ /// Gets the complex-collection indices traversed to reach each indexed property.
+ ///
+ IReadOnlyList?>? IReadOnlyIndex.CollectionIndices
+ {
+ [DebuggerStepThrough]
+ get => _collectionIndices;
+ }
+
///
/// Returns a string that represents the current object.
///
diff --git a/src/EFCore/Properties/CoreStrings.Designer.cs b/src/EFCore/Properties/CoreStrings.Designer.cs
index 1ffe01d3bc6..a44778e0c02 100644
--- a/src/EFCore/Properties/CoreStrings.Designer.cs
+++ b/src/EFCore/Properties/CoreStrings.Designer.cs
@@ -868,6 +868,14 @@ public static string ConflictingForeignKeyAttributes(object? propertyList, objec
GetString("ConflictingForeignKeyAttributes", nameof(propertyList), nameof(entityType), nameof(principalEntityType)),
propertyList, entityType, principalEntityType);
+ ///
+ /// The index named '{indexName}' on entity type '{entityType}' cannot be configured on properties {propertyList} because an index with the same name has already been defined on different properties or with different complex-collection indices. Use a different name for this index.
+ ///
+ public static string ConflictingNamedIndex(object? indexName, object? entityType, object? propertyList)
+ => string.Format(
+ GetString("ConflictingNamedIndex", nameof(indexName), nameof(entityType), nameof(propertyList)),
+ indexName, entityType, propertyList);
+
///
/// The entity type '{entity}' has both [Keyless] and [PrimaryKey] attributes; one must be removed.
///
@@ -1727,14 +1735,6 @@ public static string IndexOnComplexProperty(object? indexProperties, object? ent
GetString("IndexOnComplexProperty", nameof(indexProperties), nameof(entityType), nameof(property)),
indexProperties, entityType, property);
- ///
- /// A value factory cannot be created for the index {indexProperties} on the entity type '{entityType}' because it contains the complex property '{property}'. Index value factories are not supported for indexes that contain complex properties.
- ///
- public static string IndexValueFactoryWithComplexProperty(object? indexProperties, object? entityType, object? property)
- => string.Format(
- GetString("IndexValueFactoryWithComplexProperty", nameof(indexProperties), nameof(entityType), nameof(property)),
- indexProperties, entityType, property);
-
///
/// The specified index properties {indexProperties} are not declared on the entity type '{entityType}'. Ensure that index properties are declared on the target entity type.
///
@@ -1751,6 +1751,14 @@ public static string IndexPropertyMustBePropertyOrComplexProperty(object? proper
GetString("IndexPropertyMustBePropertyOrComplexProperty", nameof(property), nameof(entityType)),
property, entityType);
+ ///
+ /// A value factory cannot be created for the index {indexProperties} on the entity type '{entityType}' because it contains the complex property '{property}'. Index value factories are not supported for indexes that contain complex properties.
+ ///
+ public static string IndexValueFactoryWithComplexProperty(object? indexProperties, object? entityType, object? property)
+ => string.Format(
+ GetString("IndexValueFactoryWithComplexProperty", nameof(indexProperties), nameof(entityType), nameof(property)),
+ indexProperties, entityType, property);
+
///
/// The index {index} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'.
///
@@ -1783,6 +1791,14 @@ public static string InvalidAlternateKeyValue(object? entityType, object? keyPro
GetString("InvalidAlternateKeyValue", nameof(entityType), nameof(keyProperty)),
entityType, keyProperty);
+ ///
+ /// The collection-indices entry for property '{property}' on index {indexProperties} has {actualCount} element(s), but the property path traverses {expectedCount} complex collection(s).
+ ///
+ public static string InvalidCollectionIndicesEntryLength(object? property, object? indexProperties, object? actualCount, object? expectedCount)
+ => string.Format(
+ GetString("InvalidCollectionIndicesEntryLength", nameof(property), nameof(indexProperties), nameof(actualCount), nameof(expectedCount)),
+ property, indexProperties, actualCount, expectedCount);
+
///
/// The specified type '{type}' must be a non-interface type with a public constructor to be used as a complex type.
///
@@ -1829,14 +1845,6 @@ public static string InvalidIncludeExpression(object? expression)
GetString("InvalidIncludeExpression", nameof(expression)),
expression);
- ///
- /// The number of indices provided ({indicesCount}) must match the number of array segments in the JSON path ({arraySegmentCount}).
- ///
- public static string InvalidStructuredJsonPathIndexCount(object? indicesCount, object? arraySegmentCount)
- => string.Format(
- GetString("InvalidStructuredJsonPathIndexCount", nameof(indicesCount), nameof(arraySegmentCount)),
- indicesCount, arraySegmentCount);
-
///
/// Unable to track an entity of type '{entityType}' because its primary key property '{keyProperty}' is null.
///
@@ -1877,6 +1885,14 @@ public static string InvalidNavigationWithInverseProperty(object? property, obje
GetString("InvalidNavigationWithInverseProperty", "0_property", "1_entityType", nameof(referencedProperty), nameof(referencedEntityType)),
property, entityType, referencedProperty, referencedEntityType);
+ ///
+ /// Invalid number of index collection-indices entries provided for {indexProperties}: {numValues} entries were provided, but the index has {numProperties} properties.
+ ///
+ public static string InvalidNumberOfIndexCollectionIndices(object? indexProperties, object? numValues, object? numProperties)
+ => string.Format(
+ GetString("InvalidNumberOfIndexCollectionIndices", nameof(indexProperties), nameof(numValues), nameof(numProperties)),
+ indexProperties, numValues, numProperties);
+
///
/// Invalid number of index sort order values provided for {indexProperties}: {numValues} values were provided, but the index has {numProperties} properties.
///
@@ -1955,6 +1971,14 @@ public static string InvalidSetTypeOwned(object? typeName, object? ownerType)
GetString("InvalidSetTypeOwned", nameof(typeName), nameof(ownerType)),
typeName, ownerType);
+ ///
+ /// The number of indices provided ({indicesCount}) must match the number of array segments in the JSON path ({arraySegmentCount}).
+ ///
+ public static string InvalidStructuredJsonPathIndexCount(object? indicesCount, object? arraySegmentCount)
+ => string.Format(
+ GetString("InvalidStructuredJsonPathIndexCount", nameof(indicesCount), nameof(arraySegmentCount)),
+ indicesCount, arraySegmentCount);
+
///
/// Invalid {name}: {value}
///
@@ -3950,31 +3974,6 @@ public static EventDefinition LogCompiledModelProviderMismatch(I
return (EventDefinition)definition;
}
- ///
- /// 'EnsureCreated' was called on a context that is already tracking added, modified, or deleted entities. These tracked changes would be lost if a retry occurs due to a transient failure.
- ///
- public static EventDefinition LogEnsureCreatedWithTrackedEntities(IDiagnosticsLogger logger)
- {
- var definition = ((LoggingDefinitions)logger.Definitions).LogEnsureCreatedWithTrackedEntities;
- if (definition == null)
- {
- definition = NonCapturingLazyInitializer.EnsureInitialized(
- ref ((LoggingDefinitions)logger.Definitions).LogEnsureCreatedWithTrackedEntities,
- logger,
- static logger => new EventDefinition(
- logger.Options,
- CoreEventId.EnsureCreatedWithTrackedEntitiesWarning,
- LogLevel.Warning,
- "CoreEventId.EnsureCreatedWithTrackedEntitiesWarning",
- level => LoggerMessage.Define(
- level,
- CoreEventId.EnsureCreatedWithTrackedEntitiesWarning,
- _resourceManager.GetString("LogEnsureCreatedWithTrackedEntities")!)));
- }
-
- return (EventDefinition)definition;
- }
-
///
/// The unchanged property '{typePath}.{property}' was detected as changed and will be marked as modified. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see property values.
///
@@ -4275,6 +4274,31 @@ public static EventDefinition LogDuplicateDependentEntityTypeIns
return (EventDefinition)definition;
}
+ ///
+ /// 'EnsureCreated' was called on a context that is already tracking added, modified, or deleted entities. These tracked changes would be lost if a retry occurs due to a transient failure. Call 'EnsureCreated' before making changes to the context, or disable this warning by using 'ConfigureWarnings(w => w.Ignore(CoreEventId.EnsureCreatedWithTrackedEntitiesWarning))'.
+ ///
+ public static EventDefinition LogEnsureCreatedWithTrackedEntities(IDiagnosticsLogger logger)
+ {
+ var definition = ((LoggingDefinitions)logger.Definitions).LogEnsureCreatedWithTrackedEntities;
+ if (definition == null)
+ {
+ definition = NonCapturingLazyInitializer.EnsureInitialized(
+ ref ((LoggingDefinitions)logger.Definitions).LogEnsureCreatedWithTrackedEntities,
+ logger,
+ static logger => new EventDefinition(
+ logger.Options,
+ CoreEventId.EnsureCreatedWithTrackedEntitiesWarning,
+ LogLevel.Warning,
+ "CoreEventId.EnsureCreatedWithTrackedEntitiesWarning",
+ level => LoggerMessage.Define(
+ level,
+ CoreEventId.EnsureCreatedWithTrackedEntitiesWarning,
+ _resourceManager.GetString("LogEnsureCreatedWithTrackedEntities")!)));
+ }
+
+ return (EventDefinition)definition;
+ }
+
///
/// An exception occurred while iterating over the results of a query for context type '{contextType}'.{newline}{error}
///
diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx
index c2665c3bc1f..6da3480c0bd 100644
--- a/src/EFCore/Properties/CoreStrings.resx
+++ b/src/EFCore/Properties/CoreStrings.resx
@@ -435,6 +435,9 @@
There are multiple [ForeignKey] attributes which are pointing to same set of properties '{propertyList}' on entity type '{entityType}' and targeting the principal entity type '{principalEntityType}'.
+
+ The index named '{indexName}' on entity type '{entityType}' cannot be configured on properties {propertyList} because an index with the same name has already been defined on different properties or with different complex-collection indices. Use a different name for this index.
+
The entity type '{entity}' has both [Keyless] and [PrimaryKey] attributes; one must be removed.
@@ -769,15 +772,15 @@
The index {indexProperties} on the entity type '{entityType}' cannot be configured because it is defined on the complex property '{property}'. Indexes are not supported on complex properties.
-
- A value factory cannot be created for the index {indexProperties} on the entity type '{entityType}' because it contains the complex property '{property}'. Index value factories are not supported for indexes that contain complex properties.
-
The specified index properties {indexProperties} are not declared on the entity type '{entityType}'. Ensure that index properties are declared on the target entity type.
The index property '{property}' on the entity type '{entityType}' cannot be configured because it is not a scalar property or a complex property. Only scalar properties and complex properties can be referenced by an index.
+
+ A value factory cannot be created for the index {indexProperties} on the entity type '{entityType}' because it contains the complex property '{property}'. Index value factories are not supported for indexes that contain complex properties.
+
The index {index} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'.
@@ -790,6 +793,9 @@
Unable to track an entity of type '{entityType}' because alternate key property '{keyProperty}' is null. If the alternate key is not used in a relationship, then consider using a unique index instead. Unique indexes may contain nulls, while alternate keys may not.
+
+ The collection-indices entry for property '{property}' on index {indexProperties} has {actualCount} element(s), but the property path traverses {expectedCount} complex collection(s).
+
The specified type '{type}' must be a non-interface type with a public constructor to be used as a complex type.
@@ -808,9 +814,6 @@
The expression '{expression}' is invalid inside an 'Include' operation, since it does not represent a property access: 't => t.MyProperty'. To target navigations declared on derived types, use casting ('t => ((Derived)t).MyProperty') or the 'as' operator ('t => (t as Derived).MyProperty'). Collection navigation access can be filtered by composing Where, OrderBy(Descending), ThenBy(Descending), Skip or Take operations. For more information on including related data, see https://go.microsoft.com/fwlink/?LinkID=746393.
-
- The number of indices provided ({indicesCount}) must match the number of array segments in the JSON path ({arraySegmentCount}).
-
Unable to track an entity of type '{entityType}' because its primary key property '{keyProperty}' is null.
@@ -826,6 +829,9 @@
The [InverseProperty] attribute on property '{1_entityType}.{0_property}' is not valid. The property '{referencedProperty}' is not a valid navigation on the related type '{referencedEntityType}'. Ensure that the property exists and is a valid reference or collection navigation.
+
+ Invalid number of index collection-indices entries provided for {indexProperties}: {numValues} entries were provided, but the index has {numProperties} properties.
+
Invalid number of index sort order values provided for {indexProperties}: {numValues} values were provided, but the index has {numProperties} properties.
@@ -856,6 +862,9 @@
Cannot create a DbSet for '{typeName}' because it is configured as an owned entity type and must be accessed through its owning entity type '{ownerType}'. See https://aka.ms/efcore-docs-owned for more information.
+
+ The number of indices provided ({indicesCount}) must match the number of array segments in the JSON path ({arraySegmentCount}).
+
Invalid {name}: {value}
@@ -969,10 +978,6 @@
A compiled model was found but it was built for the database provider '{compiledProviderName}'. The current context is using the database provider '{currentProviderName}'. The compiled model was ignored. Regenerate the compiled model with the correct provider.
Warning CoreEventId.CompiledModelProviderMismatchWarning string string
-
- 'EnsureCreated' was called on a context that is already tracking added, modified, or deleted entities. These tracked changes would be lost if a retry occurs due to a transient failure. Call 'EnsureCreated' before making changes to the context, or disable this warning by using 'ConfigureWarnings(w => w.Ignore(CoreEventId.EnsureCreatedWithTrackedEntitiesWarning))'.
- Warning CoreEventId.EnsureCreatedWithTrackedEntitiesWarning
-
The unchanged property '{typePath}.{property}' was detected as changed and will be marked as modified. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see property values.
Debug CoreEventId.ComplexElementPropertyChangeDetected string string
@@ -1021,6 +1026,10 @@
The same entity is being tracked as different entity types '{dependent1}' and '{dependent2}' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
Warning CoreEventId.DuplicateDependentEntityTypeInstanceWarning string string
+
+ 'EnsureCreated' was called on a context that is already tracking added, modified, or deleted entities. These tracked changes would be lost if a retry occurs due to a transient failure. Call 'EnsureCreated' before making changes to the context, or disable this warning by using 'ConfigureWarnings(w => w.Ignore(CoreEventId.EnsureCreatedWithTrackedEntitiesWarning))'.
+ Warning CoreEventId.EnsureCreatedWithTrackedEntitiesWarning
+
An exception occurred while iterating over the results of a query for context type '{contextType}'.{newline}{error}
Error CoreEventId.QueryIterationFailed Type string Exception
diff --git a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
index f343fb355b6..9f90895e409 100644
--- a/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
+++ b/test/EFCore.Cosmos.Tests/Infrastructure/CosmosModelValidatorTest.cs
@@ -564,6 +564,39 @@ private class ComplexTypeInCollection
public string Value { get; set; }
}
+ [Fact]
+ public virtual void Passes_on_vector_index_on_complex_type_property()
+ {
+ var modelBuilder = CreateConventionModelBuilder();
+ modelBuilder.Entity(b =>
+ {
+ b.ComplexProperty(e => e.Details, cb =>
+ {
+ cb.Property(d => d.Embedding);
+ });
+ b.HasIndex("Details.Embedding").IsVectorIndex(VectorIndexType.Flat);
+ });
+
+ var entityType = modelBuilder.Model.FindEntityType(typeof(EntityWithVectorInComplexType))!;
+ var complexType = entityType.FindComplexProperty(nameof(EntityWithVectorInComplexType.Details))!.ComplexType;
+ var embeddingProperty = complexType.FindProperty(nameof(EmbeddingDetails.Embedding))!;
+ embeddingProperty.SetVectorDistanceFunction(DistanceFunction.Cosine);
+ embeddingProperty.SetVectorDimensions(128);
+
+ Validate(modelBuilder);
+ }
+
+ private class EntityWithVectorInComplexType
+ {
+ public string Id { get; set; }
+ public EmbeddingDetails Details { get; set; }
+ }
+
+ private class EmbeddingDetails
+ {
+ public ReadOnlyMemory Embedding { get; set; }
+ }
+
[Fact]
public virtual void Detects_trigger_on_derived_type()
{
diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs
index 42387c0555d..92109ce2d79 100644
--- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs
+++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.ModelSnapshot.cs
@@ -7507,6 +7507,322 @@ public virtual void IndexAttribute_SortInTempDb_is_stored_in_snapshot()
Assert.True(index.GetSortInTempDb());
});
+ private class SnapshotBlog
+ {
+ public int Id { get; set; }
+ public string Title { get; set; }
+ public List Posts { get; set; } = [];
+ public SnapshotAddress Owner { get; set; }
+ }
+
+ private class SnapshotPost
+ {
+ public string Title { get; set; }
+ public int Rating { get; set; }
+ }
+
+ private class SnapshotAddress
+ {
+ public string City { get; set; }
+ public string Country { get; set; }
+ }
+
+ [Fact]
+ public void Snapshot_emits_dotted_path_for_index_through_complex_property()
+ => Test(
+ b => b.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Owner.City);
+ }),
+ """b.HasIndex("Owner.City")""",
+ model => Assert.Equal(
+ "City",
+ Assert.Single(
+ model.FindEntityType(typeof(SnapshotBlog)).GetIndexes(),
+ i => i.CollectionIndices is null).Properties.Single().Name),
+ fullSnapshot: false);
+
+ [Fact]
+ public void Snapshot_emits_empty_brackets_for_index_through_complex_collection()
+ => Test(
+ b => b.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Posts.Select(p => p.Title));
+ }),
+ """b.HasIndex("Posts[].Title")""",
+ model =>
+ {
+ var index = model.FindEntityType(typeof(SnapshotBlog)).GetIndexes().Single();
+ Assert.Equal("Title", index.Properties.Single().Name);
+ Assert.Equal(new int?[] { null }, Assert.Single(index.CollectionIndices));
+ },
+ fullSnapshot: false);
+
+ [Fact]
+ public void Snapshot_emits_numeric_bracket_for_index_through_complex_collection_indexer()
+ => Test(
+ b => b.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Posts[0].Rating);
+ }),
+ """b.HasIndex("Posts[0].Rating")""",
+ model =>
+ {
+ var index = model.FindEntityType(typeof(SnapshotBlog)).GetIndexes().Single();
+ Assert.Equal("Rating", index.Properties.Single().Name);
+ Assert.Equal(new int?[] { 0 }, Assert.Single(index.CollectionIndices));
+ },
+ fullSnapshot: false);
+
+ [Fact]
+ public virtual void Index_through_complex_property_is_stored_in_snapshot()
+ => Test(
+ builder => builder.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Owner.City);
+ }),
+ AddBoilerPlate(
+ GetHeading()
+ + """
+ modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Title")
+ .HasColumnType("nvarchar(max)");
+
+ b.ComplexProperty(typeof(Dictionary), "Owner", "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog.Owner#SnapshotAddress", b1 =>
+ {
+ b1.Property("City");
+
+ b1.Property("Country");
+
+ b1
+ .ToJson("Owner")
+ .HasColumnType("nvarchar(max)");
+ });
+
+ b.ComplexCollection(typeof(List>), "Posts", "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog.Posts#SnapshotPost", b1 =>
+ {
+ b1.Property("Rating");
+
+ b1.Property("Title");
+
+ b1
+ .ToJson("Posts")
+ .HasColumnType("nvarchar(max)");
+ });
+
+ b.HasKey("Id");
+
+ b.HasIndex("Owner.City");
+
+ b.ToTable("SnapshotBlog", "DefaultSchema");
+ });
+""", usingCollections: true),
+ model =>
+ {
+ var index = Assert.Single(model.FindEntityType(typeof(SnapshotBlog)).GetIndexes());
+ Assert.Equal("City", index.Properties.Single().Name);
+ Assert.Null(index.CollectionIndices);
+ });
+
+ [Fact]
+ public virtual void Index_through_complex_collection_all_elements_is_stored_in_snapshot()
+ => Test(
+ builder => builder.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Posts.Select(p => p.Title));
+ }),
+ AddBoilerPlate(
+ GetHeading()
+ + """
+ modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
+ b.Property("Title")
+ .HasColumnType("nvarchar(max)");
+
+ b.ComplexProperty(typeof(Dictionary), "Owner", "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog.Owner#SnapshotAddress", b1 =>
+ {
+ b1.Property("City");
+
+ b1.Property("Country");
+
+ b1
+ .ToJson("Owner")
+ .HasColumnType("nvarchar(max)");
+ });
+
+ b.ComplexCollection(typeof(List>), "Posts", "Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog.Posts#SnapshotPost", b1 =>
+ {
+ b1.Property("Rating");
+
+ b1.Property("Title");
+
+ b1
+ .ToJson("Posts")
+ .HasColumnType("nvarchar(max)");
+ });
+
+ b.HasKey("Id");
+
+ b.HasIndex("Posts[].Title");
+
+ b.ToTable("SnapshotBlog", "DefaultSchema");
+ });
+""", usingCollections: true),
+ model =>
+ {
+ var index = Assert.Single(model.FindEntityType(typeof(SnapshotBlog)).GetIndexes());
+ Assert.Equal("Title", index.Properties.Single().Name);
+ Assert.Equal(new int?[] { null }, Assert.Single(index.CollectionIndices!));
+ });
+
+ [Fact]
+ public virtual void Index_through_complex_collection_indexer_is_stored_in_snapshot()
+ => Test(
+ builder => builder.Entity(eb =>
+ {
+ eb.Property(e => e.Title);
+ eb.ComplexProperty(e => e.Owner, cb =>
+ {
+ cb.Property(a => a.City);
+ cb.Property(a => a.Country);
+ cb.ToJson();
+ });
+ eb.ComplexCollection(e => e.Posts, cb =>
+ {
+ cb.Property(p => p.Title);
+ cb.Property(p => p.Rating);
+ cb.ToJson();
+ });
+ eb.HasIndex(e => e.Posts[0].Rating);
+ }),
+ AddBoilerPlate(
+ GetHeading()
+ + """
+ modelBuilder.Entity("Microsoft.EntityFrameworkCore.Migrations.Design.CSharpMigrationsGeneratorTest+SnapshotBlog", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int");
+
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property