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("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[0].Rating"); + + b.ToTable("SnapshotBlog", "DefaultSchema"); + }); +""", usingCollections: true), + model => + { + var index = Assert.Single(model.FindEntityType(typeof(SnapshotBlog)).GetIndexes()); + Assert.Equal("Rating", index.Properties.Single().Name); + Assert.Equal(new int?[] { 0 }, Assert.Single(index.CollectionIndices!)); + }); + #endregion #region ForeignKey diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs index eacf7243b91..5074a3d5953 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTest.cs @@ -100,7 +100,9 @@ public void Test_new_annotations_handled_for_entity_types() RelationalAnnotationNames.ContainerColumnType, RelationalAnnotationNames.StoreType, RelationalAnnotationNames.UseNamedDefaultConstraints, - RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, + RelationalAnnotationNames.JsonIndex, + RelationalAnnotationNames.JsonIndexPaths }; // Add a line here if the code generator is supposed to handle this annotation @@ -259,7 +261,9 @@ public void Test_new_annotations_handled_for_properties() RelationalAnnotationNames.JsonPropertyName, RelationalAnnotationNames.StoreType, RelationalAnnotationNames.UseNamedDefaultConstraints, - RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations + RelationalAnnotationNames.IsForeignKeyExcludedFromMigrations, + RelationalAnnotationNames.JsonIndex, + RelationalAnnotationNames.JsonIndexPaths }; var columnMapping = $@"{_nl}.{nameof(RelationalPropertyBuilderExtensions.HasColumnType)}(""default_int_mapping"")"; diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTestBase.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTestBase.cs index ebe7a7a9c63..0b35e3c609f 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTestBase.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationsGeneratorTestBase.cs @@ -13,10 +13,15 @@ public abstract class CSharpMigrationsGeneratorTestBase protected abstract TestHelpers TestHelpers { get; } - protected void Test(Action buildModel, string expectedCode, Action assert) - => Test(buildModel, expectedCode, (m, _) => assert(m)); - - protected void Test(Action buildModel, string expectedCode, Action assert, bool validate = false) + protected void Test(Action buildModel, string expectedCode, Action assert, bool fullSnapshot = true) + => Test(buildModel, expectedCode, (m, _) => assert(m), fullSnapshot: fullSnapshot); + + protected void Test( + Action buildModel, + string expectedCode, + Action assert, + bool validate = false, + bool fullSnapshot = true) { var modelBuilder = CreateConventionalModelBuilder(); modelBuilder.HasDefaultSchema("DefaultSchema"); @@ -26,10 +31,10 @@ protected void Test(Action buildModel, string expectedCode, Action var model = modelBuilder.FinalizeModel(designTime: true, skipValidation: !validate); - Test(model, expectedCode, assert); + Test(model, expectedCode, assert, fullSnapshot); } - protected void Test(IModel model, string expectedCode, Action assert) + protected void Test(IModel model, string expectedCode, Action assert, bool fullSnapshot = true) { var generator = CreateMigrationsGenerator(); var code = generator.GenerateSnapshot("RootNamespace", typeof(DbContext), "Snapshot", model); @@ -39,11 +44,19 @@ protected void Test(IModel model, string expectedCode, Action as try { - Assert.Equal(expectedCode, code, ignoreLineEndingDifferences: true); + if (fullSnapshot) + { + Assert.Equal(expectedCode, code, ignoreLineEndingDifferences: true); + } + else + { + Assert.Contains(expectedCode, code); + } } - catch (EqualException e) + catch (XunitException e) { - throw new Exception(e.Message + Environment.NewLine + Environment.NewLine + "-- Actual code:" + Environment.NewLine + code); + throw new Exception( + e.Message + Environment.NewLine + Environment.NewLine + "-- Actual code:" + Environment.NewLine + code); } var targetOptionsBuilder = TestHelpers diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs index 9316d108f5c..05d0c2692da 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsInfrastructureTestBase.cs @@ -324,7 +324,7 @@ public virtual async Task Can_apply_two_migrations_in_transaction_async() var strategy = db.Database.CreateExecutionStrategy(); await strategy.ExecuteAsync(async () => { - using var transaction = db.Database.BeginTransactionAsync(); + await using var transaction = await db.Database.BeginTransactionAsync(); var migrator = db.GetService(); await migrator.MigrateAsync("Migration1"); await migrator.MigrateAsync("Migration2"); diff --git a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs index 0686408ed1e..81c430fe82a 100644 --- a/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Scaffolding/CompiledModelRelationalTestBase.cs @@ -526,6 +526,7 @@ protected override void BuildComplexTypesModel(ModelBuilder modelBuilder) "ManyOwned", "OwnedCollection", eb => eb.ToJson()); eb.ComplexProperty(p => p.Dependent, cb => cb.ToJson()); eb.HasIndex(e => e.Dependent, "IX_PrincipalDerived_Dependent"); + eb.HasIndex(["ManyOwned[0].Details"], "IX_PrincipalDerived_ManyOwned_Indexer"); }); } @@ -665,6 +666,13 @@ protected override void AssertComplexTypes(IModel model) Assert.All(detailsMappings, m => Assert.Same(detailsProp, m.Property)); } } + + var manyOwnedDetailsProperty = manyOwnedComplexProperty.ComplexType.FindProperty(nameof(OwnedType.Details))!; + + var manyOwnedIndexerIndex = principalDerived.GetIndexes().Single(i => i.Name == "IX_PrincipalDerived_ManyOwned_Indexer"); + Assert.Same(manyOwnedDetailsProperty, manyOwnedIndexerIndex.Properties.Single()); + Assert.NotNull(manyOwnedIndexerIndex.CollectionIndices); + Assert.Equal(new int?[] { 0 }, manyOwnedIndexerIndex.CollectionIndices.Single()); } var dependentComplexProperty = principalDerived.FindComplexProperty(nameof(PrincipalDerived>.Dependent))!; diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index 61cab3bdd96..654e4e0447a 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -139,10 +139,8 @@ public override void Detects_index_on_complex_collection_property() var collectionProperty = entityType.FindComplexProperty(nameof(EntityWithComplexCollection.Items))!; entityType.AddIndex([collectionProperty], ConfigurationSource.Explicit); - VerifyError( - CoreStrings.IndexOnComplexCollection( - "{'Items'}", nameof(EntityWithComplexCollection), nameof(EntityWithComplexCollection.Items)), - modelBuilder); + // Indexing a JSON-mapped complex collection is valid for relational providers + Validate(modelBuilder); } public override void Detects_index_traversing_complex_collection() @@ -258,6 +256,108 @@ public virtual void Passes_on_index_on_complex_property_mapped_to_json() var index = model.FindEntityType(typeof(SampleEntity))!.GetIndexes().Single(); } + [Fact] + public virtual void Passes_on_json_path_index_in_single_complex_collection() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.Items).ToJson(); + b.HasIndex("Items[].Value"); + }); + + var model = Validate(modelBuilder); + var index = model.FindEntityType(typeof(EntityWithComplexCollection))!.GetIndexes().Single(); + Assert.Equal("Value", index.Properties.Single().Name); + Assert.Equal(new int?[] { null }, index.CollectionIndices.Single()); + } + + [Fact] + public virtual void Passes_on_json_path_index_through_nested_complex_collections() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.Posts, cb => + { + cb.ToJson(); + cb.Property(p => p.Title); + cb.ComplexCollection(p => p.Comments, ccb => ccb.Property(c => c.Text)); + }); + b.HasIndex("Posts[0].Comments[1].Text"); + }); + + var model = Validate(modelBuilder); + var index = model.FindEntityType(typeof(EntityWithNestedComplexCollections))!.GetIndexes().Single(); + Assert.Equal("Text", index.Properties.Single().Name); + Assert.Equal([0, 1], index.CollectionIndices!.Single()); + } + + [Fact] + public virtual void Passes_on_json_path_index_through_nested_complex_collections_all_elements() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.Posts, cb => + { + cb.ToJson(); + cb.Property(p => p.Title); + cb.ComplexCollection(p => p.Comments, ccb => ccb.Property(c => c.Text)); + }); + b.HasIndex("Posts[].Comments[].Text"); + }); + + var model = Validate(modelBuilder); + var index = model.FindEntityType(typeof(EntityWithNestedComplexCollections))!.GetIndexes().Single(); + Assert.Equal([null, null], index.CollectionIndices!.Single()); + } + + private sealed class EntityWithNestedComplexCollections + { + public int Id { get; set; } + public List Posts { get; set; } = []; + } + + private sealed class NestedPost + { + public string Title { get; set; } = null!; + public List Comments { get; set; } = []; + } + + private sealed class NestedComment + { + public string Text { get; set; } = null!; + } + + private sealed class EntityWithTwoJsonCollections + { + public int Id { get; set; } + public List ItemsA { get; set; } = []; + public List ItemsB { get; set; } = []; + } + + [Fact] + public virtual void Detects_json_path_index_spanning_multiple_json_columns() + { + var modelBuilder = CreateConventionModelBuilder(); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.ComplexCollection(e => e.ItemsA).ToJson("ItemsAJson"); + b.ComplexCollection(e => e.ItemsB).ToJson("ItemsBJson"); + b.HasIndex("ItemsA[].Value", "ItemsB[].Value"); + }); + + VerifyError( + RelationalStrings.JsonPathIndexPropertiesInDifferentJsonColumns( + "{'Value', 'Value'}", nameof(EntityWithTwoJsonCollections), "ItemsAJson", "ItemsBJson"), + modelBuilder); + } + [Fact] public virtual void GetNullableValueFactory_throws_for_index_containing_complex_property() { diff --git a/test/EFCore.Relational.Tests/Metadata/RelationalJsonIndexTest.cs b/test/EFCore.Relational.Tests/Metadata/RelationalJsonIndexTest.cs new file mode 100644 index 00000000000..1853d0928f6 --- /dev/null +++ b/test/EFCore.Relational.Tests/Metadata/RelationalJsonIndexTest.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +namespace Microsoft.EntityFrameworkCore.Metadata; + +public class RelationalJsonIndexTest +{ + [Fact] + public void Constructor_throws_when_collectionIndices_count_does_not_match_elements() + { + var elements = new IRelationalJsonElement[] { null!, null! }; + var collectionIndices = new IReadOnlyList?[] { [null] }; // 1 entry, but 2 elements + + var exception = Assert.Throws( + () => new RelationalJsonIndex(elements, collectionIndices)); + + Assert.Equal( + RelationalStrings.JsonPathIndexElementsCollectionIndicesMismatch(2, 1) + + " (Parameter 'collectionIndices')", + exception.Message); + } + + [Fact] + public void Constructor_succeeds_when_collectionIndices_is_null() + { + var elements = new IRelationalJsonElement[] { null! }; + + var index = new RelationalJsonIndex(elements, collectionIndices: null); + + Assert.Same(elements, index.Elements); + Assert.Null(index.CollectionIndices); + } + + [Fact] + public void Constructor_succeeds_when_counts_match() + { + var elements = new IRelationalJsonElement[] { null!, null! }; + var collectionIndices = new IReadOnlyList?[] { [null], [0] }; + + var index = new RelationalJsonIndex(elements, collectionIndices); + + Assert.Same(elements, index.Elements); + Assert.Same(collectionIndices, index.CollectionIndices); + } +} diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Generic.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Generic.cs index 3d1b91a5b9e..3805da9a3b6 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Generic.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Generic.cs @@ -232,6 +232,9 @@ public override TestIndexBuilder HasIndex(Expression HasIndex(params string[] propertyNames) => new GenericTestIndexBuilder(EntityTypeBuilder.HasIndex(propertyNames)); + public override TestIndexBuilder HasIndex(string[] propertyNames, string name) + => new GenericTestIndexBuilder(EntityTypeBuilder.HasIndex(propertyNames, name)); + public override TestOwnedNavigationBuilder OwnsOne(string navigationName) => new GenericTestOwnedNavigationBuilder( EntityTypeBuilder.OwnsOne(navigationName)); diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Inheritance.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Inheritance.cs index b17a1d68650..91ba08b0fe0 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Inheritance.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.Inheritance.cs @@ -159,8 +159,8 @@ public virtual void Can_set_and_remove_base_type() return true; }); Fixture.TestHelpers.ModelAsserter.AssertEqual( - initialIndexes.SingleOrDefault()?.Properties.OfType().ToList() ?? [], - pickle.GetIndexes().SingleOrDefault()?.Properties.OfType().ToList() ?? []); + initialIndexes.SingleOrDefault()?.Properties.Cast().ToList() ?? [], + pickle.GetIndexes().SingleOrDefault()?.Properties.Cast().ToList() ?? []); Fixture.TestHelpers.ModelAsserter.AssertEqual( initialForeignKeys.Single().Properties, pickle.GetForeignKeys().Single().Properties); @@ -191,8 +191,8 @@ public virtual void Can_set_and_remove_base_type() return true; }); Fixture.TestHelpers.ModelAsserter.AssertEqual( - initialIndexes.SingleOrDefault()?.Properties.OfType().ToList() ?? [], - ingredient.GetIndexes().SingleOrDefault()?.Properties.OfType().ToList() ?? []); + initialIndexes.SingleOrDefault()?.Properties.Cast().ToList() ?? [], + ingredient.GetIndexes().SingleOrDefault()?.Properties.Cast().ToList() ?? []); Fixture.TestHelpers.ModelAsserter.AssertEqual( initialForeignKeys.Single().Properties, ingredient.GetForeignKeys().Single().Properties); diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonGeneric.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonGeneric.cs index 57518e925b8..da6fb8486f6 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonGeneric.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonGeneric.cs @@ -3,6 +3,7 @@ using System.Reflection; using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; namespace Microsoft.EntityFrameworkCore.ModelBuilding; @@ -258,16 +259,31 @@ public override TestEntityTypeBuilder Ignore(string propertyName) => Wrap(EntityTypeBuilder.Ignore(propertyName)); public override TestIndexBuilder HasIndex(Expression> indexExpression) - => new NonGenericTestIndexBuilder( - EntityTypeBuilder.HasIndex(indexExpression.GetMemberAccessChainList().Select(ToDottedName).ToArray())); + { + var (members, isCollection, collectionIndices) = indexExpression.MatchComplexMemberAccessList(nameof(indexExpression)); + var builder = ((EntityType)EntityTypeBuilder.Metadata).Builder; + var properties = builder.GetOrCreateProperties(members, isCollection, ConfigurationSource.Explicit)!; + return new NonGenericTestIndexBuilder( + new IndexBuilder( + builder.HasIndex(properties, collectionIndices, name: null, ConfigurationSource.Explicit)!.Metadata)); + } public override TestIndexBuilder HasIndex(Expression> indexExpression, string name) - => new NonGenericTestIndexBuilder( - EntityTypeBuilder.HasIndex(indexExpression.GetMemberAccessChainList().Select(ToDottedName).ToArray(), name)); + { + var (members, isCollection, collectionIndices) = indexExpression.MatchComplexMemberAccessList(nameof(indexExpression)); + var builder = ((EntityType)EntityTypeBuilder.Metadata).Builder; + var properties = builder.GetOrCreateProperties(members, isCollection, ConfigurationSource.Explicit)!; + return new NonGenericTestIndexBuilder( + new IndexBuilder( + builder.HasIndex(properties, collectionIndices, name, ConfigurationSource.Explicit)!.Metadata)); + } public override TestIndexBuilder HasIndex(params string[] propertyNames) => new NonGenericTestIndexBuilder(EntityTypeBuilder.HasIndex(propertyNames)); + public override TestIndexBuilder HasIndex(string[] propertyNames, string name) + => new NonGenericTestIndexBuilder(EntityTypeBuilder.HasIndex(propertyNames, name)); + public override TestOwnedNavigationBuilder OwnsOne(string navigationName) => new NonGenericTestOwnedNavigationBuilder( EntityTypeBuilder.OwnsOne(typeof(TRelatedEntity), navigationName)); diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonRelationship.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonRelationship.cs index 7661c56d847..1ad9846ce91 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonRelationship.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.NonRelationship.cs @@ -1887,6 +1887,260 @@ public virtual void Can_add_contained_indexes() Assert.False(secondIndexBuilder.Metadata.IsUnique); } + // Common setup for the complex-index tests below. Configures ComplexProperties with Customer (single + // complex) and Customers (complex collection) and ignores everything else so conventions stay quiet. + private static TestModelBuilder ConfigureComplexIndexEntity( + TestModelBuilder modelBuilder, + Action> configure) + => modelBuilder + .Ignore() + .Ignore() + .Entity(b => + { + b.Ignore(e => e.CollectionQuarks); + b.Ignore(e => e.QuarksCollection); + b.Ignore(e => e.DoubleProperty); + b.Ignore(e => e.Quarks); + b.ComplexProperty(e => e.Customer!, cb => + { + cb.Ignore(c => c.Details); + cb.Ignore(c => c.Orders); + }); + b.ComplexCollection(e => e.Customers, cb => + { + cb.Ignore(c => c.Details); + cb.Ignore(c => c.Orders); + }); + configure(b); + }); + + [Fact] + public virtual void HasIndex_through_complex_property_resolves_leaf_with_no_collection_indices() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customer!.Name)); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + var leaf = index.Properties.Single(); + Assert.Equal(nameof(Customer.Name), leaf.Name); + Assert.Equal(typeof(Customer), leaf.DeclaringType.ClrType); + Assert.Null(index.CollectionIndices); + } + + [Fact] + public virtual void HasIndex_over_complex_collection_records_null_collection_index() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customers.Select(c => c.Name))); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + var leaf = index.Properties.Single(); + Assert.Equal(nameof(Customer.Name), leaf.Name); + Assert.Equal(typeof(Customer), leaf.DeclaringType.ClrType); + + var collectionIndices = index.CollectionIndices; + Assert.NotNull(collectionIndices); + var entry = Assert.Single(collectionIndices); + Assert.NotNull(entry); + Assert.Equal(new int?[] { null }, entry); + } + + [Fact] + public virtual void HasIndex_with_constant_indexer_records_collection_index() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customers[0].Name)); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + Assert.Equal(nameof(Customer.Name), index.Properties.Single().Name); + + var collectionIndices = index.CollectionIndices; + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { 0 }, Assert.Single(collectionIndices)); + } + + [Fact] + public virtual void HasIndex_with_multiple_leaves_mixing_complex_and_collection_paths() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity( + modelBuilder, + b => b.HasIndex(e => new { e.Customer!.Name, Titles = e.Customers.Select(c => c.Title) })); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + + Assert.Equal(2, index.Properties.Count); + Assert.Equal(nameof(Customer.Name), index.Properties[0].Name); + Assert.Equal(nameof(Customer.Title), index.Properties[1].Name); + + var collectionIndices = index.CollectionIndices; + Assert.NotNull(collectionIndices); + Assert.Equal(2, collectionIndices.Count); + Assert.Null(collectionIndices[0]); + Assert.Equal(new int?[] { null }, collectionIndices[1]); + } + + [Fact] + public virtual void HasIndex_with_different_collection_indices_creates_distinct_indexes() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity( + modelBuilder, b => + { + b.HasIndex(e => e.Customers[0].Name); + b.HasIndex(e => e.Customers.Select(c => c.Name)); + }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var indexes = entityType.GetIndexes().ToList(); + Assert.Equal(2, indexes.Count); + + Assert.Single( + indexes, i => i.CollectionIndices is { Count: 1 } ci + && ci[0] is { } entry + && entry.SequenceEqual(new int?[] { 0 })); + Assert.Single( + indexes, i => i.CollectionIndices is { Count: 1 } ci + && ci[0] is { } entry + && entry.SequenceEqual(new int?[] { null })); + } + + [Fact] + public virtual void ForeignKeyIndexConvention_does_not_treat_JSON_path_indexes_as_redundant_coverage() + { + // A regular index on a scalar shouldn't be considered as covering a JSON-path index over a complex + // collection leaf, and vice versa. Both should survive. + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity( + modelBuilder, b => + { + b.HasIndex(e => e.Customers.Select(c => c.Name)); + b.HasIndex(e => e.Id); + }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var indexes = entityType.GetIndexes().ToList(); + + Assert.Equal(2, indexes.Count); + Assert.Single(indexes, i => i.CollectionIndices is not null); + Assert.Single(indexes, i => i.CollectionIndices is null); + } + + [Fact] + public virtual void HasIndex_string_path_dotted_complex_property() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customer!.Name)); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + Assert.Equal(nameof(Customer.Name), index.Properties.Single().Name); + Assert.Null(index.CollectionIndices); + } + + [Fact] + public virtual void HasIndex_string_path_empty_bracket_yields_null_collection_index() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customers.Select(c => c.Title))); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + Assert.Equal(nameof(Customer.Title), index.Properties.Single().Name); + + var collectionIndices = index.CollectionIndices; + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { null }, Assert.Single(collectionIndices)); + } + + [Fact] + public virtual void HasIndex_string_path_numeric_bracket_yields_byte_collection_index() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customers[3].Title)); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + Assert.Equal(nameof(Customer.Title), index.Properties.Single().Name); + + var collectionIndices = index.CollectionIndices; + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { 3 }, Assert.Single(collectionIndices)); + } + + [Fact] + public virtual void HasIndex_string_path_with_name_uses_named_overload() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity(modelBuilder, b => b.HasIndex(e => e.Customers[0].Name, "IX_FirstCustomerName")); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var index = entityType.GetIndexes().Single(); + Assert.Equal("IX_FirstCustomerName", index.Name); + Assert.Equal(nameof(Customer.Name), index.Properties.Single().Name); + Assert.Equal(new int?[] { 0 }, Assert.Single(index.CollectionIndices!)); + } + + [Fact] + public virtual void HasIndex_named_with_conflicting_collection_indices_throws() + { + var modelBuilder = CreateModelBuilder(); + var caught = Assert.Throws( + () => ConfigureComplexIndexEntity( + modelBuilder, b => + { + b.HasIndex(e => e.Customers[0].Name, "MyIdx"); + b.HasIndex(e => e.Customers.Select(c => c.Name), "MyIdx"); + })); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + Assert.Equal( + CoreStrings.ConflictingNamedIndex( + "MyIdx", + entityType.DisplayName(), + entityType.FindIndex("MyIdx")!.Properties.Format()), + caught.Message); + } + + [Fact] + public virtual void HasIndex_unnamed_plain_and_json_path_on_same_property_coexist() + { + // Same leaf Property (Customers.Name), but distinct CollectionIndices identities — both should + // survive without triggering DuplicateIndex. + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity( + modelBuilder, b => + { + b.HasIndex(e => e.Customers.Select(c => c.Name)); + b.HasIndex(e => e.Customers[0].Name); + }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + var indexes = entityType.GetIndexes().ToList(); + Assert.Equal(2, indexes.Count); + Assert.Single(indexes, i => i.CollectionIndices![0]!.SequenceEqual(new int?[] { null })); + Assert.Single(indexes, i => i.CollectionIndices![0]!.SequenceEqual(new int?[] { 0 })); + } + + [Fact] + public virtual void HasIndex_idempotent_for_same_json_path_index() + { + var modelBuilder = CreateModelBuilder(); + ConfigureComplexIndexEntity( + modelBuilder, b => + { + b.HasIndex(e => e.Customers[0].Name); + b.HasIndex(e => e.Customers[0].Name); + }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(ComplexProperties))!; + Assert.Single(entityType.GetIndexes()); + } + [Fact] public virtual void Can_set_primary_key_by_convention_for_user_specified_shadow_property() { diff --git a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.cs b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.cs index 87272a1ec17..2e45c92a3cc 100644 --- a/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.cs +++ b/test/EFCore.Specification.Tests/ModelBuilding/ModelBuilderTest.cs @@ -260,6 +260,7 @@ public abstract TestEntityTypeBuilder Ignore( public abstract TestIndexBuilder HasIndex(Expression> indexExpression); public abstract TestIndexBuilder HasIndex(Expression> indexExpression, string name); public abstract TestIndexBuilder HasIndex(params string[] propertyNames); + public abstract TestIndexBuilder HasIndex(string[] propertyNames, string name); public abstract TestOwnedNavigationBuilder OwnsOne(string navigationName) where TRelatedEntity : class; diff --git a/test/EFCore.Specification.Tests/TestUtilities/ModelAsserter.cs b/test/EFCore.Specification.Tests/TestUtilities/ModelAsserter.cs index ed26f265b19..fc84cfc7e8d 100644 --- a/test/EFCore.Specification.Tests/TestUtilities/ModelAsserter.cs +++ b/test/EFCore.Specification.Tests/TestUtilities/ModelAsserter.cs @@ -952,6 +952,7 @@ public virtual bool AssertEqual( if (designTime) { Assert.Equal(expected.IsDescending, actual.IsDescending); + AssertCollectionIndicesEqual(expected.CollectionIndices, actual.CollectionIndices); } }, () => Assert.Equal(expected.IsUnique, actual.IsUnique), @@ -968,6 +969,32 @@ public virtual bool AssertEqual( return true; } + private static void AssertCollectionIndicesEqual( + IReadOnlyList?>? expected, + IReadOnlyList?>? actual) + { + if (expected is null) + { + Assert.Null(actual); + return; + } + + Assert.NotNull(actual); + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + if (expected[i] is null) + { + Assert.Null(actual[i]); + } + else + { + Assert.NotNull(actual[i]); + Assert.Equal(expected[i], actual[i]); + } + } + } + public virtual IReadOnlyModel Clone(IReadOnlyModel model) { IMutableModel modelClone = new Model(model.ModelId); @@ -1096,8 +1123,8 @@ protected virtual void Copy(IReadOnlyEntityType sourceEntityType, IMutableEntity { var targetProperties = index.Properties.Select(p => targetEntityType.FindProperty(p.Name)!).ToList(); var clonedIndex = index.Name == null - ? targetEntityType.AddIndex(targetProperties) - : targetEntityType.AddIndex(targetProperties, index.Name); + ? targetEntityType.AddIndex(targetProperties, index.CollectionIndices) + : targetEntityType.AddIndex(targetProperties, index.CollectionIndices, index.Name); Copy(index, clonedIndex); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 6bad88d3db5..9b688e3bc72 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using Microsoft.Data.SqlClient; using Microsoft.Data.SqlTypes; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; @@ -2814,6 +2815,369 @@ await Test( AssertSql("DROP INDEX [IX_VectorEntities_Vector] ON [VectorEntities];"); } + #region JSON path indexes + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual Task Create_json_index_over_complex_collection_all_elements() + { + // SQL Server doesn't support indexes over all elements of JSON arrays + return Assert.ThrowsAsync( + () => Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items[].Value"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Same(table.Columns.Single(c => c.Name == "ItemsJson"), Assert.Single(index.Columns)); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + })); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Create_json_index_over_complex_collection_specific_element() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items[0].Value"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Same(table.Columns.Single(c => c.Name == "ItemsJson"), Assert.Single(index.Columns)); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +CREATE JSON INDEX [IX_JsonIndexEntities_Items_Value] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$[0].Value'); +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Rename_json_index_over_complex_collection() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + }); + }), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Value"], "IX_OldName"), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Value"], "IX_NewName"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_NewName", index.Name); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +EXEC sp_rename N'[JsonIndexEntities].[IX_OldName]', N'IX_NewName', 'INDEX'; +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Change_json_index_path_index() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Other"], "IX_Items"), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[1].Other"], "IX_Items"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_Items", index.Name); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +DROP INDEX [IX_Items] ON [JsonIndexEntities]; +""", + // + """ +CREATE JSON INDEX [IX_Items] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$[1].Other'); +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Change_json_index_path() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Value"], "IX_Items"), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Other"], "IX_Items"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_Items", index.Name); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +DROP INDEX [IX_Items] ON [JsonIndexEntities]; +""", + // + """ +CREATE JSON INDEX [IX_Items] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$[0].Other'); +"""); + } + + private class JsonIndexItem + { + public string Value { get; set; } = null!; + public string Other { get; set; } = null!; + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Create_json_index_over_whole_complex_collection() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Same(table.Columns.Single(c => c.Name == "ItemsJson"), Assert.Single(index.Columns)); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +CREATE JSON INDEX [IX_JsonIndexEntities_Items] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$'); +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Create_json_index_over_multiple_paths_in_same_column() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items[0].Value", "Items[0].Other"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + // Both properties map to the same JSON column — only one column entry + Assert.Same(table.Columns.Single(c => c.Name == "ItemsJson"), Assert.Single(index.Columns)); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +CREATE JSON INDEX [IX_JsonIndexEntities_Items_Value_Items_Other] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$[0].Value', N'$[0].Other'); +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Create_regular_index_on_json_column_and_regular_column() + { + // SQL Server does not allow an nvarchar(max) column to participate in a regular index. + await Assert.ThrowsAsync( + () => Test( + builder => builder.Entity( + "JsonRegularIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.Property("Name"); + e.ComplexProperty( + "Details", cb => + { + cb.ToJson("DetailsJson").HasColumnType("nvarchar(max)"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("JsonRegularIndexEntities").HasIndex("Name", "Details"), + model => { })); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Drop_json_index_over_complex_collection() + { + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + }); + }), + builder => builder.Entity("JsonIndexEntities").HasIndex(["Items[0].Value"], "IX_ItemsValue"), + builder => { }, + model => + { + var table = Assert.Single(model.Tables); + Assert.Empty(table.Indexes); + }); + + AssertSql( + """ +DROP INDEX [IX_ItemsValue] ON [JsonIndexEntities]; +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual async Task Create_json_index_over_multiple_paths_with_different_collection_indices() + { + // Two paths into the same JSON column but pointing at different array elements ([0] and [1]). + await Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items[0].Value", "Items[1].Other"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Same(table.Columns.Single(c => c.Name == "ItemsJson"), Assert.Single(index.Columns)); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + }); + + AssertSql( + """ +CREATE JSON INDEX [IX_JsonIndexEntities_Items_Value_Items_Other] ON [JsonIndexEntities]([ItemsJson]) FOR (N'$[0].Value', N'$[1].Other'); +"""); + } + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public virtual Task Create_json_index_over_multiple_paths_with_wildcard_and_indexer() + { + // Mix of "all elements" (Items[].Value) and a specific element (Items[0].Other) in one FOR clause. + // SQL Server rejects mixing [*] and [N] in the same JSON index FOR list. + return Assert.ThrowsAsync( + () => Test( + builder => builder.Entity( + "JsonIndexEntities", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.ComplexCollection, JsonIndexItem>( + "Items", cb => + { + cb.ToJson("ItemsJson").HasColumnType("json"); + cb.Property(i => i.Value); + cb.Property(i => i.Other); + }); + }), + builder => { }, + builder => builder.Entity("JsonIndexEntities").HasIndex("Items[].Value", "Items[0].Other"), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.NotNull(index[RelationalAnnotationNames.JsonIndexPaths]); + })); + } + + #endregion + #region Full-text search [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsFullTextSearchSupported))] diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs index 1b5d512d382..e626f662536 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/DbContextModelBuilder.cs @@ -79,7 +79,7 @@ private IRelationalModel CreateRelationalModel() microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("FlagsEnum2", flagsEnum2ColumnBase); var idColumnBase = new ColumnBase("Id", "bigint", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase); microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase.Columns.Add("Id", idColumnBase); - var manyOwned_DetailsColumnBase = new ColumnBase("ManyOwned_Details", "varchar(max)", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) + var manyOwned_DetailsColumnBase = new ColumnBase("ManyOwned_Details", "varchar(900)", microsoftEntityFrameworkCoreScaffoldingCompiledModelTestBasePrincipalBaseTableBase) { IsNullable = true }; @@ -561,7 +561,7 @@ private IRelationalModel CreateRelationalModel() }; principalBaseTable.Columns.Add("Deets", deetsColumn); deetsColumn.Accessors = ColumnAccessorsFactory.CreateGeneric(deetsColumn); - var dependentColumn = new JsonColumn("Dependent", "nvarchar(450)", principalBaseTable) + var dependentColumn = new JsonColumn("Dependent", "json", principalBaseTable) { IsNullable = true }; @@ -585,7 +585,7 @@ private IRelationalModel CreateRelationalModel() var flagsEnum2Column = new Column("FlagsEnum2", "int", principalBaseTable); principalBaseTable.Columns.Add("FlagsEnum2", flagsEnum2Column); flagsEnum2Column.Accessors = ColumnAccessorsFactory.CreateGeneric(flagsEnum2Column); - var manyOwnedColumn = new JsonColumn("ManyOwned", "nvarchar(max)", principalBaseTable) + var manyOwnedColumn = new JsonColumn("ManyOwned", "json", principalBaseTable) { IsNullable = true }; @@ -1899,6 +1899,15 @@ private IRelationalModel CreateRelationalModel() iX_PrincipalDerived_Dependent.MappedIndexes.Add(iX_PrincipalDerived_DependentIx); RelationalModel.GetOrCreateTableIndexes(iX_PrincipalDerived_DependentIx).Add(iX_PrincipalDerived_Dependent); principalBaseTable.Indexes.Add("IX_PrincipalDerived_Dependent", iX_PrincipalDerived_Dependent); + var iX_PrincipalDerived_ManyOwned_Indexer = new TableIndex( + "IX_PrincipalDerived_ManyOwned_Indexer", principalBaseTable, new[] { manyOwnedColumn }, false); + iX_PrincipalDerived_ManyOwned_Indexer.SetRowIndexValueFactory(new SimpleRowIndexValueFactory(iX_PrincipalDerived_ManyOwned_Indexer)); + var iX_PrincipalDerived_ManyOwned_IndexerIx = RelationalModel.GetIndex(this, + "Microsoft.EntityFrameworkCore.Scaffolding.CompiledModelTestBase+PrincipalDerived>", + "IX_PrincipalDerived_ManyOwned_Indexer"); + iX_PrincipalDerived_ManyOwned_Indexer.MappedIndexes.Add(iX_PrincipalDerived_ManyOwned_IndexerIx); + RelationalModel.GetOrCreateTableIndexes(iX_PrincipalDerived_ManyOwned_IndexerIx).Add(iX_PrincipalDerived_ManyOwned_Indexer); + principalBaseTable.Indexes.Add("IX_PrincipalDerived_ManyOwned_Indexer", iX_PrincipalDerived_ManyOwned_Indexer); var sqlQueryMappings0 = new List(); principalDerived.SetRuntimeAnnotation("Relational:SqlQueryMappings", sqlQueryMappings0); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs index 2083498d066..e5cb2ca5a07 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs @@ -35,7 +35,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas discriminatorValue: "PrincipalDerived>", propertyCount: 0, complexPropertyCount: 2, - namedIndexCount: 1); + namedIndexCount: 2); DependentComplexProperty.Create(runtimeEntityType); ManyOwnedComplexProperty.Create(runtimeEntityType); @@ -43,6 +43,11 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas new[] { runtimeEntityType.FindComplexProperty("Dependent") }, name: "IX_PrincipalDerived_Dependent"); + var iX_PrincipalDerived_ManyOwned_Indexer = runtimeEntityType.AddIndex( + new[] { runtimeEntityType.FindComplexProperty("ManyOwned").ComplexType.FindProperty("Details") }, + name: "IX_PrincipalDerived_ManyOwned_Indexer", + collectionIndices: [[0]]); + return runtimeEntityType; } @@ -148,7 +153,7 @@ public static RuntimeComplexProperty Create(RuntimeEntityType declaringType) id.AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.None); complexType.AddAnnotation("Relational:ContainerColumnName", "Dependent"); - complexType.AddAnnotation("Relational:ContainerColumnType", "nvarchar(450)"); + complexType.AddAnnotation("Relational:ContainerColumnType", "json"); complexType.AddAnnotation("Relational:FunctionName", null); complexType.AddAnnotation("Relational:Schema", null); complexType.AddAnnotation("Relational:SqlQuery", "select * from PrincipalBase"); @@ -277,8 +282,8 @@ public static RuntimeComplexProperty Create(RuntimeEntityType declaringType) int (string v) => ((object)v).GetHashCode(), string (string v) => v), mappingInfo: new RelationalTypeMappingInfo( - storeTypeName: "varchar(max)"), - storeTypePostfix: StoreTypePostfix.None); + storeTypeName: "varchar(900)", + size: 900)); details.AddAnnotation("foo", "bar"); details.AddAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.None); @@ -1150,6 +1155,7 @@ public static RuntimeComplexProperty Create(RuntimeEntityType declaringType) PrincipalComplexProperty.Create(complexType); complexType.AddAnnotation("go", "brr"); complexType.AddAnnotation("Relational:ContainerColumnName", "ManyOwned"); + complexType.AddAnnotation("Relational:ContainerColumnType", "json"); complexType.AddAnnotation("Relational:FunctionName", null); complexType.AddAnnotation("Relational:Schema", null); complexType.AddAnnotation("Relational:SqlQuery", "select * from PrincipalBase"); diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs index c0d3a2aa813..4c28fe420e1 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/CompiledModelSqlServerTest.cs @@ -338,6 +338,20 @@ protected override void AssertTpcSprocs(IModel model) Assert.Equal([alternateIndex], principalBaseId.GetContainingIndexes()); } + // ConditionalFact does not work on overridden test methods (xunit discovers the override via + // the base method's [Fact] and the conditional attribute is ignored). Use [Fact] + a runtime + // skip so the condition is honored when the override actually runs. + [Fact] + public override Task ComplexTypes() + { + if (!SqlServerTestEnvironment.IsJsonTypeSupported) + { + throw Xunit.Sdk.SkipException.ForSkip("Requires IsJsonTypeSupported"); + } + + return base.ComplexTypes(); + } + protected override void BuildComplexTypesModel(ModelBuilder modelBuilder) { base.BuildComplexTypesModel(modelBuilder); @@ -357,7 +371,8 @@ protected override void BuildComplexTypesModel(ModelBuilder modelBuilder) modelBuilder.Entity>>(eb => { - eb.ComplexProperty(p => p.Dependent, cb => cb.HasColumnType("nvarchar(450)")); + eb.ComplexProperty(p => p.Dependent, cb => cb.HasColumnType("json")); + eb.ComplexCollection, OwnedType>("ManyOwned", cb => cb.HasColumnType("json")); }); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs index 4c8a42dabc4..ac4e7423c9f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Scaffolding/SqlServerDatabaseModelFactoryTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Globalization; +using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.SqlServer.Diagnostics.Internal; @@ -2638,6 +2639,181 @@ jsonTypeColumn json NULL }, "DROP TABLE JsonColumns;"); + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_paths() + => Test( + @" +CREATE TABLE JsonIndexTable ( + Id int PRIMARY KEY, + Data json NULL +); +CREATE JSON INDEX IX_JsonIndexTable_Data ON JsonIndexTable(Data) FOR (N'$.Title', N'$.Posts.Rating');", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_JsonIndexTable_Data", index.Name); + Assert.Same(table.Columns.Single(c => c.Name == "Data"), Assert.Single(index.Columns)); + + var jsonInfo = Assert.IsType>(index[RelationalAnnotationNames.JsonIndexPaths]); + Assert.Equal("Data", jsonInfo.Item1); + Assert.Equal(["$.Posts.Rating", "$.Title"], jsonInfo.Item2); + }, + "DROP TABLE JsonIndexTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_with_single_path() + => Test( + @" +CREATE TABLE JsonSinglePathTable ( + Id int PRIMARY KEY, + Doc json NULL +); +CREATE JSON INDEX IX_JsonSinglePathTable_Doc ON JsonSinglePathTable(Doc) FOR (N'$.Name');", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_JsonSinglePathTable_Doc", index.Name); + Assert.Same(table.Columns.Single(c => c.Name == "Doc"), Assert.Single(index.Columns)); + + var jsonInfo = Assert.IsType>(index[RelationalAnnotationNames.JsonIndexPaths]); + Assert.Equal("Doc", jsonInfo.Item1); + Assert.Equal(["$.Name"], jsonInfo.Item2); + }, + "DROP TABLE JsonSinglePathTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_with_indexer_path() + => Test( + @" +CREATE TABLE JsonIndexerPathTable ( + Id int PRIMARY KEY, + Items json NULL +); +CREATE JSON INDEX IX_JsonIndexerPathTable_Items ON JsonIndexerPathTable(Items) FOR (N'$.Items[0].Value');", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_JsonIndexerPathTable_Items", index.Name); + + var jsonInfo = Assert.IsType>(index[RelationalAnnotationNames.JsonIndexPaths]); + Assert.Equal("Items", jsonInfo.Item1); + Assert.Equal(["$.Items[0].Value"], jsonInfo.Item2); + }, + "DROP TABLE JsonIndexerPathTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_alongside_regular_index_on_same_table() + => Test( + @" +CREATE TABLE JsonMixedIndexTable ( + Id int PRIMARY KEY, + Name nvarchar(100) NOT NULL, + Data json NULL +); +CREATE INDEX IX_JsonMixedIndexTable_Name ON JsonMixedIndexTable(Name); +CREATE JSON INDEX IX_JsonMixedIndexTable_Data ON JsonMixedIndexTable(Data) FOR (N'$.City');", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + Assert.Equal(2, table.Indexes.Count); + + var regular = Assert.Single(table.Indexes, i => i.Name == "IX_JsonMixedIndexTable_Name"); + Assert.Same(table.Columns.Single(c => c.Name == "Name"), Assert.Single(regular.Columns)); + Assert.Null(regular[RelationalAnnotationNames.JsonIndexPaths]); + + var json = Assert.Single(table.Indexes, i => i.Name == "IX_JsonMixedIndexTable_Data"); + Assert.Same(table.Columns.Single(c => c.Name == "Data"), Assert.Single(json.Columns)); + var jsonInfo = Assert.IsType>(json[RelationalAnnotationNames.JsonIndexPaths]); + Assert.Equal("Data", jsonInfo.Item1); + Assert.Equal(["$.City"], jsonInfo.Item2); + }, + "DROP TABLE JsonMixedIndexTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_no_JSON_index_annotation_when_no_json_index_exists() + => Test( + @" +CREATE TABLE JsonNoIndexTable ( + Id int PRIMARY KEY, + Data json NULL, + Name nvarchar(100) +); +CREATE INDEX IX_JsonNoIndexTable_Name ON JsonNoIndexTable(Name);", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + var index = Assert.Single(table.Indexes); + Assert.Equal("IX_JsonNoIndexTable_Name", index.Name); + Assert.Null(index[RelationalAnnotationNames.JsonIndexPaths]); + }, + "DROP TABLE JsonNoIndexTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_unique_JSON_index() + // SQL Server doesn't support UNIQUE on JSON indexes; the batch is rejected at parse time. + => Assert.Throws( + () => Test( + @" +CREATE TABLE JsonUniqueIndexTable ( + Id int PRIMARY KEY, + Data json NULL +); +CREATE UNIQUE JSON INDEX IX_JsonUniqueIndexTable_Data ON JsonUniqueIndexTable(Data) FOR (N'$.Name');", + [], + [], + (dbModel, scaffoldingFactory) => { }, + "DROP TABLE JsonUniqueIndexTable;")); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_with_fillfactor() + => Test( + @" +CREATE TABLE JsonFillFactorTable ( + Id int PRIMARY KEY, + Data json NULL +); +CREATE JSON INDEX IX_JsonFillFactorTable_Data ON JsonFillFactorTable(Data) FOR (N'$.Name') WITH (FILLFACTOR = 80);", + [], + [], + (dbModel, scaffoldingFactory) => + { + var table = dbModel.Tables.Single(); + var index = Assert.Single(table.Indexes); + Assert.False(index.IsUnique); + Assert.Equal(80, index[SqlServerAnnotationNames.FillFactor]); + }, + "DROP TABLE JsonFillFactorTable;"); + + [ConditionalFact(typeof(SqlServerTestEnvironment), nameof(SqlServerTestEnvironment.IsJsonTypeSupported))] + public void Scaffolds_JSON_index_with_filter() + // SQL Server doesn't support a WHERE filter on JSON indexes; the batch is rejected at parse time. + => Assert.Throws( + () => Test( + @" +CREATE TABLE JsonFilteredIndexTable ( + Id int PRIMARY KEY, + Discriminator int NOT NULL, + Data json NULL +); +CREATE JSON INDEX IX_JsonFilteredIndexTable_Data ON JsonFilteredIndexTable(Data) FOR (N'$.Name') WHERE [Discriminator] = 1;", + [], + [], + (dbModel, scaffoldingFactory) => { }, + "DROP TABLE JsonFilteredIndexTable;")); + [Fact] public void Specific_max_length_are_add_to_store_type() => Test( diff --git a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs index 206c4c61469..29e7aabbabc 100644 --- a/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs +++ b/test/EFCore.SqlServer.Tests/Infrastructure/SqlServerModelValidatorTest.cs @@ -1287,6 +1287,48 @@ public class VectorEntityWithNonVector public string NonVectorProperty { get; set; } } + [Fact] + [Experimental("EF9105")] + public virtual void Passes_for_vector_index_on_complex_type_property_not_mapped_to_json() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity(b => + { + b.ComplexProperty( + v => v.VectorContainer, + n => n.Property(v => v.Vector).HasMaxLength(3)); + b.HasVectorIndex("VectorContainer.Vector").HasMetric("cosine"); + }); + + Validate(modelBuilder); + } + + [Fact] + [Experimental("EF9105")] + public virtual void Throws_for_vector_index_on_complex_type_property_mapped_to_json() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity(b => + { + b.ComplexProperty( + v => v.VectorContainer, + n => + { + n.ToJson(); + n.Property(v => v.Vector).HasMaxLength(3); + }); + b.HasVectorIndex("VectorContainer.Vector").HasMetric("cosine"); + }); + + VerifyError( + SqlServerStrings.VectorPropertiesNotSupportedInJson( + "VectorInsideJsonEntity.VectorContainer#VectorContainer", + nameof(VectorContainer.Vector)), + modelBuilder); + } + #endregion Vector #region Full-text search diff --git a/test/EFCore.Tests/Extensions/ExpressionExtensionsTest.cs b/test/EFCore.Tests/Extensions/ExpressionExtensionsTest.cs index 425a5ec0800..aee41f0e486 100644 --- a/test/EFCore.Tests/Extensions/ExpressionExtensionsTest.cs +++ b/test/EFCore.Tests/Extensions/ExpressionExtensionsTest.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 Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.ModelBuilding; // ReSharper disable InconsistentNaming @@ -209,4 +210,132 @@ public void Get_member_access_list_should_throw_when_member_access_not_on_the_pr CoreStrings.InvalidMembersExpression(expression), Assert.Throws(() => expression.GetMemberAccessList()).Message); } + + private sealed class ComplexBlog + { + public string Title { get; set; } + public List Posts { get; set; } = []; + public ComplexPost[] PostArray { get; set; } = []; + } + + private sealed class ComplexPost + { + public string Title { get; set; } + public List Comments { get; set; } = []; + } + + private sealed class ComplexComment + { + public string Text { get; set; } + } + + [Fact] + public void MatchComplexMemberAccessList_handles_simple_member() + { + Expression> expression = b => b.Title; + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + var leaf = Assert.Single(members); + Assert.Equal(["Title"], leaf.Select(m => m.Name)); + Assert.Null(isCollection); + Assert.Null(collectionIndices); + } + + [Fact] + public void MatchComplexMemberAccessList_handles_select_over_complex_collection() + { + Expression> expression = b => b.Posts.Select(p => p.Title); + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + var leaf = Assert.Single(members); + Assert.Equal(["Posts", "Title"], leaf.Select(m => m.Name)); + Assert.NotNull(isCollection); + Assert.Equal([true, false], Assert.Single(isCollection)); + Assert.NotNull(collectionIndices); + Assert.Equal([null], Assert.Single(collectionIndices)); + } + + [Fact] + public void MatchComplexMemberAccessList_handles_nested_select() + { + Expression> expression = + b => b.Posts.Select(p => p.Comments.Select(c => c.Text)); + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + var leaf = Assert.Single(members); + Assert.Equal(["Posts", "Comments", "Text"], leaf.Select(m => m.Name)); + Assert.NotNull(isCollection); + Assert.Equal(new[] { true, true, false }, Assert.Single(isCollection)); + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { null, null }, Assert.Single(collectionIndices)); + } + + [Fact] + public void MatchComplexMemberAccessList_handles_list_indexer() + { + Expression> expression = b => b.Posts[0].Title; + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + var leaf = Assert.Single(members); + Assert.Equal(["Posts", "Title"], leaf.Select(m => m.Name)); + Assert.NotNull(isCollection); + Assert.Equal([true, false], Assert.Single(isCollection)); + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { 0 }, Assert.Single(collectionIndices)); + } + + [Fact] + public void MatchComplexMemberAccessList_handles_array_indexer() + { + Expression> expression = b => b.PostArray[2].Title; + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + var leaf = Assert.Single(members); + Assert.Equal(["PostArray", "Title"], leaf.Select(m => m.Name)); + Assert.NotNull(isCollection); + Assert.Equal([true, false], Assert.Single(isCollection)); + Assert.NotNull(collectionIndices); + Assert.Equal(new int?[] { 2 }, Assert.Single(collectionIndices)); + } + + [Fact] + public void MatchComplexMemberAccessList_handles_anonymous_with_mixed_leaves() + { + Expression> expression = + b => new { b.Title, Names = b.Posts.Select(p => p.Title) }; + + var (members, isCollection, collectionIndices) = expression.MatchComplexMemberAccessList(nameof(expression)); + + Assert.Equal(2, members.Count); + Assert.Equal(["Title"], members[0].Select(m => m.Name)); + Assert.Equal(["Posts", "Title"], members[1].Select(m => m.Name)); + Assert.NotNull(isCollection); + Assert.Equal([false], isCollection[0]); + Assert.Equal([true, false], isCollection[1]); + Assert.NotNull(collectionIndices); + Assert.Null(collectionIndices[0]); + Assert.Equal([null], collectionIndices[1]); + } + + [Fact] + public void MatchComplexMemberAccessList_throws_for_non_constant_indexer() + { + var idx = 1; + Expression> expression = b => b.Posts[idx].Title; + + Assert.Throws(() => expression.MatchComplexMemberAccessList(nameof(expression))); + } + + [Fact] + public void MatchComplexMemberAccessList_throws_for_unrelated_call() + { + Expression> expression = b => b.Posts.First().Title; + + Assert.Throws(() => expression.MatchComplexMemberAccessList(nameof(expression))); + } } diff --git a/test/EFCore.Tests/Metadata/Internal/IndexTest.cs b/test/EFCore.Tests/Metadata/Internal/IndexTest.cs index 520f6d36fe3..428e66cb0ec 100644 --- a/test/EFCore.Tests/Metadata/Internal/IndexTest.cs +++ b/test/EFCore.Tests/Metadata/Internal/IndexTest.cs @@ -120,4 +120,560 @@ private class Order public int Id { get; set; } } + + private sealed class Blog + { + public int Id { get; set; } + public string Title { get; set; } + public List Posts { get; set; } = []; + public Address Owner { get; set; } + } + + private sealed class Post + { + public string Title { get; set; } + public int Rating { get; set; } + public List Comments { get; set; } = []; + } + + private sealed class Comment + { + public string Text { get; set; } + } + + private sealed class Address + { + public string City { get; set; } + public string Country { get; set; } + } + + private static ModelBuilder CreateComplexModelBuilder() + { + var modelBuilder = new ModelBuilder(); + modelBuilder.Entity(b => + { + b.Property(e => e.Title); + + b.ComplexProperty(e => e.Owner, cb => + { + cb.Property(a => a.City); + cb.Property(a => a.Country); + }); + + b.ComplexCollection(e => e.Posts, cb => + { + cb.Property(p => p.Title); + cb.Property(p => p.Rating); + cb.ComplexCollection(p => p.Comments, ccb => ccb.Property(c => c.Text)); + }); + }); + + return modelBuilder; + } + + [Theory] + [InlineData("Posts[")] // unterminated bracket + [InlineData("Posts[abc].Title")] // non-numeric index + [InlineData("Posts[-1].Title")] // negative index + [InlineData("Posts[].")] // empty trailing segment + [InlineData(".Title")] // empty leading segment + [InlineData("[0].Title")] // bracket at start of segment + [InlineData("")] // empty path + public void MatchComplexPath_rejects_invalid_path(string path) + { + Assert.Null(InternalTypeBaseBuilder.MatchComplexPath(path)); + } + + [Theory] + [InlineData("Posts[].Title")] + [InlineData("Posts[*].Title")] + public void MatchComplexPath_accepts_all_elements_syntaxes(string path) + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath(path); + Assert.NotNull(parsed); + Assert.Equal(["Posts", "Title"], parsed.Value.MemberNames); + Assert.Equal([true, false], parsed.Value.IsCollection); + Assert.Equal([null], parsed.Value.CollectionIndices); + } + + [Fact] + public void MatchComplexPath_preserves_collection_flag_on_leaf() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Posts[]"); + Assert.NotNull(parsed); + Assert.Equal(["Posts"], parsed.Value.MemberNames); + Assert.Equal([true], parsed.Value.IsCollection); + Assert.Equal([null], parsed.Value.CollectionIndices); + } + + [Fact] + public void MatchComplexPath_preserves_indexer_on_leaf() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Posts[3]"); + Assert.NotNull(parsed); + Assert.Equal(["Posts"], parsed.Value.MemberNames); + Assert.Equal([true], parsed.Value.IsCollection); + Assert.Equal([3], parsed.Value.CollectionIndices); + } + + [Fact] + public void MatchComplexPath_single_scalar_member_emits_single_flag() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Title"); + Assert.NotNull(parsed); + Assert.Equal(["Title"], parsed.Value.MemberNames); + Assert.Equal([false], parsed.Value.IsCollection); + Assert.Null(parsed.Value.CollectionIndices); + } + + [Fact] + public void FindIndex_with_collection_indices_returns_matching_json_path_index() + { + // (Properties, CollectionIndices) form the full identity of an unnamed JSON-path index. + // FindIndex(properties, CI) must locate it; the no-CI overload should not, because that overload + // looks up the "plain" (CI=null) index identity. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Entity().Metadata; + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + entityType.AddIndex( + [titleProp], + [[0]], + ConfigurationSource.Explicit); + + var found = entityType.FindIndex([titleProp], [[0]]); + Assert.NotNull(found); + Assert.Equal([0], Assert.Single(found.CollectionIndices!)); + + Assert.Null(entityType.FindIndex([titleProp], [[null]])); + Assert.Null(entityType.FindIndex([titleProp])); + } + + [Fact] + public void FindIndex_without_collection_indices_returns_plain_index_only() + { + // When both a plain index and a JSON-path index exist over the same leaf, FindIndex(properties) + // should return the plain (CI=null) one — JSON-path indexes are addressable only via the + // (properties, CI) overload. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Entity().Metadata; + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + entityType.AddIndex( + [titleProp], + [[0]], + ConfigurationSource.Explicit); + entityType.AddIndex([titleProp], ConfigurationSource.Explicit); + + var foundPlain = entityType.FindIndex([titleProp]); + Assert.NotNull(foundPlain); + Assert.Null(foundPlain.CollectionIndices); + + var foundJson = entityType.FindIndex( + [titleProp], [[0]]); + Assert.NotNull(foundJson); + Assert.Equal([0], Assert.Single(foundJson.CollectionIndices!)); + } + + [Fact] + public void AddIndex_unnamed_with_different_collection_indices_does_not_throw_duplicate() + { + // Adding two unnamed indexes with the same Properties but different CollectionIndices via the + // internal AddIndex API succeeds because their UnnamedIndexKey identities differ. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Entity().Metadata; + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + + entityType.AddIndex( + [titleProp], + [[0]], + ConfigurationSource.Explicit); + + entityType.AddIndex( + [titleProp], + [[1]], + ConfigurationSource.Explicit); + + Assert.Equal(2, entityType.GetIndexes().Count()); + } + + [Fact] + public void NormalizeCollectionIndices_throws_when_entry_length_exceeds_collection_count() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + + // Posts is a single complex collection, so the entry for a leaf inside it should have exactly 1 element. + // Providing 2 elements should throw. + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + var tooManyIndices = new IReadOnlyList[] { [null, null] }; + + var ex = Assert.Throws( + () => new Index( + [titleProp], tooManyIndices, entityType, ConfigurationSource.Explicit)); + + Assert.Contains(CoreStrings.InvalidCollectionIndicesEntryLength("Title", "{'" + titleProp.Name + "'}", 2, 1), ex.Message); + } + + [Fact] + public void NormalizeCollectionIndices_throws_when_entry_length_is_zero_for_collection_property() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + + // Posts is a single complex collection, so providing an empty entry (0 elements) should throw. + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + var emptyIndices = new IReadOnlyList[] { Array.Empty() }; + + var ex = Assert.Throws( + () => new Index( + [titleProp], emptyIndices, entityType, ConfigurationSource.Explicit)); + + Assert.Contains(CoreStrings.InvalidCollectionIndicesEntryLength("Title", "{'" + titleProp.Name + "'}", 0, 1), ex.Message); + } + + [Fact] + public void NormalizeCollectionIndices_throws_when_non_null_entry_for_non_collection_property() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + + // Owner.City is NOT inside a complex collection, so the entry should be null (0 collection segments). + // Providing a non-null entry with 1 element should throw. + var cityProp = (PropertyBase)entityType.FindComplexProperty("Owner")!.ComplexType.FindProperty("City")!; + var wrongIndices = new IReadOnlyList[] { [null] }; + + var ex = Assert.Throws( + () => new Index( + [cityProp], wrongIndices, entityType, ConfigurationSource.Explicit)); + + Assert.Contains(CoreStrings.InvalidCollectionIndicesEntryLength("City", "{'" + cityProp.Name + "'}", 1, 0), ex.Message); + } + + [Fact] + public void NormalizeCollectionIndices_accepts_correct_entry_length_for_collection_property() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + var correctIndices = new IReadOnlyList[] { [null] }; + + var index = new Index( + [titleProp], correctIndices, entityType, ConfigurationSource.Explicit); + + Assert.NotNull(index.CollectionIndices); + Assert.Equal([null], Assert.Single(index.CollectionIndices)); + } + + [Fact] + public void NormalizeCollectionIndices_counts_leaf_complex_collection_property() + { + // When the indexed leaf itself is a complex collection (string path "Posts[]" / "Posts[3]"), + // CollectionIndices must carry a single entry for that leaf. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var postsProp = (PropertyBase)entityType.FindComplexProperty("Posts")!; + + var index = new Index( + [postsProp], [[3]], entityType, ConfigurationSource.Explicit); + + Assert.NotNull(index.CollectionIndices); + Assert.Equal([3], Assert.Single(index.CollectionIndices)); + } + + [Fact] + public void NormalizeCollectionIndices_throws_when_entry_length_mismatch_for_leaf_complex_collection_property() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var postsProp = (PropertyBase)entityType.FindComplexProperty("Posts")!; + var tooManyIndices = new IReadOnlyList[] { [null, null] }; + + var ex = Assert.Throws( + () => new Index( + [postsProp], tooManyIndices, entityType, ConfigurationSource.Explicit)); + + Assert.Contains(CoreStrings.InvalidCollectionIndicesEntryLength("Posts", "{'" + postsProp.Name + "'}", 2, 1), ex.Message); + } + + [Fact] + public void GetOrCreateProperties_returns_existing_complex_property_as_leaf_for_string_path() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityTypeBuilder = ((EntityType)modelBuilder.Entity().Metadata).Builder; + + var properties = entityTypeBuilder.GetOrCreateProperties( + [["Owner"]], + isCollection: null, + ConfigurationSource.Explicit); + + Assert.NotNull(properties); + var leaf = Assert.Single(properties); + Assert.IsType(leaf); + Assert.Equal("Owner", leaf.Name); + } + + [Fact] + public void GetOrCreateProperties_returns_existing_complex_collection_as_leaf_for_string_path() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityTypeBuilder = ((EntityType)modelBuilder.Entity().Metadata).Builder; + + var properties = entityTypeBuilder.GetOrCreateProperties( + [["Posts"]], + isCollection: null, + ConfigurationSource.Explicit); + + Assert.NotNull(properties); + var leaf = Assert.Single(properties); + var leafComplex = Assert.IsType(leaf); + Assert.True(leafComplex.IsCollection); + Assert.Equal("Posts", leaf.Name); + } + + [Fact] + public void GetOrCreateProperties_returns_existing_complex_property_as_leaf_for_member_chain() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityTypeBuilder = ((EntityType)modelBuilder.Entity().Metadata).Builder; + var ownerMember = typeof(Blog).GetProperty(nameof(Blog.Owner))!; + + var properties = entityTypeBuilder.GetOrCreateProperties( + [[ownerMember]], + isCollection: null, + ConfigurationSource.Explicit); + + Assert.NotNull(properties); + var leaf = Assert.Single(properties); + Assert.IsType(leaf); + Assert.Equal("Owner", leaf.Name); + } + + [Fact] + public void GetOrCreateProperties_returns_existing_complex_collection_as_leaf_for_member_chain() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityTypeBuilder = ((EntityType)modelBuilder.Entity().Metadata).Builder; + var postsMember = typeof(Blog).GetProperty(nameof(Blog.Posts))!; + + var properties = entityTypeBuilder.GetOrCreateProperties( + [[postsMember]], + isCollection: null, + ConfigurationSource.Explicit); + + Assert.NotNull(properties); + var leaf = Assert.Single(properties); + var leafComplex = Assert.IsType(leaf); + Assert.True(leafComplex.IsCollection); + Assert.Equal("Posts", leaf.Name); + } + + [Fact] + public void MatchComplexPath_parses_nested_complex_collections() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Posts[0].Comments[1].Text"); + Assert.NotNull(parsed); + Assert.Equal(["Posts", "Comments", "Text"], parsed.Value.MemberNames); + Assert.Equal([true, true, false], parsed.Value.IsCollection); + Assert.Equal([0, 1], parsed.Value.CollectionIndices); + } + + [Fact] + public void MatchComplexPath_parses_nested_complex_collections_all_elements() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Posts[].Comments[].Text"); + Assert.NotNull(parsed); + Assert.Equal(["Posts", "Comments", "Text"], parsed.Value.MemberNames); + Assert.Equal([true, true, false], parsed.Value.IsCollection); + Assert.Equal([null, null], parsed.Value.CollectionIndices); + } + + [Fact] + public void MatchComplexPath_parses_mixed_indexer_and_wildcard() + { + var parsed = InternalTypeBaseBuilder.MatchComplexPath("Posts[2].Comments[*].Text"); + Assert.NotNull(parsed); + Assert.Equal([2, null], parsed.Value.CollectionIndices); + } + + [Fact] + public void NormalizeCollectionIndices_counts_nested_complex_collections_in_path() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var textProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType + .FindComplexProperty("Comments")!.ComplexType.FindProperty("Text")!; + + // Two collection traversals (Posts, Comments) precede Text — entry must have 2 elements. + var index = new Index( + [textProp], [[0, 1]], entityType, ConfigurationSource.Explicit); + + Assert.NotNull(index.CollectionIndices); + Assert.Equal([0, 1], Assert.Single(index.CollectionIndices)); + } + + [Fact] + public void NormalizeCollectionIndices_throws_for_nested_collections_when_entry_length_is_wrong() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var textProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType + .FindComplexProperty("Comments")!.ComplexType.FindProperty("Text")!; + + var ex = Assert.Throws( + () => new Index([textProp], [[0]], entityType, ConfigurationSource.Explicit)); + + Assert.Contains( + CoreStrings.InvalidCollectionIndicesEntryLength("Text", "{'" + textProp.Name + "'}", 1, 2), + ex.Message); + } + + [Fact] + public void Detach_and_reattach_preserves_index_with_collection_indices() + { + // Reattachment goes through InternalIndexBuilder.RequiresComplexReattach when the index targets + // properties inside a complex chain or carries collection indices. Verify the round-trip preserves + // Properties, CollectionIndices, Name, and IsUnique. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + + var original = entityType.AddIndex([titleProp], [[5]], "IX_Reattach", ConfigurationSource.Explicit); + original.SetIsUnique(true, ConfigurationSource.Explicit); + + var detached = InternalEntityTypeBuilder.DetachIndex(original); + Assert.Null(entityType.FindIndex("IX_Reattach")); + + var reattached = detached.Attach(entityType.Builder); + Assert.NotNull(reattached); + + var newIndex = entityType.FindIndex("IX_Reattach")!; + Assert.Same(reattached.Metadata, newIndex); + Assert.Equal("IX_Reattach", newIndex.Name); + Assert.True(newIndex.IsUnique); + Assert.Same(titleProp, Assert.Single(newIndex.Properties)); + Assert.NotNull(newIndex.CollectionIndices); + Assert.Equal([5], Assert.Single(newIndex.CollectionIndices!)); + } + + [Fact] + public void Detach_and_reattach_preserves_unnamed_index_with_collection_indices() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var titleProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType.FindProperty("Title")!; + + var original = entityType.AddIndex([titleProp], [[null]], ConfigurationSource.Explicit); + + var detached = InternalEntityTypeBuilder.DetachIndex(original); + Assert.Null(entityType.FindIndex([titleProp], [[null]])); + + var reattached = detached.Attach(entityType.Builder); + Assert.NotNull(reattached); + + var newIndex = entityType.FindIndex([titleProp], [[null]])!; + Assert.Same(reattached.Metadata, newIndex); + Assert.Null(newIndex.Name); + Assert.NotNull(newIndex.CollectionIndices); + Assert.Equal(new int?[] { null }, Assert.Single(newIndex.CollectionIndices!)); + } + + [Fact] + public void Detach_and_reattach_preserves_index_through_nested_complex_collections() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var textProp = (PropertyBase)entityType.FindComplexProperty("Posts")!.ComplexType + .FindComplexProperty("Comments")!.ComplexType.FindProperty("Text")!; + + var original = entityType.AddIndex([textProp], [[0, 1]], "IX_NestedReattach", ConfigurationSource.Explicit); + + var detached = InternalEntityTypeBuilder.DetachIndex(original); + var reattached = detached.Attach(entityType.Builder); + Assert.NotNull(reattached); + + var newIndex = entityType.FindIndex("IX_NestedReattach")!; + Assert.Same(reattached.Metadata, newIndex); + Assert.Same(textProp, Assert.Single(newIndex.Properties)); + Assert.NotNull(newIndex.CollectionIndices); + Assert.Equal([0, 1], Assert.Single(newIndex.CollectionIndices!)); + } + + [Fact] + public void Detach_and_reattach_preserves_index_on_leaf_complex_collection_property() + { + // RequiresComplexReattach writes chainFlags[depth] = false even when the leaf is itself a + // complex collection (because the leaf is the indexed property, not a traversal step). The + // leaf still resolves correctly through FindMember on the rebuilt entity type, and the + // single CollectionIndices entry for the leaf must round-trip. + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var postsProp = (PropertyBase)entityType.FindComplexProperty("Posts")!; + + var original = entityType.AddIndex([postsProp], [[7]], "IX_LeafCollectionReattach", ConfigurationSource.Explicit); + + var detached = InternalEntityTypeBuilder.DetachIndex(original); + var reattached = detached.Attach(entityType.Builder); + Assert.NotNull(reattached); + + var newIndex = entityType.FindIndex("IX_LeafCollectionReattach")!; + Assert.Same(reattached.Metadata, newIndex); + var leaf = Assert.Single(newIndex.Properties); + var leafComplex = Assert.IsType(leaf); + Assert.True(leafComplex.IsCollection); + Assert.Equal("Posts", leaf.Name); + Assert.NotNull(newIndex.CollectionIndices); + Assert.Equal([7], Assert.Single(newIndex.CollectionIndices!)); + } + + [Fact] + public void Detach_and_reattach_preserves_unnamed_index_on_leaf_complex_collection_property() + { + var modelBuilder = CreateComplexModelBuilder(); + var entityType = (EntityType)modelBuilder.Model.FindEntityType(typeof(Blog))!; + var postsProp = (PropertyBase)entityType.FindComplexProperty("Posts")!; + + var original = entityType.AddIndex([postsProp], [[null]], ConfigurationSource.Explicit); + + var detached = InternalEntityTypeBuilder.DetachIndex(original); + var reattached = detached.Attach(entityType.Builder); + Assert.NotNull(reattached); + + var newIndex = entityType.FindIndex([postsProp], [[null]])!; + Assert.Same(reattached.Metadata, newIndex); + Assert.Null(newIndex.Name); + Assert.Same(postsProp, Assert.Single(newIndex.Properties)); + Assert.NotNull(newIndex.CollectionIndices); + Assert.Equal(new int?[] { null }, Assert.Single(newIndex.CollectionIndices!)); + } + + private class InheritedBase + { + public int Id { get; set; } + } + + private sealed class InheritedDerived : InheritedBase + { + public string OnlyOnDerived { get; set; } = null!; + } + + [Fact] + public void FindMember_used_by_GetOrCreateProperties_does_not_match_derived_entity_type_members() + { + // Index resolution must only see members reachable from `this` (this type + base types), not + // members declared on derived types. Verify the lookup primitives behave that way: FindMember + // returns null while FindMembersInHierarchy still finds the derived-type member. + var modelBuilder = new ModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity( + b => + { + b.HasBaseType(); + b.Property(d => d.OnlyOnDerived); + }); + + var baseType = (EntityType)modelBuilder.Model.FindEntityType(typeof(InheritedBase))!; + + Assert.Null(baseType.FindMember(nameof(InheritedDerived.OnlyOnDerived))); + Assert.NotNull(baseType.FindMembersInHierarchy(nameof(InheritedDerived.OnlyOnDerived)).FirstOrDefault()); + } }