diff --git a/src/EFCore.Abstractions/IndexAttribute.cs b/src/EFCore.Abstractions/IndexAttribute.cs index c60caefcb14..d190b1b1b0d 100644 --- a/src/EFCore.Abstractions/IndexAttribute.cs +++ b/src/EFCore.Abstractions/IndexAttribute.cs @@ -54,6 +54,12 @@ public bool IsUnique set => _isUnique = value; } + /// + /// Gets a set of values indicating whether each corresponding index column has descending sort order. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending order. + /// + public bool[]? IsDescending { get; set; } + /// /// Checks whether has been explicitly set to a value. /// diff --git a/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpMigrationOperationGenerator.cs index 15380528cbb..3907dc3049f 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.Length > 0) + { + 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..05a20a1b893 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.Count > 0) + { + 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..dc410f2d135 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs @@ -567,6 +567,48 @@ private void GenerateIndex(IIndex index) lines.Add($".{nameof(IndexBuilder.IsUnique)}()"); } + // Ascending/descending: + // If all ascending (IsDescending.Count == 0), no need to scaffold IsDescending() call (default) + // If all descending (IsDescending is all true), scaffold parameterless IsDescending() + // Otherwise, scaffold IsDescending() with values up until the last true (unspecified values default to false) + if (index.IsDescending.Count > 0) + { + var descendingBuilder = new StringBuilder() + .Append('.') + .Append(nameof(IndexBuilder.IsDescending)) + .Append('('); + + var isAnyAscending = false; + var lastDescending = -1; + for (var i = 0; i < index.IsDescending.Count; i++) + { + if (index.IsDescending[i]) + { + lastDescending = i; + } + else + { + isAnyAscending = true; + } + } + + if (isAnyAscending) + { + for (var i = 0; i <= lastDescending; i++) + { + if (i > 0) + { + descendingBuilder.Append(", "); + } + + descendingBuilder.Append(_code.Literal(index.IsDescending[i])); + } + } + + descendingBuilder.Append(')'); + lines.Add(descendingBuilder.ToString()); + } + 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..cad7339c887 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.Count > 0) + { + 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..2d9c154db81 100644 --- a/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs +++ b/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs @@ -625,7 +625,12 @@ protected virtual EntityTypeBuilder VisitIndexes(EntityTypeBuilder builder, ICol ? builder.HasIndex(propertyNames) : builder.HasIndex(propertyNames, index.Name); - indexBuilder = indexBuilder.IsUnique(index.IsUnique); + indexBuilder.IsUnique(index.IsUnique); + + if (index.IsDescending.Any(desc => desc)) + { + indexBuilder.IsDescending(index.IsDescending.ToArray()); + } if (index.Filter != null) { diff --git a/src/EFCore.Relational/Metadata/ITableIndex.cs b/src/EFCore.Relational/Metadata/ITableIndex.cs index 93608beeadf..3ea33a757cf 100644 --- a/src/EFCore.Relational/Metadata/ITableIndex.cs +++ b/src/EFCore.Relational/Metadata/ITableIndex.cs @@ -39,6 +39,12 @@ public interface ITableIndex : IAnnotatable /// bool IsUnique { get; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending order. + /// + IReadOnlyList IsDescending { get; } + /// /// Gets the expression used as the index filter. /// @@ -70,8 +76,12 @@ 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}'{(i < IsDescending.Count && IsDescending[i] ? " Desc" : "")}")) + .Append('}'); if (IsUnique) { diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 98db63d5249..6f25c2a2f41 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -952,7 +952,7 @@ private static void PopulateConstraints(Table table) continue; } - tableIndex = new TableIndex(name, table, columns, index.IsUnique); + tableIndex = new TableIndex(name, table, columns, index.IsUnique, index.IsDescending); table.Indexes.Add(name, tableIndex); } diff --git a/src/EFCore.Relational/Metadata/Internal/TableIndex.cs b/src/EFCore.Relational/Metadata/Internal/TableIndex.cs index a23021c34bb..36d5a083ca2 100644 --- a/src/EFCore.Relational/Metadata/Internal/TableIndex.cs +++ b/src/EFCore.Relational/Metadata/Internal/TableIndex.cs @@ -21,12 +21,14 @@ public TableIndex( string name, Table table, IReadOnlyList columns, - bool unique) + bool unique, + IReadOnlyList isDescending) { Name = name; Table = table; Columns = columns; IsUnique = unique; + IsDescending = isDescending; } /// @@ -68,6 +70,9 @@ public override bool IsReadOnly /// public virtual bool IsUnique { get; } + /// + public virtual IReadOnlyList IsDescending { get; } + /// 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..7e0e0bea530 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1393,6 +1393,7 @@ protected virtual IEnumerable Diff( private bool IndexStructureEquals(ITableIndex source, ITableIndex target, DiffContext diffContext) => source.IsUnique == target.IsUnique + && 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..5b6c22b14ce 100644 --- a/src/EFCore.Relational/Migrations/MigrationBuilder.cs +++ b/src/EFCore.Relational/Migrations/MigrationBuilder.cs @@ -601,6 +601,11 @@ 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 less sort order values are provided than there are columns, the remaining columns will have ascending 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 +613,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 +636,11 @@ 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 less sort order values are provided than there are columns, the remaining columns will have ascending 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 +648,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)); @@ -651,6 +664,12 @@ public virtual OperationBuilder CreateIndex( IsUnique = unique, Filter = filter }; + + if (descending is not null) + { + operation.IsDescending = descending; + } + 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..1b427aaae64 100644 --- a/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs +++ b/src/EFCore.Relational/Migrations/Operations/CreateIndexOperation.cs @@ -12,11 +12,6 @@ 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; } - /// /// The name of the index. /// @@ -37,6 +32,17 @@ public class CreateIndexOperation : MigrationOperation, ITableMigrationOperation /// public virtual string[] Columns { get; set; } = null!; + /// + /// 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. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending order. + /// + public virtual bool[] IsDescending { get; set; } = Array.Empty(); + /// /// An expression to use as the index filter. /// @@ -53,11 +59,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/Scaffolding/Metadata/DatabaseIndex.cs b/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs index 92de823af64..50102790d7d 100644 --- a/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs +++ b/src/EFCore.Relational/Scaffolding/Metadata/DatabaseIndex.cs @@ -28,10 +28,16 @@ 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. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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/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..7253c79c301 100644 --- a/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs +++ b/src/EFCore/Metadata/Builders/IConventionIndexBuilder.cs @@ -27,18 +27,36 @@ 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. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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..39105d70492 100644 --- a/src/EFCore/Metadata/Builders/IndexBuilder.cs +++ b/src/EFCore/Metadata/Builders/IndexBuilder.cs @@ -77,6 +77,28 @@ public virtual IndexBuilder IsUnique(bool unique = true) return this; } + /// + /// Configures the sort order(s) for the columns of this index (ascending or descending). + /// + /// + /// If empty, all index columns have descending sort order. Otherwise, each value determines whether the corresponding index + /// column has descending sort order. If less sort order values are provided than there are columns, the remaining columns will have + /// ascending order. + /// + /// The same builder instance so that multiple configuration calls can be chained. + public virtual IndexBuilder IsDescending(params bool[] descending) + { + if (descending.Length == 0) + { + descending = new bool[Builder.Metadata.Properties.Count]; + Array.Fill(descending, true); + } + + 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..ed66c21ba61 100644 --- a/src/EFCore/Metadata/Builders/IndexBuilder`.cs +++ b/src/EFCore/Metadata/Builders/IndexBuilder`.cs @@ -49,4 +49,16 @@ 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). + /// + /// + /// If empty, all index columns have descending sort order. Otherwise, each value determines whether the corresponding index + /// column has descending sort order. If less sort order values are provided than there are columns, the remaining columns will have + /// ascending 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..3f08caf32a3 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.IsUnique(indexAttribute.IsUnique, fromDataAnnotation: true); + } + + if (indexAttribute.IsDescending is not null) + { + 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..0312d7ec5db 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 (_boolConventionContext.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..e75d49b63b5 100644 --- a/src/EFCore/Metadata/IConventionIndex.cs +++ b/src/EFCore/Metadata/IConventionIndex.cs @@ -54,4 +54,21 @@ 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. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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..23db73a7ff0 100644 --- a/src/EFCore/Metadata/IMutableIndex.cs +++ b/src/EFCore/Metadata/IMutableIndex.cs @@ -23,6 +23,12 @@ public interface IMutableIndex : IReadOnlyIndex, IMutableAnnotatable /// new bool IsUnique { get; set; } + /// + /// A set of values indicating whether each corresponding index column has descending sort order. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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..d132969113d 100644 --- a/src/EFCore/Metadata/IReadOnlyIndex.cs +++ b/src/EFCore/Metadata/IReadOnlyIndex.cs @@ -28,6 +28,12 @@ public interface IReadOnlyIndex : IReadOnlyAnnotatable /// bool IsUnique { get; } + /// + /// Gets a set of values indicating whether each corresponding index column has descending sort order. + /// If less sort order values are provided than there are columns, the remaining columns will have ascending 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 diff --git a/src/EFCore/Metadata/Internal/Index.cs b/src/EFCore/Metadata/Internal/Index.cs index f4a2bf54d69..3645301f242 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,74 @@ 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.TooManyIndexSortOrderValues(descending.Count, Properties.Format(), Properties.Count), nameof(descending)); + } + + // Normalize all-false array to the simpler empty array representation + if (descending is not null && descending.All(d => d == false)) + { + descending = DefaultIsDescending; + } + + var oldIsDescending = IsDescending; + var isChanging = _isDescending is null != descending is null + || descending 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 + = Array.Empty(); + + /// + /// 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. /// @@ -369,4 +440,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..19c2a65c49a 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.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..306c3b13af2 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 => Array.Empty(); + } + /// /// 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..acdd0fb22bf 100644 --- a/src/EFCore/Properties/CoreStrings.Designer.cs +++ b/src/EFCore/Properties/CoreStrings.Designer.cs @@ -2585,6 +2585,14 @@ public static string TempValuePersists(object? property, object? entityType, obj GetString("TempValuePersists", "0_property", "1_entityType", nameof(state)), property, entityType, state); + /// + /// Too many index sort order values ({numDescendingValues}) specified for index {indexProperties}, which has only {numProperties} properties. + /// + public static string TooManyIndexSortOrderValues(object? numDescendingValues, object? indexProperties, object? numProperties) + => string.Format( + GetString("TooManyIndexSortOrderValues", nameof(numDescendingValues), nameof(indexProperties), nameof(numProperties)), + numDescendingValues, indexProperties, numProperties); + /// /// The instance of entity type '{runtimeEntityType}' cannot be tracked as the entity type '{entityType}' because the two types are not in the same hierarchy. /// diff --git a/src/EFCore/Properties/CoreStrings.resx b/src/EFCore/Properties/CoreStrings.resx index e2872bc118d..5481f527d8d 100644 --- a/src/EFCore/Properties/CoreStrings.resx +++ b/src/EFCore/Properties/CoreStrings.resx @@ -1397,6 +1397,9 @@ The property '{1_entityType}.{0_property}' has a temporary value while attempting to change the entity's state to '{state}'. Either set a permanent value explicitly, or ensure that the database is configured to generate values for this property. + + Too many index sort order values ({numDescendingValues}) specified for {indexProperties}, which has only {numProperties} properties. + The instance of entity type '{runtimeEntityType}' cannot be tracked as the entity type '{entityType}' because the two types are not in the same hierarchy. diff --git a/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs b/test/EFCore.Design.Tests/Migrations/Design/CSharpMigrationOperationGeneratorTest.cs index 22778a022c3..55454c273bb 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.Empty(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..469756bed64 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,75 @@ 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""); + + 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.Equal(Array.Empty(), emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Equal(Array.Empty(), 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..74d3f331aad 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(); 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(); 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(); x.HasIndex(new[] { "B", "C" }, "IndexOnBAndC") .HasFilter("Filter SQL") .HasAnnotation("AnnotationName", "AnnotationValue"); @@ -696,6 +699,106 @@ 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""); + + entity.HasIndex(e => new { e.X, e.Y, e.Z }, ""IX_all_descending"") + .IsDescending(); + + 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); + + 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.Equal(Array.Empty(), emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Equal(Array.Empty(), 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 }, 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..ffc906f6940 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(); 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, true })] [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..58f9bad1376 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.Equal(Array.Empty(), emptyIndex.IsDescending); + + var allAscendingIndex = Assert.Single(entityType.GetIndexes(), i => i.Name == "IX_all_ascending"); + Assert.Equal(Array.Empty(), 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..3d6baf6133c 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,46 @@ 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(), + 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), + 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.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..f6871e70a40 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -1246,6 +1246,22 @@ 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 Create_index_with_filter() { await base.Create_index_with_filter(); 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..7af816c8073 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(); 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]