diff --git a/src/EFCore.Abstractions/IndexAttribute.cs b/src/EFCore.Abstractions/IndexAttribute.cs index c60caefcb14..4b93178c115 100644 --- a/src/EFCore.Abstractions/IndexAttribute.cs +++ b/src/EFCore.Abstractions/IndexAttribute.cs @@ -15,8 +15,9 @@ namespace Microsoft.EntityFrameworkCore; [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] public sealed class IndexAttribute : Attribute { - private bool? _isUnique; private string? _name; + private bool? _isUnique; + private bool[]? _isDescending; /// /// Initializes a new instance of the class. @@ -54,6 +55,24 @@ public bool IsUnique set => _isUnique = value; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + public bool[]? IsDescending + { + get => _isDescending; + set + { + if (value is not null && value.Length != PropertyNames.Count) + { + throw new ArgumentException( + AbstractionsStrings.InvalidNumberOfIndexSortOrderValues(value.Length, PropertyNames.Count), nameof(IsDescending)); + } + + _isDescending = value; + } + } + /// /// Checks whether has been explicitly set to a value. /// diff --git a/src/EFCore.Abstractions/Properties/AbstractionsStrings.Designer.cs b/src/EFCore.Abstractions/Properties/AbstractionsStrings.Designer.cs index 9c2fdfcc201..059dddee5cf 100644 --- a/src/EFCore.Abstractions/Properties/AbstractionsStrings.Designer.cs +++ b/src/EFCore.Abstractions/Properties/AbstractionsStrings.Designer.cs @@ -52,6 +52,14 @@ public static string CollectionArgumentIsEmpty(object? argumentName) GetString("CollectionArgumentIsEmpty", nameof(argumentName)), argumentName); + /// + /// Invalid number of index sort order values: {numValues} values were provided, but the index has {numProperties} properties. + /// + public static string InvalidNumberOfIndexSortOrderValues(object? numValues, object? numProperties) + => string.Format( + GetString("CollectionArgumentIsEmpty", nameof(numValues), nameof(numProperties)), + numValues, numProperties); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name)!; diff --git a/src/EFCore.Abstractions/Properties/AbstractionsStrings.resx b/src/EFCore.Abstractions/Properties/AbstractionsStrings.resx index 7bdf6f59663..568f47d97cd 100644 --- a/src/EFCore.Abstractions/Properties/AbstractionsStrings.resx +++ b/src/EFCore.Abstractions/Properties/AbstractionsStrings.resx @@ -129,4 +129,7 @@ The collection argument '{argumentName}' must contain at least one element. + + Invalid number of index sort order values: {numValues} values were provided, but the index has {numProperties} properties. + \ No newline at end of file diff --git a/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs index 15380528cbb..b8aa23b7459 100644 --- a/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs +++ b/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs @@ -911,6 +911,14 @@ protected virtual void Generate(CreateIndexOperation operation, IndentedStringBu .Append("unique: true"); } + if (operation.IsDescending is not null) + { + builder + .AppendLine(",") + .Append("descending: ") + .Append(Code.Literal(operation.IsDescending)); + } + if (operation.Filter != null) { builder diff --git a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs index 981cffe17a9..533cb70ebf9 100644 --- a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs +++ b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs @@ -634,6 +634,15 @@ protected virtual void GenerateIndex( .Append(".IsUnique()"); } + if (index.IsDescending is not null) + { + stringBuilder + .AppendLine() + .Append(".IsDescending(") + .Append(string.Join(", ", index.IsDescending.Select(Code.Literal))) + .Append(')'); + } + GenerateIndexAnnotations(indexBuilderName, index, stringBuilder); } diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs index cd186897fe9..78e4ff45d77 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs @@ -567,6 +567,11 @@ private void GenerateIndex(IIndex index) lines.Add($".{nameof(IndexBuilder.IsUnique)}()"); } + if (index.IsDescending is not null) + { + lines.Add($".{nameof(IndexBuilder.IsDescending)}({string.Join(", ", index.IsDescending.Select(d => _code.Literal(d)))})"); + } + GenerateAnnotations(index, annotations, lines); AppendMultiLineFluentApi(index.DeclaringEntityType, lines); diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs index be657806d10..bade36e8011 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpEntityTypeGenerator.cs @@ -212,6 +212,11 @@ private void GenerateIndexAttributes(IEntityType entityType) indexAttribute.AddParameter($"{nameof(IndexAttribute.IsUnique)} = {_code.Literal(index.IsUnique)}"); } + if (index.IsDescending is not null) + { + indexAttribute.AddParameter($"{nameof(IndexAttribute.IsDescending)} = {_code.UnknownLiteral(index.IsDescending)}"); + } + _sb.AppendLine(indexAttribute.ToString()); } } diff --git a/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs b/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs index fc26a105b1d..839dfbcf7c4 100644 --- a/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs +++ b/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs @@ -627,9 +627,14 @@ protected virtual EntityTypeBuilder VisitIndexes(EntityTypeBuilder builder, ICol indexBuilder = indexBuilder.IsUnique(index.IsUnique); + if (index.IsDescending.Any(desc => desc)) + { + indexBuilder = indexBuilder.IsDescending(index.IsDescending.ToArray()); + } + if (index.Filter != null) { - indexBuilder.HasFilter(index.Filter); + indexBuilder = indexBuilder.HasFilter(index.Filter); } indexBuilder.Metadata.AddAnnotations(index.GetAnnotations()); diff --git a/src/EFCore.Relational/Metadata/ITableIndex.cs b/src/EFCore.Relational/Metadata/ITableIndex.cs index 93608beeadf..86ab50b304b 100644 --- a/src/EFCore.Relational/Metadata/ITableIndex.cs +++ b/src/EFCore.Relational/Metadata/ITableIndex.cs @@ -39,6 +39,11 @@ public interface ITableIndex : IAnnotatable /// bool IsUnique { get; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + IReadOnlyList? IsDescending { get; } + /// /// Gets the expression used as the index filter. /// @@ -70,8 +75,21 @@ string ToDebugString(MetadataDebugStringOptions options = MetadataDebugStringOpt builder .Append(Name) - .Append(' ') - .Append(ColumnBase.Format(Columns)); + .Append(" {") + .AppendJoin( + ", ", + Enumerable.Range(0, Columns.Count) + .Select( + i => + $@"'{Columns[i].Name}'{( + MappedIndexes.First() is not RuntimeIndex + && IsDescending is not null + && i < IsDescending.Count + && IsDescending[i] + ? " Desc" + : "" + )}")) + .Append('}'); if (IsUnique) { diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs b/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs index fdf330b8323..2dd404a4500 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalIndexExtensions.cs @@ -32,9 +32,9 @@ public static bool AreCompatible( { throw new InvalidOperationException( RelationalStrings.DuplicateIndexTableMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.GetDatabaseName(storeObject), index.DeclaringEntityType.GetSchemaQualifiedTableName(), @@ -50,9 +50,9 @@ public static bool AreCompatible( { throw new InvalidOperationException( RelationalStrings.DuplicateIndexColumnMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject), @@ -69,9 +69,29 @@ public static bool AreCompatible( { throw new InvalidOperationException( RelationalStrings.DuplicateIndexUniquenessMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), + duplicateIndex.DeclaringEntityType.DisplayName(), + index.DeclaringEntityType.GetSchemaQualifiedTableName(), + index.GetDatabaseName(storeObject))); + } + + return false; + } + + if (index.IsDescending is null != duplicateIndex.IsDescending is null + || (index.IsDescending is not null + && duplicateIndex.IsDescending is not null + && !index.IsDescending.SequenceEqual(duplicateIndex.IsDescending))) + { + if (shouldThrow) + { + throw new InvalidOperationException( + RelationalStrings.DuplicateIndexSortOrdersMismatch( + index.DisplayName(), + index.DeclaringEntityType.DisplayName(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject))); @@ -82,16 +102,21 @@ public static bool AreCompatible( if (index.GetFilter(storeObject) != duplicateIndex.GetFilter(storeObject)) { - throw new InvalidOperationException( - RelationalStrings.DuplicateIndexFiltersMismatch( - index.Properties.Format(), - index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), - duplicateIndex.DeclaringEntityType.DisplayName(), - index.DeclaringEntityType.GetSchemaQualifiedTableName(), - index.GetDatabaseName(storeObject), - index.GetFilter(), - duplicateIndex.GetFilter())); + if (shouldThrow) + { + throw new InvalidOperationException( + RelationalStrings.DuplicateIndexFiltersMismatch( + index.DisplayName(), + index.DeclaringEntityType.DisplayName(), + duplicateIndex.DisplayName(), + duplicateIndex.DeclaringEntityType.DisplayName(), + index.DeclaringEntityType.GetSchemaQualifiedTableName(), + index.GetDatabaseName(storeObject), + index.GetFilter(), + duplicateIndex.GetFilter())); + } + + return false; } return true; diff --git a/src/EFCore.Relational/Metadata/Internal/TableIndex.cs b/src/EFCore.Relational/Metadata/Internal/TableIndex.cs index a23021c34bb..318a1c97453 100644 --- a/src/EFCore.Relational/Metadata/Internal/TableIndex.cs +++ b/src/EFCore.Relational/Metadata/Internal/TableIndex.cs @@ -68,6 +68,10 @@ public override bool IsReadOnly /// public virtual bool IsUnique { get; } + /// + public virtual IReadOnlyList? IsDescending + => MappedIndexes.First().IsDescending; + /// public virtual string? Filter => MappedIndexes.First().GetFilter(StoreObjectIdentifier.Table(Table.Name, Table.Schema)); diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index 5fae40d6d3f..cba40a8ebbd 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1393,6 +1393,10 @@ protected virtual IEnumerable Diff( private bool IndexStructureEquals(ITableIndex source, ITableIndex target, DiffContext diffContext) => source.IsUnique == target.IsUnique + && ((source.IsDescending is null && target.IsDescending is null) + || (source.IsDescending is not null + && target.IsDescending is not null + && source.IsDescending.SequenceEqual(target.IsDescending))) && source.Filter == target.Filter && !HasDifferences(source.GetAnnotations(), target.GetAnnotations()) && source.Columns.Select(p => p.Name).SequenceEqual( diff --git a/src/EFCore.Relational/Migrations/MigrationBuilder.cs b/src/EFCore.Relational/Migrations/MigrationBuilder.cs index fb6fb2f1b8f..d5c37ae52d2 100644 --- a/src/EFCore.Relational/Migrations/MigrationBuilder.cs +++ b/src/EFCore.Relational/Migrations/MigrationBuilder.cs @@ -601,6 +601,10 @@ public virtual AlterOperationBuilder AlterTable( /// The schema that contains the table, or to use the default schema. /// Indicates whether or not the index enforces uniqueness. /// The filter to apply to the index, or for no filter. + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// If , all columns will have ascending order. + /// /// A builder to allow annotations to be added to the operation. public virtual OperationBuilder CreateIndex( string name, @@ -608,14 +612,16 @@ public virtual OperationBuilder CreateIndex( string column, string? schema = null, bool unique = false, - string? filter = null) + string? filter = null, + bool[]? descending = null) => CreateIndex( name, table, new[] { Check.NotEmpty(column, nameof(column)) }, schema, unique, - filter); + filter, + descending); /// /// Builds a to create a new composite (multi-column) index. @@ -629,6 +635,10 @@ public virtual OperationBuilder CreateIndex( /// The schema that contains the table, or to use the default schema. /// Indicates whether or not the index enforces uniqueness. /// The filter to apply to the index, or for no filter. + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// If , all columns will have ascending order. + /// /// A builder to allow annotations to be added to the operation. public virtual OperationBuilder CreateIndex( string name, @@ -636,7 +646,8 @@ public virtual OperationBuilder CreateIndex( string[] columns, string? schema = null, bool unique = false, - string? filter = null) + string? filter = null, + bool[]? descending = null) { Check.NotEmpty(name, nameof(name)); Check.NotEmpty(table, nameof(table)); @@ -649,8 +660,10 @@ public virtual OperationBuilder CreateIndex( Name = name, Columns = columns, IsUnique = unique, + IsDescending = descending, Filter = filter }; + Operations.Add(operation); return new OperationBuilder(operation); diff --git a/src/EFCore.Relational/Migrations/MigrationsSqlGenerator.cs b/src/EFCore.Relational/Migrations/MigrationsSqlGenerator.cs index f08c9c69118..5528830b693 100644 --- a/src/EFCore.Relational/Migrations/MigrationsSqlGenerator.cs +++ b/src/EFCore.Relational/Migrations/MigrationsSqlGenerator.cs @@ -428,9 +428,11 @@ protected virtual void Generate( .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Name)) .Append(" ON ") .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Table, operation.Schema)) - .Append(" (") - .Append(ColumnList(operation.Columns)) - .Append(")"); + .Append(" ("); + + GenerateIndexColumnList(operation, model, builder); + + builder.Append(")"); IndexOptions(operation, model, builder); @@ -1659,23 +1661,41 @@ protected virtual void CheckConstraint( /// 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 virtual void IndexTraits( - MigrationOperation operation, - IModel? model, - MigrationCommandListBuilder builder) + protected virtual void IndexTraits(MigrationOperation operation, IModel? model, MigrationCommandListBuilder builder) { } + /// + /// Returns a SQL fragment for the column list 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 virtual void GenerateIndexColumnList(CreateIndexOperation operation, IModel? model, MigrationCommandListBuilder builder) + { + for (var i = 0; i < operation.Columns.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(operation.Columns[i])); + + if (operation.IsDescending is not null && i < operation.IsDescending.Length && operation.IsDescending[i]) + { + builder.Append(" DESC"); + } + } + } + /// /// 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 virtual void IndexOptions( - CreateIndexOperation operation, - IModel? model, - MigrationCommandListBuilder builder) + protected virtual void IndexOptions(CreateIndexOperation operation, IModel? model, MigrationCommandListBuilder builder) { if (!string.IsNullOrEmpty(operation.Filter)) { diff --git a/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs b/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs index 4b136221509..a156281704e 100644 --- a/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs +++ b/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs @@ -12,10 +12,8 @@ namespace Microsoft.EntityFrameworkCore.Migrations.Operations; [DebuggerDisplay("CREATE INDEX {Name} ON {Table}")] public class CreateIndexOperation : MigrationOperation, ITableMigrationOperation { - /// - /// Indicates whether or not the index should enforce uniqueness. - /// - public virtual bool IsUnique { get; set; } + private string[]? _columns; + private bool[]? _isDescending; /// /// The name of the index. @@ -35,7 +33,41 @@ public class CreateIndexOperation : MigrationOperation, ITableMigrationOperation /// /// The ordered list of column names for the column that make up the index. /// - public virtual string[] Columns { get; set; } = null!; + public virtual string[] Columns + { + get => _columns!; + set + { + if (_isDescending is not null && value.Length != _isDescending.Length) + { + throw new ArgumentException(RelationalStrings.CreateIndexOperationWithInvalidSortOrder(_isDescending.Length, value.Length)); + } + + _columns = value; + } + } + + /// + /// Indicates whether or not the index should enforce uniqueness. + /// + public virtual bool IsUnique { get; set; } + + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + public virtual bool[]? IsDescending + { + get => _isDescending; + set + { + if (value is not null && _columns is not null && value.Length != _columns.Length) + { + throw new ArgumentException(RelationalStrings.CreateIndexOperationWithInvalidSortOrder(value.Length, _columns.Length)); + } + + _isDescending = value; + } + } /// /// An expression to use as the index filter. @@ -53,11 +85,12 @@ public static CreateIndexOperation CreateFrom(ITableIndex index) var operation = new CreateIndexOperation { - IsUnique = index.IsUnique, Name = index.Name, Schema = index.Table.Schema, Table = index.Table.Name, Columns = index.Columns.Select(p => p.Name).ToArray(), + IsUnique = index.IsUnique, + IsDescending = index.IsDescending?.ToArray(), Filter = index.Filter }; operation.AddAnnotations(index.GetAnnotations()); diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 7e7db5126aa..496cd075aaa 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -151,6 +151,14 @@ public static string ConflictingRowValuesSensitive(object? firstEntityType, obje GetString("ConflictingRowValuesSensitive", nameof(firstEntityType), nameof(secondEntityType), nameof(keyValue), nameof(firstConflictingValue), nameof(secondConflictingValue), nameof(column)), firstEntityType, secondEntityType, keyValue, firstConflictingValue, secondConflictingValue, column); + /// + /// {numSortOrderProperties} values were provided in CreateIndexOperations.IsDescending, but the operation has {numColumns} columns. + /// + public static string CreateIndexOperationWithInvalidSortOrder(object? numSortOrderProperties, object? numColumns) + => string.Format( + GetString("CreateIndexOperationWithInvalidSortOrder", nameof(numSortOrderProperties), nameof(numColumns)), + numSortOrderProperties, numColumns); + /// /// There is no property mapped to the column '{table}.{column}' which is used in a data operation. Either add a property mapped to this column, or specify the column types in the data operation. /// @@ -470,36 +478,44 @@ public static string DuplicateForeignKeyUniquenessMismatch(object? foreignKeyPro foreignKeyProperties1, entityType1, foreignKeyProperties2, entityType2, table, foreignKeyName); /// - /// The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different columns ({columnNames1} and {columnNames2}). + /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different columns ({columnNames1} and {columnNames2}). + /// + public static string DuplicateIndexColumnMismatch(object? index1, object? entityType1, object? index2, object? entityType2, object? table, object? indexName, object? columnNames1, object? columnNames2) + => string.Format( + GetString("DuplicateIndexColumnMismatch", nameof(index1), nameof(entityType1), nameof(index2), nameof(entityType2), nameof(table), nameof(indexName), nameof(columnNames1), nameof(columnNames2)), + index1, entityType1, index2, entityType2, table, indexName, columnNames1, columnNames2); + + /// + /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different filters ('{filter1}' and '{filter2}'). /// - public static string DuplicateIndexColumnMismatch(object? indexProperties1, object? entityType1, object? indexProperties2, object? entityType2, object? table, object? indexName, object? columnNames1, object? columnNames2) + public static string DuplicateIndexFiltersMismatch(object? index1, object? entityType1, object? index2, object? entityType2, object? table, object? indexName, object? filter1, object? filter2) => string.Format( - GetString("DuplicateIndexColumnMismatch", nameof(indexProperties1), nameof(entityType1), nameof(indexProperties2), nameof(entityType2), nameof(table), nameof(indexName), nameof(columnNames1), nameof(columnNames2)), - indexProperties1, entityType1, indexProperties2, entityType2, table, indexName, columnNames1, columnNames2); + GetString("DuplicateIndexFiltersMismatch", nameof(index1), nameof(entityType1), nameof(index2), nameof(entityType2), nameof(table), nameof(indexName), nameof(filter1), nameof(filter2)), + index1, entityType1, index2, entityType2, table, indexName, filter1, filter2); /// - /// The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different filters ('{filter1}' and '{filter2}'). + /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different sort orders. /// - public static string DuplicateIndexFiltersMismatch(object? indexProperties1, object? entityType1, object? indexProperties2, object? entityType2, object? table, object? indexName, object? filter1, object? filter2) + public static string DuplicateIndexSortOrdersMismatch(object? index1, object? entityType1, object? index2, object? entityType2, object? table, object? indexName) => string.Format( - GetString("DuplicateIndexFiltersMismatch", nameof(indexProperties1), nameof(entityType1), nameof(indexProperties2), nameof(entityType2), nameof(table), nameof(indexName), nameof(filter1), nameof(filter2)), - indexProperties1, entityType1, indexProperties2, entityType2, table, indexName, filter1, filter2); + GetString("DuplicateIndexSortOrdersMismatch", nameof(index1), nameof(entityType1), nameof(index2), nameof(entityType2), nameof(table), nameof(indexName)), + index1, entityType1, index2, entityType2, table, indexName); /// - /// The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{indexName}', but are declared on different tables ('{table1}' and '{table2}'). + /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{indexName}', but are declared on different tables ('{table1}' and '{table2}'). /// - public static string DuplicateIndexTableMismatch(object? indexProperties1, object? entityType1, object? indexProperties2, object? entityType2, object? indexName, object? table1, object? table2) + public static string DuplicateIndexTableMismatch(object? index1, object? entityType1, object? index2, object? entityType2, object? indexName, object? table1, object? table2) => string.Format( - GetString("DuplicateIndexTableMismatch", nameof(indexProperties1), nameof(entityType1), nameof(indexProperties2), nameof(entityType2), nameof(indexName), nameof(table1), nameof(table2)), - indexProperties1, entityType1, indexProperties2, entityType2, indexName, table1, table2); + GetString("DuplicateIndexTableMismatch", nameof(index1), nameof(entityType1), nameof(index2), nameof(entityType2), nameof(indexName), nameof(table1), nameof(table2)), + index1, entityType1, index2, entityType2, indexName, table1, table2); /// - /// The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different uniqueness configurations. + /// The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different uniqueness configurations. /// - public static string DuplicateIndexUniquenessMismatch(object? indexProperties1, object? entityType1, object? indexProperties2, object? entityType2, object? table, object? indexName) + public static string DuplicateIndexUniquenessMismatch(object? index1, object? entityType1, object? index2, object? entityType2, object? table, object? indexName) => string.Format( - GetString("DuplicateIndexUniquenessMismatch", nameof(indexProperties1), nameof(entityType1), nameof(indexProperties2), nameof(entityType2), nameof(table), nameof(indexName)), - indexProperties1, entityType1, indexProperties2, entityType2, table, indexName); + GetString("DuplicateIndexUniquenessMismatch", nameof(index1), nameof(entityType1), nameof(index2), nameof(entityType2), nameof(table), nameof(indexName)), + index1, entityType1, index2, entityType2, table, indexName); /// /// The keys {keyProperties1} on '{entityType1}' and {keyProperties2} on '{entityType2}' are both mapped to '{table}.{keyName}', but with different columns ({columnNames1} and {columnNames2}). diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 366f1912ce3..19e96b800f6 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -169,6 +169,9 @@ Instances of entity types '{firstEntityType}' and '{secondEntityType}' are mapped to the same row with the key value '{keyValue}', but have different property values '{firstConflictingValue}' and '{secondConflictingValue}' for the column '{column}'. + + {numSortOrderProperties} values were provided in CreateIndexOperations.IsDescending, but the operation has {numColumns} columns. + There is no property mapped to the column '{table}.{column}' which is used in a data operation. Either add a property mapped to this column, or specify the column types in the data operation. @@ -290,16 +293,19 @@ The foreign keys {foreignKeyProperties1} on '{entityType1}' and {foreignKeyProperties2} on '{entityType2}' are both mapped to '{table}.{foreignKeyName}', but with different uniqueness configurations. - The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different columns ({columnNames1} and {columnNames2}). + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different columns ({columnNames1} and {columnNames2}). - The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different filters ('{filter1}' and '{filter2}'). + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different filters ('{filter1}' and '{filter2}'). + + + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different sort orders. - The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{indexName}', but are declared on different tables ('{table1}' and '{table2}'). + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{indexName}', but are declared on different tables ('{table1}' and '{table2}'). - The indexes {indexProperties1} on '{entityType1}' and {indexProperties2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different uniqueness configurations. + The indexes {index1} on '{entityType1}' and {index2} on '{entityType2}' are both mapped to '{table}.{indexName}', but with different uniqueness configurations. The keys {keyProperties1} on '{entityType1}' and {keyProperties2} on '{entityType2}' are both mapped to '{table}.{keyName}', but with different columns ({columnNames1} and {columnNames2}). diff --git a/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs b/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs index 92de823af64..57f19fcc024 100644 --- a/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs +++ b/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs @@ -28,10 +28,15 @@ public class DatabaseIndex : Annotatable public virtual IList Columns { get; } = new List(); /// - /// Indicates whether or not the index constrains uniqueness. + /// Indicates whether or not the index enforces uniqueness. /// public virtual bool IsUnique { get; set; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + public virtual IList IsDescending { get; set; } = new List(); + /// /// The filter expression, or if the index has no filter. /// diff --git a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs index d4e4f6dff32..3d066e9c478 100644 --- a/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs +++ b/src/EFCore.SqlServer/Infrastructure/Internal/SqlServerModelValidator.cs @@ -163,7 +163,7 @@ protected virtual void ValidateIndexIncludeProperties( throw new InvalidOperationException( SqlServerStrings.IncludePropertyNotFound( notFound, - index.Name == null ? index.Properties.Format() : "'" + index.Name + "'", + index.DisplayName(), index.DeclaringEntityType.DisplayName())); } @@ -179,7 +179,7 @@ protected virtual void ValidateIndexIncludeProperties( SqlServerStrings.IncludePropertyDuplicated( index.DeclaringEntityType.DisplayName(), duplicateProperty, - index.Name == null ? index.Properties.Format() : "'" + index.Name + "'")); + index.DisplayName())); } var coveredProperty = includeProperties @@ -191,7 +191,7 @@ protected virtual void ValidateIndexIncludeProperties( SqlServerStrings.IncludePropertyInIndex( index.DeclaringEntityType.DisplayName(), coveredProperty, - index.Name == null ? index.Properties.Format() : "'" + index.Name + "'")); + index.DisplayName())); } } } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerIndexExtensions.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerIndexExtensions.cs index ed2791568b5..49f21891482 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerIndexExtensions.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerIndexExtensions.cs @@ -35,9 +35,9 @@ public static bool AreCompatibleForSqlServer( { throw new InvalidOperationException( SqlServerStrings.DuplicateIndexIncludedMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject), @@ -55,9 +55,9 @@ public static bool AreCompatibleForSqlServer( { throw new InvalidOperationException( SqlServerStrings.DuplicateIndexOnlineMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject))); @@ -72,9 +72,9 @@ public static bool AreCompatibleForSqlServer( { throw new InvalidOperationException( SqlServerStrings.DuplicateIndexClusteredMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject))); @@ -89,9 +89,9 @@ public static bool AreCompatibleForSqlServer( { throw new InvalidOperationException( SqlServerStrings.DuplicateIndexFillFactorMismatch( - index.Properties.Format(), + index.DisplayName(), index.DeclaringEntityType.DisplayName(), - duplicateIndex.Properties.Format(), + duplicateIndex.DisplayName(), duplicateIndex.DeclaringEntityType.DisplayName(), index.DeclaringEntityType.GetSchemaQualifiedTableName(), index.GetDatabaseName(storeObject))); diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 255e4fdc683..878e053f7cf 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -748,10 +748,9 @@ protected override void Generate( IndexTraits(operation, model, builder); - builder - .Append("(") - .Append(ColumnList(operation.Columns)) - .Append(")"); + builder.Append("("); + GenerateIndexColumnList(operation, model, builder); + builder.Append(")"); } else { diff --git a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs index 0c7d9b78c32..71c4e39fb73 100644 --- a/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs +++ b/src/EFCore.SqlServer/Scaffolding/Internal/SqlServerDatabaseModelFactory.cs @@ -932,6 +932,7 @@ private void GetIndexes(DbConnection connection, IReadOnlyList ta [i].[filter_definition], [i].[fill_factor], COL_NAME([ic].[object_id], [ic].[column_id]) AS [column_name], + [ic].[is_descending_key], [ic].[is_included_column] FROM [sys].[indexes] AS [i] JOIN [sys].[tables] AS [t] ON [i].[object_id] = [t].[object_id] @@ -1137,6 +1138,8 @@ bool TryGetIndex( return false; } + index.IsDescending.Add(dataRecord.GetValueOrDefault("is_descending_key")); + index.Columns.Add(column); } diff --git a/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs b/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs index fb527ae7c37..b2a50da6bbf 100644 --- a/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs +++ b/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs @@ -458,8 +458,9 @@ private void GetIndexes(DbConnection connection, DatabaseTable table) using (var command2 = connection.CreateCommand()) { command2.CommandText = new StringBuilder() - .AppendLine("SELECT \"name\"") - .AppendLine("FROM pragma_index_info(@index)") + .AppendLine("SELECT \"name\", \"desc\"") + .AppendLine("FROM pragma_index_xinfo(@index)") + .AppendLine("WHERE key = 1") .AppendLine("ORDER BY \"seqno\";") .ToString(); @@ -477,6 +478,7 @@ private void GetIndexes(DbConnection connection, DatabaseTable table) Check.DebugAssert(column != null, "column is null."); index.Columns.Add(column); + index.IsDescending.Add(reader2.GetBoolean(1)); } } diff --git a/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs b/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs index be420be010c..93d7dafa6f8 100644 --- a/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs +++ b/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs @@ -27,18 +27,30 @@ public interface IConventionIndexBuilder : IConventionAnnotatableBuilder /// /// A value indicating whether the index is unique. /// Indicates whether the configuration was specified using a data annotation. - /// - /// The same builder instance if the uniqueness was configured, - /// otherwise. - /// + /// The same builder instance if the uniqueness was configured, otherwise. IConventionIndexBuilder? IsUnique(bool? unique, bool fromDataAnnotation = false); /// - /// Returns a value indicating whether this index uniqueness can be configured - /// from the current configuration source. + /// Returns a value indicating whether this index uniqueness can be configured from the current configuration source. /// /// A value indicating whether the index is unique. /// Indicates whether the configuration was specified using a data annotation. /// if the index uniqueness can be configured. bool CanSetIsUnique(bool? unique, bool fromDataAnnotation = false); + + /// + /// Configures the sort order(s) for the columns of this index (ascending or descending). + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// Indicates whether the configuration was specified using a data annotation. + /// The same builder instance if the uniqueness was configured, otherwise. + IConventionIndexBuilder? IsDescending(IReadOnlyList? descending, bool fromDataAnnotation = false); + + /// + /// Returns a value indicating whether this index sort order can be configured from the current configuration source. + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// Indicates whether the configuration was specified using a data annotation. + /// if the index uniqueness can be configured. + bool CanSetIsDescending(IReadOnlyList? descending, bool fromDataAnnotation = false); } diff --git a/src/EFCore/Metadata/Builders/IndexBuilder.cs b/src/EFCore/Metadata/Builders/IndexBuilder.cs index 9f3f1e899cc..71779f59636 100644 --- a/src/EFCore/Metadata/Builders/IndexBuilder.cs +++ b/src/EFCore/Metadata/Builders/IndexBuilder.cs @@ -77,6 +77,18 @@ public virtual IndexBuilder IsUnique(bool unique = true) return this; } + /// + /// Configures the sort order(s) for the columns of this index (ascending or descending). + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// The same builder instance so that multiple configuration calls can be chained. + public virtual IndexBuilder IsDescending(params bool[] descending) + { + Builder.IsDescending(descending, ConfigurationSource.Explicit); + + return this; + } + #region Hidden System.Object members /// diff --git a/src/EFCore/Metadata/Builders/IndexBuilder`.cs b/src/EFCore/Metadata/Builders/IndexBuilder`.cs index 8bfc61e74b1..e8f36809e9e 100644 --- a/src/EFCore/Metadata/Builders/IndexBuilder`.cs +++ b/src/EFCore/Metadata/Builders/IndexBuilder`.cs @@ -49,4 +49,12 @@ public IndexBuilder(IMutableIndex index) /// The same builder instance so that multiple configuration calls can be chained. public new virtual IndexBuilder IsUnique(bool unique = true) => (IndexBuilder)base.IsUnique(unique); + + /// + /// Configures the sort order(s) for the columns of this index (ascending or descending). + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// The same builder instance so that multiple configuration calls can be chained. + public new virtual IndexBuilder IsDescending(params bool[] descending) + => (IndexBuilder)base.IsDescending(descending); } diff --git a/src/EFCore/Metadata/Conventions/ConventionSet.cs b/src/EFCore/Metadata/Conventions/ConventionSet.cs index 3d168974fb8..54cc3869801 100644 --- a/src/EFCore/Metadata/Conventions/ConventionSet.cs +++ b/src/EFCore/Metadata/Conventions/ConventionSet.cs @@ -207,6 +207,12 @@ public class ConventionSet public virtual IList IndexUniquenessChangedConventions { get; } = new List(); + /// + /// Conventions to run when the sort order of an index is changed. + /// + public virtual IList IndexSortOrderChangedConventions { get; } + = new List(); + /// /// Conventions to run when an annotation is changed on an index. /// diff --git a/src/EFCore/Metadata/Conventions/IIndexSortOrderChangedConvention.cs b/src/EFCore/Metadata/Conventions/IIndexSortOrderChangedConvention.cs new file mode 100644 index 00000000000..b537fc71c71 --- /dev/null +++ b/src/EFCore/Metadata/Conventions/IIndexSortOrderChangedConvention.cs @@ -0,0 +1,22 @@ +// 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.Conventions; + +/// +/// Represents an operation that should be performed when the sort order of an index is changed. +/// +/// +/// See Model building conventions for more information and examples. +/// +public interface IIndexSortOrderChangedConvention : IConvention +{ + /// + /// Called after the uniqueness for an index is changed. + /// + /// The builder for the index. + /// Additional information associated with convention execution. + void ProcessIndexSortOrderChanged( + IConventionIndexBuilder indexBuilder, + IConventionContext?> context); +} diff --git a/src/EFCore/Metadata/Conventions/IndexAttributeConvention.cs b/src/EFCore/Metadata/Conventions/IndexAttributeConvention.cs index 0f8f1cef2f6..52831af95ca 100644 --- a/src/EFCore/Metadata/Conventions/IndexAttributeConvention.cs +++ b/src/EFCore/Metadata/Conventions/IndexAttributeConvention.cs @@ -110,9 +110,17 @@ private static void CheckIndexAttributesAndEnsureIndex( { CheckIgnoredProperties(indexAttribute, entityType); } - else if (indexAttribute.IsUniqueHasValue) + else { - indexBuilder.IsUnique(indexAttribute.IsUnique, fromDataAnnotation: true); + if (indexAttribute.IsUniqueHasValue) + { + indexBuilder = indexBuilder.IsUnique(indexAttribute.IsUnique, fromDataAnnotation: true); + } + + if (indexBuilder is not null && indexAttribute.IsDescending is not null) + { + indexBuilder = indexBuilder.IsDescending(indexAttribute.IsDescending, fromDataAnnotation: true); + } } } } diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs index 252512a3fea..6ea81d79fec 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ConventionScope.cs @@ -127,6 +127,8 @@ public int GetLeafCount() IConventionIndex index); public abstract bool? OnIndexUniquenessChanged(IConventionIndexBuilder indexBuilder); + public abstract IReadOnlyList? OnIndexSortOrderChanged(IConventionIndexBuilder indexBuilder); + public abstract IConventionKeyBuilder? OnKeyAdded(IConventionKeyBuilder keyBuilder); public abstract IConventionAnnotation? OnKeyAnnotationChanged( diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs index e7945a2f2f5..8e471dba230 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.DelayedConventionScope.cs @@ -183,6 +183,12 @@ public override IConventionIndex OnIndexRemoved( return indexBuilder.Metadata.IsUnique; } + public override IReadOnlyList? OnIndexSortOrderChanged(IConventionIndexBuilder indexBuilder) + { + Add(new OnIndexSortOrderChangedNode(indexBuilder)); + return indexBuilder.Metadata.IsDescending; + } + public override IConventionAnnotation? OnIndexAnnotationChanged( IConventionIndexBuilder indexBuilder, string name, @@ -901,6 +907,19 @@ public override void Run(ConventionDispatcher dispatcher) => dispatcher._immediateConventionScope.OnIndexUniquenessChanged(IndexBuilder); } + private sealed class OnIndexSortOrderChangedNode : ConventionNode + { + public OnIndexSortOrderChangedNode(IConventionIndexBuilder indexBuilder) + { + IndexBuilder = indexBuilder; + } + + public IConventionIndexBuilder IndexBuilder { get; } + + public override void Run(ConventionDispatcher dispatcher) + => dispatcher._immediateConventionScope.OnIndexSortOrderChanged(IndexBuilder); + } + private sealed class OnIndexAnnotationChangedNode : ConventionNode { public OnIndexAnnotationChangedNode( diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs index 846d88247b1..68ec5094ed8 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.ImmediateConventionScope.cs @@ -29,6 +29,7 @@ private sealed class ImmediateConventionScope : ConventionScope private readonly ConventionContext _stringConventionContext; private readonly ConventionContext _fieldInfoConventionContext; private readonly ConventionContext _boolConventionContext; + private readonly ConventionContext?> _boolListConventionContext; public ImmediateConventionScope(ConventionSet conventionSet, ConventionDispatcher dispatcher) { @@ -54,6 +55,7 @@ public ImmediateConventionScope(ConventionSet conventionSet, ConventionDispatche _stringConventionContext = new ConventionContext(dispatcher); _fieldInfoConventionContext = new ConventionContext(dispatcher); _boolConventionContext = new ConventionContext(dispatcher); + _boolListConventionContext = new ConventionContext?>(dispatcher); } public override void Run(ConventionDispatcher dispatcher) @@ -969,6 +971,29 @@ public IConventionModelBuilder OnModelInitialized(IConventionModelBuilder modelB return !indexBuilder.Metadata.IsInModel ? null : _boolConventionContext.Result; } + public override IReadOnlyList? OnIndexSortOrderChanged(IConventionIndexBuilder indexBuilder) + { + using (_dispatcher.DelayConventions()) + { + _boolListConventionContext.ResetState(indexBuilder.Metadata.IsDescending); + foreach (var indexConvention in _conventionSet.IndexSortOrderChangedConventions) + { + if (!indexBuilder.Metadata.IsInModel) + { + return null; + } + + indexConvention.ProcessIndexSortOrderChanged(indexBuilder, _boolListConventionContext); + if (_boolListConventionContext.ShouldStopProcessing()) + { + return _boolListConventionContext.Result; + } + } + } + + return !indexBuilder.Metadata.IsInModel ? null : _boolListConventionContext.Result; + } + public override IConventionAnnotation? OnIndexAnnotationChanged( IConventionIndexBuilder indexBuilder, string name, diff --git a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs index 20f24d61469..69ef6bd3cd1 100644 --- a/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs +++ b/src/EFCore/Metadata/Conventions/Internal/ConventionDispatcher.cs @@ -483,6 +483,15 @@ public virtual IConventionModelBuilder OnModelFinalizing(IConventionModelBuilder public virtual bool? OnIndexUniquenessChanged(IConventionIndexBuilder indexBuilder) => _scope.OnIndexUniquenessChanged(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 IReadOnlyList? OnIndexSortOrderChanged(IConventionIndexBuilder indexBuilder) + => _scope.OnIndexSortOrderChanged(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 diff --git a/src/EFCore/Metadata/IConventionIndex.cs b/src/EFCore/Metadata/IConventionIndex.cs index ee2cfcdc9a2..ccad73d3b7e 100644 --- a/src/EFCore/Metadata/IConventionIndex.cs +++ b/src/EFCore/Metadata/IConventionIndex.cs @@ -54,4 +54,18 @@ public interface IConventionIndex : IReadOnlyIndex, IConventionAnnotatable /// /// The configuration source for . ConfigurationSource? GetIsUniqueConfigurationSource(); + + /// + /// Sets the sort order(s) for this index (ascending or descending). + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// Indicates whether the configuration was specified using a data annotation. + /// The configured sort order(s). + IReadOnlyList? SetIsDescending(IReadOnlyList? descending, bool fromDataAnnotation = false); + + /// + /// Returns the configuration source for . + /// + /// The configuration source for . + ConfigurationSource? GetIsDescendingConfigurationSource(); } diff --git a/src/EFCore/Metadata/IMutableIndex.cs b/src/EFCore/Metadata/IMutableIndex.cs index c1270b4995e..bd77f85679f 100644 --- a/src/EFCore/Metadata/IMutableIndex.cs +++ b/src/EFCore/Metadata/IMutableIndex.cs @@ -23,6 +23,11 @@ public interface IMutableIndex : IReadOnlyIndex, IMutableAnnotatable /// new bool IsUnique { get; set; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + new IReadOnlyList? IsDescending { get; set; } + /// /// Gets the properties that this index is defined on. /// diff --git a/src/EFCore/Metadata/IReadOnlyIndex.cs b/src/EFCore/Metadata/IReadOnlyIndex.cs index 6bcb12dbecf..f7ab17a9a4a 100644 --- a/src/EFCore/Metadata/IReadOnlyIndex.cs +++ b/src/EFCore/Metadata/IReadOnlyIndex.cs @@ -28,6 +28,11 @@ public interface IReadOnlyIndex : IReadOnlyAnnotatable /// bool IsUnique { get; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// + IReadOnlyList? IsDescending { 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 @@ -35,6 +40,15 @@ public interface IReadOnlyIndex : IReadOnlyAnnotatable /// IReadOnlyEntityType DeclaringEntityType { get; } + /// + /// Gets the friendly display name for the given , returning its if one is defined, + /// or a string representation of its if this is an unnamed index. + /// + /// The display name. + [DebuggerStepThrough] + string DisplayName() + => Name is null ? Properties.Format() : $"'{Name}'"; + /// /// /// Creates a human-readable representation of the given metadata. diff --git a/src/EFCore/Metadata/Internal/EntityType.cs b/src/EFCore/Metadata/Internal/EntityType.cs index 00e68d5f620..e74bbe1fcd6 100644 --- a/src/EFCore/Metadata/Internal/EntityType.cs +++ b/src/EFCore/Metadata/Internal/EntityType.cs @@ -2249,7 +2249,7 @@ public virtual IEnumerable FindIndexesInHierarchy(string name) if (!_unnamedIndexes.Remove(index.Properties)) { throw new InvalidOperationException( - CoreStrings.IndexWrongType(index.Properties.Format(), DisplayName(), index.DeclaringEntityType.DisplayName())); + CoreStrings.IndexWrongType(index.DisplayName(), DisplayName(), index.DeclaringEntityType.DisplayName())); } } else @@ -2618,7 +2618,7 @@ private void CheckPropertyNotInUse(Property property) throw new InvalidOperationException( CoreStrings.PropertyInUseIndex( property.Name, DisplayName(), - containingIndex.Properties.Format(), containingIndex.DeclaringEntityType.DisplayName())); + containingIndex.DisplayName(), containingIndex.DeclaringEntityType.DisplayName())); } } diff --git a/src/EFCore/Metadata/Internal/Index.cs b/src/EFCore/Metadata/Internal/Index.cs index f4a2bf54d69..43c887c43b4 100644 --- a/src/EFCore/Metadata/Internal/Index.cs +++ b/src/EFCore/Metadata/Internal/Index.cs @@ -15,10 +15,13 @@ namespace Microsoft.EntityFrameworkCore.Metadata.Internal; public class Index : ConventionAnnotatable, IMutableIndex, IConventionIndex, IIndex { private bool? _isUnique; + private IReadOnlyList? _isDescending; + private InternalIndexBuilder? _builder; private ConfigurationSource _configurationSource; private ConfigurationSource? _isUniqueConfigurationSource; + private ConfigurationSource? _isDescendingConfigurationSource; // Warning: Never access these fields directly as access needs to be thread-safe private object? _nullableValueFactory; @@ -178,8 +181,8 @@ public virtual bool IsUnique : oldIsUnique; } - private static bool DefaultIsUnique - => false; + private static readonly bool DefaultIsUnique + = false; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -193,6 +196,70 @@ private static bool DefaultIsUnique private void UpdateIsUniqueConfigurationSource(ConfigurationSource configurationSource) => _isUniqueConfigurationSource = configurationSource.Max(_isUniqueConfigurationSource); + /// + /// 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? IsDescending + { + get => _isDescending ?? DefaultIsDescending; + set => SetIsDescending(value, 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. + /// + public virtual IReadOnlyList? SetIsDescending(IReadOnlyList? descending, ConfigurationSource configurationSource) + { + EnsureMutable(); + + if (descending is not null && descending.Count != Properties.Count) + { + throw new ArgumentException( + CoreStrings.InvalidNumberOfIndexSortOrderValues(DisplayName(), descending.Count, Properties.Count), nameof(descending)); + } + + var oldIsDescending = IsDescending; + var isChanging = + (_isDescending is null && descending is not null && descending.Any(desc => desc)) + || (descending is null && _isDescending is not null && _isDescending.Any(desc => desc)) + || (descending is not null && oldIsDescending is not null && !oldIsDescending.SequenceEqual(descending)); + _isDescending = descending; + + if (descending == null) + { + _isDescendingConfigurationSource = null; + } + else + { + UpdateIsDescendingConfigurationSource(configurationSource); + } + + return isChanging + ? DeclaringEntityType.Model.ConventionDispatcher.OnIndexSortOrderChanged(Builder) + : oldIsDescending; + } + + private static readonly bool[]? DefaultIsDescending + = 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 ConfigurationSource? GetIsDescendingConfigurationSource() + => _isDescendingConfigurationSource; + + private void UpdateIsDescendingConfigurationSource(ConfigurationSource configurationSource) + => _isDescendingConfigurationSource = configurationSource.Max(_isDescendingConfigurationSource); + /// /// Runs the conventions when an annotation was set or removed. /// @@ -240,6 +307,16 @@ public virtual DebugView DebugView () => ((IIndex)this).ToDebugString(), () => ((IIndex)this).ToDebugString(MetadataDebugStringOptions.LongDefault)); + /// + /// 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] + public virtual string DisplayName() + => Name is null ? Properties.Format() : $"'{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 @@ -369,4 +446,15 @@ IEntityType IIndex.DeclaringEntityType [DebuggerStepThrough] bool? IConventionIndex.SetIsUnique(bool? unique, bool fromDataAnnotation) => SetIsUnique(unique, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// 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] + IReadOnlyList? IConventionIndex.SetIsDescending(IReadOnlyList? descending, bool fromDataAnnotation) + => SetIsDescending(descending, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + } diff --git a/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs b/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs index 55ff70cd7ac..f55646fa897 100644 --- a/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs +++ b/src/EFCore/Metadata/Internal/InternalIndexBuilder.cs @@ -49,6 +49,34 @@ public virtual bool CanSetIsUnique(bool? unique, ConfigurationSource? configurat => Metadata.IsUnique == unique || configurationSource.Overrides(Metadata.GetIsUniqueConfigurationSource()); + /// + /// 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? IsDescending(IReadOnlyList? descending, ConfigurationSource configurationSource) + { + if (!CanSetIsDescending(descending, configurationSource)) + { + return null; + } + + Metadata.SetIsDescending(descending, configurationSource); + return this; + } + + /// + /// 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 bool CanSetIsDescending(IReadOnlyList? descending, ConfigurationSource? configurationSource) + => descending is null && Metadata.IsDescending is null + || descending is not null && Metadata.IsDescending is not null && Metadata.IsDescending.SequenceEqual(descending) + || configurationSource.Overrides(Metadata.GetIsDescendingConfigurationSource()); + /// /// 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 @@ -107,4 +135,26 @@ bool IConventionIndexBuilder.CanSetIsUnique(bool? unique, bool fromDataAnnotatio => CanSetIsUnique( unique, fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// 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. + /// + IConventionIndexBuilder? IConventionIndexBuilder.IsDescending(IReadOnlyList? descending, bool fromDataAnnotation) + => IsDescending( + descending, + fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); + + /// + /// 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. + /// + bool IConventionIndexBuilder.CanSetIsDescending(IReadOnlyList? descending, bool fromDataAnnotation) + => CanSetIsDescending( + descending, + fromDataAnnotation ? ConfigurationSource.DataAnnotation : ConfigurationSource.Convention); } diff --git a/src/EFCore/Metadata/RuntimeIndex.cs b/src/EFCore/Metadata/RuntimeIndex.cs index 109d734f507..c5ecc2c41e3 100644 --- a/src/EFCore/Metadata/RuntimeIndex.cs +++ b/src/EFCore/Metadata/RuntimeIndex.cs @@ -55,6 +55,15 @@ public RuntimeIndex( /// public virtual RuntimeEntityType DeclaringEntityType { get; } + /// + /// Always returns an empty array for . + /// + IReadOnlyList IReadOnlyIndex.IsDescending + { + [DebuggerStepThrough] + get => throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData); + } + /// /// 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 ecebccc29e4..9e1024ec553 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -1118,12 +1118,12 @@ public static string IndexPropertiesWrongEntity(object? indexProperties, object? indexProperties, entityType); /// - /// The index {indexProperties} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'. + /// The index {index} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'. /// - public static string IndexWrongType(object? indexProperties, object? entityType, object? otherEntityType) + public static string IndexWrongType(object? index, object? entityType, object? otherEntityType) => string.Format( - GetString("IndexWrongType", nameof(indexProperties), nameof(entityType), nameof(otherEntityType)), - indexProperties, entityType, otherEntityType); + GetString("IndexWrongType", nameof(index), nameof(entityType), nameof(otherEntityType)), + index, entityType, otherEntityType); /// /// The property '{property}' cannot be ignored on entity type '{entityType}' because it's declared on the base entity type '{baseEntityType}'. To exclude this property from your model, use the [NotMapped] attribute or 'Ignore' on the base type in 'OnModelCreating'. @@ -1219,6 +1219,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 sort order values provided for {indexProperties}: {numValues} values were provided, but the index has {numProperties} properties. + /// + public static string InvalidNumberOfIndexSortOrderValues(object? indexProperties, object? numValues, object? numProperties) + => string.Format( + GetString("InvalidNumberOfIndexSortOrderValues", nameof(indexProperties), nameof(numValues), nameof(numProperties)), + indexProperties, numValues, numProperties); + /// /// The specified poolSize must be greater than 0. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index e2872bc118d..4578370aa47 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -536,7 +536,7 @@ 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 {indexProperties} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'. + The index {index} cannot be removed from the entity type '{entityType}' because it is defined on the entity type '{otherEntityType}'. The property '{property}' cannot be ignored on entity type '{entityType}' because it's declared on the base entity type '{baseEntityType}'. To exclude this property from your model, use the [NotMapped] attribute or 'Ignore' on the base type in 'OnModelCreating'. @@ -574,6 +574,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 sort order values provided for {indexProperties}: {numValues} values were provided, but the index has {numProperties} properties. + The specified poolSize must be greater than 0. diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs index 22778a022c3..9bae3036bc4 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs @@ -963,6 +963,9 @@ public void CreateIndexOperation_required_args() Assert.Equal("IX_Post_Title", o.Name); Assert.Equal("Post", o.Table); Assert.Equal(new[] { "Title" }, o.Columns); + Assert.False(o.IsUnique); + Assert.Null(o.IsDescending); + Assert.Null(o.Filter); }); [ConditionalFact] @@ -973,8 +976,9 @@ public void CreateIndexOperation_all_args() Name = "IX_Post_Title", Schema = "dbo", Table = "Post", - Columns = new[] { "Title" }, + Columns = new[] { "Title", "Name" }, IsUnique = true, + IsDescending = new[] { true, false }, Filter = "[Title] IS NOT NULL" }, "mb.CreateIndex(" @@ -985,18 +989,22 @@ public void CreateIndexOperation_all_args() + _eol + " table: \"Post\"," + _eol - + " column: \"Title\"," + + " columns: new[] { \"Title\", \"Name\" }," + _eol + " unique: true," + _eol + + " descending: new[] { true, false }," + + _eol + " filter: \"[Title] IS NOT NULL\");", o => { Assert.Equal("IX_Post_Title", o.Name); Assert.Equal("dbo", o.Schema); Assert.Equal("Post", o.Table); - Assert.Equal(new[] { "Title" }, o.Columns); + Assert.Equal(new[] { "Title", "Name" }, o.Columns); Assert.True(o.IsUnique); + Assert.Equal(new[] { true, false }, o.IsDescending); + Assert.Equal("[Title] IS NOT NULL", o.Filter); }); [ConditionalFact] diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 2779ba35261..b6e790757ae 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -164,6 +164,14 @@ private class EntityWithGenericProperty public TProperty Property { get; set; } } + private class EntityWithThreeProperties + { + public int Id { get; set; } + public int X { get; set; } + public int Y { get; set; } + public int Z { get; set; } + } + [Index(nameof(FirstName), nameof(LastName))] private class EntityWithIndexAttribute { @@ -4231,7 +4239,7 @@ public virtual void Index_Fluent_APIs_are_properly_generated() o => Assert.True(o.GetEntityTypes().Single().GetIndexes().Single().IsClustered())); [ConditionalFact] - public virtual void Index_isUnique_is_stored_in_snapshot() + public virtual void Index_IsUnique_is_stored_in_snapshot() => Test( builder => { @@ -4261,6 +4269,76 @@ public virtual void Index_isUnique_is_stored_in_snapshot() });"), o => Assert.True(o.GetEntityTypes().First().GetIndexes().First().IsUnique)); + [ConditionalFact] + public virtual void Index_IsDescending_is_stored_in_snapshot() + => Test( + builder => + { + builder.Entity( + e => + { + e.HasIndex(t => new { t.X, t.Y, t.Z }, "IX_empty"); + e.HasIndex(t => new { t.X, t.Y, t.Z }, "IX_all_ascending") + .IsDescending(false, false, false); + e.HasIndex(t => new { t.X, t.Y, t.Z }, "IX_all_descending") + .IsDescending(true, true, true); + e.HasIndex(t => new { t.X, t.Y, t.Z }, "IX_mixed") + .IsDescending(false, true, false); + }); + }, + AddBoilerPlate( + GetHeading() + + @" + modelBuilder.Entity(""Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithThreeProperties"", b => + { + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int""); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property(""Id""), 1L, 1); + + b.Property(""X"") + .HasColumnType(""int""); + + b.Property(""Y"") + .HasColumnType(""int""); + + b.Property(""Z"") + .HasColumnType(""int""); + + b.HasKey(""Id""); + + b.HasIndex(new[] { ""X"", ""Y"", ""Z"" }, ""IX_all_ascending"") + .IsDescending(false, false, false); + + b.HasIndex(new[] { ""X"", ""Y"", ""Z"" }, ""IX_all_descending"") + .IsDescending(true, true, true); + + b.HasIndex(new[] { ""X"", ""Y"", ""Z"" }, ""IX_empty""); + + b.HasIndex(new[] { ""X"", ""Y"", ""Z"" }, ""IX_mixed"") + .IsDescending(false, true, false); + + b.ToTable(""EntityWithThreeProperties""); + });"), + o => + { + var entityType = o.GetEntityTypes().Single(); + Assert.Equal(4, entityType.GetIndexes().Count()); + + var emptyIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_empty"); + Assert.Null(emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Equal(new[] { false, false, false}, allAscendingIndex.IsDescending); + + var allDescendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_descending"); + Assert.Equal(new[] { true, true, true }, allDescendingIndex.IsDescending); + + var mixedIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_mixed"); + Assert.Equal(new[] { false, true, false }, mixedIndex.IsDescending); + }); + [ConditionalFact] public virtual void Index_database_name_annotation_is_stored_in_snapshot_as_fluent_api() => Test( diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs index b0a56787422..33ed76d34e6 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs @@ -553,7 +553,8 @@ public void Entity_with_indexes_and_use_data_annotations_false_always_generates_ x.Property("C"); x.HasKey("Id"); x.HasIndex(new[] { "A", "B" }, "IndexOnAAndB") - .IsUnique(); + .IsUnique() + .IsDescending(false, true); x.HasIndex(new[] { "B", "C" }, "IndexOnBAndC") .HasFilter("Filter SQL") .HasAnnotation("AnnotationName", "AnnotationValue"); @@ -598,7 +599,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity(entity => { entity.HasIndex(e => new { e.A, e.B }, ""IndexOnAAndB"") - .IsUnique(); + .IsUnique() + .IsDescending(false, true); entity.HasIndex(e => new { e.B, e.C }, ""IndexOnBAndC"") .HasFilter(""Filter SQL"") @@ -633,7 +635,8 @@ public void Entity_with_indexes_and_use_data_annotations_true_generates_fluent_A x.Property("C"); x.HasKey("Id"); x.HasIndex(new[] { "A", "B" }, "IndexOnAAndB") - .IsUnique(); + .IsUnique() + .IsDescending(false, true); x.HasIndex(new[] { "B", "C" }, "IndexOnBAndC") .HasFilter("Filter SQL") .HasAnnotation("AnnotationName", "AnnotationValue"); @@ -696,6 +699,107 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) model => Assert.Equal(2, model.FindEntityType("TestNamespace.EntityWithIndexes").GetIndexes().Count())); + [ConditionalFact] + public void Indexes_with_descending() + => Test( + modelBuilder => modelBuilder + .Entity( + "EntityWithIndexes", + x => + { + x.Property("Id"); + x.Property("X"); + x.Property("Y"); + x.Property("Z"); + x.HasKey("Id"); + x.HasIndex(new[] { "X", "Y", "Z" }, "IX_empty"); + x.HasIndex(new[] { "X", "Y", "Z" }, "IX_all_ascending") + .IsDescending(false, false, false); + x.HasIndex(new[] { "X", "Y", "Z" }, "IX_all_descending") + .IsDescending(true, true, true); + x.HasIndex(new[] { "X", "Y", "Z" }, "IX_mixed") + .IsDescending(false, true, false); + }), + new ModelCodeGenerationOptions { UseDataAnnotations = false }, + code => + { + AssertFileContents( + @"using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace TestNamespace +{ + public partial class TestDbContext : DbContext + { + public TestDbContext() + { + } + + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet EntityWithIndexes { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { +#warning " + + DesignStrings.SensitiveInformationWarning + + @" + optionsBuilder.UseSqlServer(""Initial Catalog=TestDatabase""); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasIndex(e => new { e.X, e.Y, e.Z }, ""IX_all_ascending"") + .IsDescending(false, false, false); + + entity.HasIndex(e => new { e.X, e.Y, e.Z }, ""IX_all_descending"") + .IsDescending(true, true, true); + + entity.HasIndex(e => new { e.X, e.Y, e.Z }, ""IX_empty""); + + entity.HasIndex(e => new { e.X, e.Y, e.Z }, ""IX_mixed"") + .IsDescending(false, true, false); + + entity.Property(e => e.Id).UseIdentityColumn(); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); + } +} +", + code.ContextFile); + }, + model => + { + var entityType = model.FindEntityType("TestNamespace.EntityWithIndexes")!; + Assert.Equal(4, entityType.GetIndexes().Count()); + + var emptyIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_empty"); + Assert.Null(emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Equal(new[] { false, false, false }, allAscendingIndex.IsDescending); + + var allDescendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_descending"); + Assert.Equal(new[] { true, true, true }, allDescendingIndex.IsDescending); + + var mixedIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_mixed"); + Assert.Equal(new[] { false, true, false }, mixedIndex.IsDescending); + }); + [ConditionalFact] public void Entity_lambda_uses_correct_identifiers() => Test( diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpEntityTypeGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpEntityTypeGeneratorTest.cs index b368816418b..b14b813c5e0 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpEntityTypeGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpEntityTypeGeneratorTest.cs @@ -247,7 +247,7 @@ public partial class Vista }); [ConditionalFact] - public void IndexAttribute_is_generated_for_multiple_indexes_with_name_unique() + public void IndexAttribute_is_generated_for_multiple_indexes_with_name_unique_descending() => Test( modelBuilder => modelBuilder .Entity( @@ -260,7 +260,8 @@ public void IndexAttribute_is_generated_for_multiple_indexes_with_name_unique() x.Property("C"); x.HasKey("Id"); x.HasIndex(new[] { "A", "B" }, "IndexOnAAndB") - .IsUnique(); + .IsUnique() + .IsDescending(true, false); x.HasIndex(new[] { "B", "C" }, "IndexOnBAndC"); x.HasIndex("C"); }), @@ -277,7 +278,7 @@ public void IndexAttribute_is_generated_for_multiple_indexes_with_name_unique() namespace TestNamespace { [Index(""C"")] - [Index(""A"", ""B"", Name = ""IndexOnAAndB"", IsUnique = true)] + [Index(""A"", ""B"", Name = ""IndexOnAAndB"", IsUnique = true, IsDescending = new[] { true, false })] [Index(""B"", ""C"", Name = ""IndexOnBAndC"")] public partial class EntityWithIndexes { diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs index acfee0d4baa..6cf32d7cebf 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs @@ -1376,6 +1376,90 @@ public void Unique_index_composite_foreign_key() Assert.Equal(parent.FindPrimaryKey(), fk.PrincipalKey); } + [ConditionalFact] + public void Index_descending() + { + var table = new DatabaseTable + { + Database = Database, + Name = "SomeTable", + Columns = + { + new DatabaseColumn + { + Table = Table, + Name = "X", + StoreType = "int" + }, + new DatabaseColumn + { + Table = Table, + Name = "Y", + StoreType = "int" + }, + new DatabaseColumn + { + Table = Table, + Name = "Z", + StoreType = "int" + } + } + }; + + table.Indexes.Add( + new DatabaseIndex + { + Table = Table, + Name = "IX_empty", + Columns = { table.Columns[0], table.Columns[1], table.Columns[2] } + }); + + table.Indexes.Add( + new DatabaseIndex + { + Table = Table, + Name = "IX_all_ascending", + Columns = { table.Columns[0], table.Columns[1], table.Columns[2] }, + IsDescending = { false, false, false } + }); + + table.Indexes.Add( + new DatabaseIndex + { + Table = Table, + Name = "IX_all_descending", + Columns = { table.Columns[0], table.Columns[1], table.Columns[2] }, + IsDescending = { true, true, true } + }); + + table.Indexes.Add( + new DatabaseIndex + { + Table = Table, + Name = "IX_mixed", + Columns = { table.Columns[0], table.Columns[1], table.Columns[2] }, + IsDescending = { false, true, false } + }); + + var model = _factory.Create( + new DatabaseModel { Tables = { table } }, + new ModelReverseEngineerOptions { NoPluralize = true }); + + var entityType = model.FindEntityType("SomeTable")!; + + var emptyIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_empty"); + Assert.Null(emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Null(allAscendingIndex.IsDescending); + + var allDescendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_descending"); + Assert.Equal(new[] { true, true, true }, allDescendingIndex.IsDescending); + + var mixedIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_mixed"); + Assert.Equal(new[] { false, true, false }, mixedIndex.IsDescending); + } + [ConditionalFact] public void Unique_names() { diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index ade454b8d5a..8e8604dd05f 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -1042,6 +1042,12 @@ public virtual Task Create_index() Assert.Same(table.Columns.Single(c => c.Name == "FirstName"), Assert.Single(index.Columns)); Assert.Equal("IX_People_FirstName", index.Name); Assert.False(index.IsUnique); + + if (index.IsDescending.Count > 0) + { + Assert.Collection(index.IsDescending, descending => Assert.False(descending)); + } + Assert.Null(index.Filter); }); @@ -1064,6 +1070,88 @@ public virtual Task Create_index_unique() Assert.True(index.IsUnique); }); + [ConditionalFact] + public virtual Task Create_index_descending() + => Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("X"); + }), + builder => { }, + builder => builder.Entity("People").HasIndex("X").IsDescending(true), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Collection(index.IsDescending, Assert.True); + }); + + [ConditionalFact] + public virtual Task Create_index_descending_mixed() + => Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("X"); + e.Property("Y"); + e.Property("Z"); + }), + builder => { }, + builder => builder.Entity("People") + .HasIndex("X", "Y", "Z") + .IsDescending(false, true, false), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Collection(index.IsDescending, Assert.False, Assert.True, Assert.False); + }); + + [ConditionalFact] + public virtual Task Alter_index_make_unique() + => Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("X"); + }), + builder => builder.Entity("People").HasIndex("X"), + builder => builder.Entity("People").HasIndex("X").IsUnique(), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.True(index.IsUnique); + }); + + [ConditionalFact] + public virtual Task Alter_index_change_sort_order() + => Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("X"); + e.Property("Y"); + e.Property("Z"); + }), + builder => builder.Entity("People") + .HasIndex("X", "Y", "Z") + .IsDescending(true, false, true), + builder => builder.Entity("People") + .HasIndex("X", "Y", "Z") + .IsDescending(false, true, false), + model => + { + var table = Assert.Single(model.Tables); + var index = Assert.Single(table.Indexes); + Assert.Collection(index.IsDescending, Assert.False, Assert.True, Assert.False); + }); + [ConditionalFact] public virtual Task Create_index_with_filter() => Test( diff --git a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs index 754c5b88990..b50fdd57f5c 100644 --- a/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs +++ b/test/EFCore.Relational.Tests/Infrastructure/RelationalModelValidatorTest.cs @@ -1308,6 +1308,24 @@ public virtual void Detects_duplicate_index_names_within_hierarchy_with_differen modelBuilder); } + [ConditionalFact] + public virtual void Detects_duplicate_index_names_within_hierarchy_with_different_sort_orders() + { + var modelBuilder = CreateConventionalModelBuilder(); + modelBuilder.Entity(); + modelBuilder.Entity().HasIndex(c => c.Name).HasDatabaseName("IX_Animal_Name") + .IsDescending(true); + modelBuilder.Entity().HasIndex(d => d.Name).HasDatabaseName("IX_Animal_Name") + .IsDescending(false); + + VerifyError( + RelationalStrings.DuplicateIndexSortOrdersMismatch( + "{'" + nameof(Dog.Name) + "'}", nameof(Dog), + "{'" + nameof(Cat.Name) + "'}", nameof(Cat), + nameof(Animal), "IX_Animal_Name"), + modelBuilder); + } + [ConditionalFact] public virtual void Detects_duplicate_index_names_within_hierarchy_with_different_filters() { diff --git a/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs new file mode 100644 index 00000000000..c1ea868be05 --- /dev/null +++ b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs @@ -0,0 +1,21 @@ +// 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.Migrations.Operations; + +public class CreateIndexOperationTest +{ + [Fact] + public void IsDescending_count_matches_column_count() + { + var operation = new CreateIndexOperation(); + + operation.IsDescending = new[] { true }; + Assert.Throws(() => operation.Columns = new[] { "X", "Y" }); + + operation.IsDescending = null; + + operation.Columns = new[] { "X", "Y" }; + Assert.Throws(() => operation.IsDescending = new[] { true }); + } +} diff --git a/test/EFCore.Specification.Tests/TestUtilities/TestHelpers.cs b/test/EFCore.Specification.Tests/TestUtilities/TestHelpers.cs index 31229cd649b..9daa1ebd398 100644 --- a/test/EFCore.Specification.Tests/TestUtilities/TestHelpers.cs +++ b/test/EFCore.Specification.Tests/TestUtilities/TestHelpers.cs @@ -505,6 +505,7 @@ public void RemoveAllConventions() Conventions.IndexAnnotationChangedConventions.Clear(); Conventions.IndexRemovedConventions.Clear(); Conventions.IndexUniquenessChangedConventions.Clear(); + Conventions.IndexSortOrderChangedConventions.Clear(); Conventions.KeyAddedConventions.Clear(); Conventions.KeyAnnotationChangedConventions.Clear(); Conventions.KeyRemovedConventions.Clear(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 9e317426c62..cfbeec406c4 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -19,7 +19,7 @@ public MigrationsSqlServerTest(MigrationsSqlServerFixture fixture, ITestOutputHe : base(fixture) { Fixture.TestSqlLoggerFactory.Clear(); - // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + //Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } public override async Task Create_table() @@ -1246,6 +1246,42 @@ FROM [sys].[default_constraints] [d] @"CREATE UNIQUE INDEX [IX_People_FirstName_LastName] ON [People] ([FirstName], [LastName]) WHERE [FirstName] IS NOT NULL AND [LastName] IS NOT NULL;"); } + public override async Task Create_index_descending() + { + await base.Create_index_descending(); + + AssertSql( + @"CREATE INDEX [IX_People_X] ON [People] ([X] DESC);"); + } + + public override async Task Create_index_descending_mixed() + { + await base.Create_index_descending_mixed(); + + AssertSql( + @"CREATE INDEX [IX_People_X_Y_Z] ON [People] ([X], [Y] DESC, [Z]);"); + } + + public override async Task Alter_index_make_unique() + { + await base.Alter_index_make_unique(); + + AssertSql( + @"DROP INDEX [IX_People_X] ON [People];", + // + @"CREATE UNIQUE INDEX [IX_People_X] ON [People] ([X]);"); + } + + public override async Task Alter_index_change_sort_order() + { + await base.Alter_index_change_sort_order(); + + AssertSql( + @"DROP INDEX [IX_People_X_Y_Z] ON [People];", + // + @"CREATE INDEX [IX_People_X_Y_Z] ON [People] ([X], [Y] DESC, [Z]);"); + } + public override async Task Create_index_with_filter() { await base.Create_index_with_filter(); diff --git a/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs b/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs index b25f94fd245..2e83815ef7f 100644 --- a/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs +++ b/test/EFCore.Tests/Metadata/Conventions/ConventionDispatcherTest.cs @@ -2983,6 +2983,104 @@ public void ProcessIndexUniquenessChanged( } } +#nullable enable + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + [ConditionalTheory] + public void OnIndexSortOrderChanged_calls_conventions_in_order(bool useBuilder, bool useScope) + { + var conventions = new ConventionSet(); + + var convention1 = new IndexSortOrderChangedConvention(terminate: false); + var convention2 = new IndexSortOrderChangedConvention(terminate: true); + var convention3 = new IndexSortOrderChangedConvention(terminate: false); + conventions.IndexSortOrderChangedConventions.Add(convention1); + conventions.IndexSortOrderChangedConventions.Add(convention2); + conventions.IndexSortOrderChangedConventions.Add(convention3); + + var builder = new InternalModelBuilder(new Model(conventions)); + var entityBuilder = builder.Entity(typeof(Order), ConfigurationSource.Convention)!; + var index = entityBuilder.HasIndex(new List { "OrderId" }, ConfigurationSource.Convention)!.Metadata; + + var scope = useScope ? builder.Metadata.ConventionDispatcher.DelayConventions() : null; + + if (useBuilder) + { + index.Builder.IsDescending(new[] { true }, ConfigurationSource.Convention); + } + else + { + index.IsDescending = new[] { true }; + } + + if (useScope) + { + Assert.Empty(convention1.Calls); + Assert.Empty(convention2.Calls); + scope!.Dispose(); + } + + Assert.Equal(new[] { new[] { true } }, convention1.Calls); + Assert.Equal(new[] { new[] { true } }, convention2.Calls); + Assert.Empty(convention3.Calls); + + if (useBuilder) + { + index.Builder.IsDescending(new[] { true }, ConfigurationSource.Convention); + } + else + { + index.IsDescending = new[] { true }; + } + + Assert.Equal(new[] { new[] { true } }, convention1.Calls); + Assert.Equal(new[] { new[] { true } }, convention2.Calls); + Assert.Empty(convention3.Calls); + + if (useBuilder) + { + index.Builder.IsDescending(new[] { false }, ConfigurationSource.Convention); + } + else + { + index.IsDescending = new[] { false }; + } + + Assert.Equal(new[] { new[] { true }, new[] { false } }, convention1.Calls); + Assert.Equal(new[] { new[] { true }, new[] { false } }, convention2.Calls); + Assert.Empty(convention3.Calls); + + Assert.Same(index, entityBuilder.Metadata.RemoveIndex(index.Properties)); + } + + private class IndexSortOrderChangedConvention : IIndexSortOrderChangedConvention + { + private readonly bool _terminate; + public readonly List?> Calls = new(); + + public IndexSortOrderChangedConvention(bool terminate) + { + _terminate = terminate; + } + + public void ProcessIndexSortOrderChanged( + IConventionIndexBuilder indexBuilder, + IConventionContext?> context) + { + Assert.NotNull(indexBuilder.Metadata.Builder); + + Calls.Add(indexBuilder.Metadata.IsDescending); + + if (_terminate) + { + context.StopProcessing(); + } + } + } +#nullable restore + [InlineData(false, false)] [InlineData(true, false)] [InlineData(false, true)] diff --git a/test/EFCore.Tests/Metadata/Conventions/IndexAttributeConventionTest.cs b/test/EFCore.Tests/Metadata/Conventions/IndexAttributeConventionTest.cs index 829a87585ef..e989e1dfc84 100644 --- a/test/EFCore.Tests/Metadata/Conventions/IndexAttributeConventionTest.cs +++ b/test/EFCore.Tests/Metadata/Conventions/IndexAttributeConventionTest.cs @@ -28,6 +28,7 @@ public void IndexAttribute_overrides_configuration_from_convention() var indexProperties = new List { propABuilder.Metadata.Name, propBBuilder.Metadata.Name }; var indexBuilder = entityBuilder.HasIndex(indexProperties, "IndexOnAAndB", ConfigurationSource.Convention); indexBuilder.IsUnique(false, ConfigurationSource.Convention); + indexBuilder.IsDescending(new[] { false, true }, ConfigurationSource.Convention); RunConvention(entityBuilder); RunConvention(modelBuilder); @@ -35,8 +36,11 @@ public void IndexAttribute_overrides_configuration_from_convention() var index = entityBuilder.Metadata.GetIndexes().Single(); Assert.Equal(ConfigurationSource.DataAnnotation, index.GetConfigurationSource()); Assert.Equal("IndexOnAAndB", index.Name); + Assert.True(index.IsUnique); Assert.Equal(ConfigurationSource.DataAnnotation, index.GetIsUniqueConfigurationSource()); + Assert.Equal(new[] { true, false }, index.IsDescending); + Assert.Equal(ConfigurationSource.DataAnnotation, index.GetIsDescendingConfigurationSource()); Assert.Collection( index.Properties, prop0 => Assert.Equal("A", prop0.Name), @@ -50,7 +54,8 @@ public void IndexAttribute_can_be_overriden_using_explicit_configuration() var entityBuilder = modelBuilder.Entity(); entityBuilder.HasIndex(new[] { "A", "B" }, "IndexOnAAndB") - .IsUnique(false); + .IsUnique(false) + .IsDescending(false, true); modelBuilder.Model.FinalizeModel(); @@ -59,6 +64,8 @@ public void IndexAttribute_can_be_overriden_using_explicit_configuration() Assert.Equal("IndexOnAAndB", index.Name); Assert.False(index.IsUnique); Assert.Equal(ConfigurationSource.Explicit, index.GetIsUniqueConfigurationSource()); + Assert.Equal(new[] { false, true }, index.IsDescending); + Assert.Equal(ConfigurationSource.Explicit, index.GetIsDescendingConfigurationSource()); Assert.Collection( index.Properties, prop0 => Assert.Equal("A", prop0.Name), @@ -316,7 +323,7 @@ private IndexAttributeConvention CreateIndexAttributeConvention() private ProviderConventionSetBuilderDependencies CreateDependencies() => InMemoryTestHelpers.Instance.CreateContextServices().GetRequiredService(); - [Index(nameof(A), nameof(B), Name = "IndexOnAAndB", IsUnique = true)] + [Index(nameof(A), nameof(B), Name = "IndexOnAAndB", IsUnique = true, IsDescending = new[] { true, false })] private class EntityWithIndex { public int Id { get; set; } diff --git a/test/EFCore.Tests/ModelBuilding/ModelBuilderGenericTest.cs b/test/EFCore.Tests/ModelBuilding/ModelBuilderGenericTest.cs index f4e025e6f25..c7a2e0af068 100644 --- a/test/EFCore.Tests/ModelBuilding/ModelBuilderGenericTest.cs +++ b/test/EFCore.Tests/ModelBuilding/ModelBuilderGenericTest.cs @@ -581,6 +581,9 @@ public override TestIndexBuilder HasAnnotation(string annotation, objec public override TestIndexBuilder IsUnique(bool isUnique = true) => new GenericTestIndexBuilder(IndexBuilder.IsUnique(isUnique)); + public override TestIndexBuilder IsDescending(params bool[] isDescending) + => new GenericTestIndexBuilder(IndexBuilder.IsDescending(isDescending)); + IndexBuilder IInfrastructure>.Instance => IndexBuilder; } diff --git a/test/EFCore.Tests/ModelBuilding/ModelBuilderNonGenericTest.cs b/test/EFCore.Tests/ModelBuilding/ModelBuilderNonGenericTest.cs index a657349bf48..6904a9b0078 100644 --- a/test/EFCore.Tests/ModelBuilding/ModelBuilderNonGenericTest.cs +++ b/test/EFCore.Tests/ModelBuilding/ModelBuilderNonGenericTest.cs @@ -686,6 +686,9 @@ public override TestIndexBuilder HasAnnotation(string annotation, objec public override TestIndexBuilder IsUnique(bool isUnique = true) => new NonGenericTestIndexBuilder(IndexBuilder.IsUnique(isUnique)); + public override TestIndexBuilder IsDescending(params bool[] isDescending) + => new NonGenericTestIndexBuilder(IndexBuilder.IsDescending(isDescending)); + IndexBuilder IInfrastructure.Instance => IndexBuilder; } diff --git a/test/EFCore.Tests/ModelBuilding/ModelBuilderTestBase.cs b/test/EFCore.Tests/ModelBuilding/ModelBuilderTestBase.cs index c54b376fa60..368b3aaa84f 100644 --- a/test/EFCore.Tests/ModelBuilding/ModelBuilderTestBase.cs +++ b/test/EFCore.Tests/ModelBuilding/ModelBuilderTestBase.cs @@ -362,6 +362,7 @@ public abstract class TestIndexBuilder public abstract TestIndexBuilder HasAnnotation(string annotation, object? value); public abstract TestIndexBuilder IsUnique(bool isUnique = true); + public abstract TestIndexBuilder IsDescending(params bool[] isDescending); } public abstract class TestPropertyBuilder diff --git a/test/EFCore.Tests/ModelBuilding/NonRelationshipTestBase.cs b/test/EFCore.Tests/ModelBuilding/NonRelationshipTestBase.cs index af02579ec2f..d69d92909eb 100644 --- a/test/EFCore.Tests/ModelBuilding/NonRelationshipTestBase.cs +++ b/test/EFCore.Tests/ModelBuilding/NonRelationshipTestBase.cs @@ -1722,6 +1722,7 @@ public virtual void Can_add_multiple_indexes() entityBuilder.HasIndex(ix => ix.Id).IsUnique(); entityBuilder.HasIndex(ix => ix.Name).HasAnnotation("A1", "V1"); entityBuilder.HasIndex(ix => ix.Id, "Named"); + entityBuilder.HasIndex(ix => ix.Id, "Descending").IsDescending(true); var model = modelBuilder.FinalizeModel(); @@ -1729,7 +1730,7 @@ public virtual void Can_add_multiple_indexes() var idProperty = entityType.FindProperty(nameof(Customer.Id)); var nameProperty = entityType.FindProperty(nameof(Customer.Name)); - Assert.Equal(3, entityType.GetIndexes().Count()); + Assert.Equal(4, entityType.GetIndexes().Count()); var firstIndex = entityType.FindIndex(idProperty); Assert.True(firstIndex.IsUnique); var secondIndex = entityType.FindIndex(nameProperty); @@ -1737,6 +1738,8 @@ public virtual void Can_add_multiple_indexes() Assert.Equal("V1", secondIndex["A1"]); var namedIndex = entityType.FindIndex("Named"); Assert.False(namedIndex.IsUnique); + var descendingIndex = entityType.FindIndex("Descending"); + Assert.Equal(new[] { true }, descendingIndex.IsDescending); } [ConditionalFact]