Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ var employeesByName = await context.Employees
.OrderBy(_ => _.Name)
.ToListAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L51-L62' title='Snippet source file'>snippet source</a> | <a href='#snippet-QueryWithoutOrderBy' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L62-L73' title='Snippet source file'>snippet source</a> | <a href='#snippet-QueryWithoutOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -93,7 +93,7 @@ var departments = await context.Departments
.Include(_ => _.Employees)
.ToListAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L69-L77' title='Snippet source file'>snippet source</a> | <a href='#snippet-IncludeSupport' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L80-L88' title='Snippet source file'>snippet source</a> | <a href='#snippet-IncludeSupport' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -109,7 +109,7 @@ builder.Entity<Product>()
.ThenBy(_ => _.Name)
.ThenByDescending(_ => _.Price);
```
<sup><a href='/src/Tests/Snippets.cs#L82-L89' title='Snippet source file'>snippet source</a> | <a href='#snippet-MultiColumnOrdering' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L93-L100' title='Snippet source file'>snippet source</a> | <a href='#snippet-MultiColumnOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -147,6 +147,23 @@ builder.Entity<EntityWithVeryLongNameThatWouldExceedTheLimit>()
If the auto-generated name exceeds 128 characters, an `InvalidOperationException` is thrown with a message suggesting to use `WithIndexName()`.


### Disabling Index Creation

To opt out of automatic index creation (for example, if indexes are managed separately):

<!-- snippet: DisableIndexCreation -->
<a id='snippet-DisableIndexCreation'></a>
```cs
protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
createIndexes: false);
```
<sup><a href='/src/Tests/Snippets.cs#L47-L53' title='Snippet source file'>snippet source</a> | <a href='#snippet-DisableIndexCreation' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

When index creation is disabled, calling `WithIndexName()` throws an `InvalidOperationException`.


## Require Ordering for All Entities

Enable validation mode to ensure all entities have default ordering configured:
Expand Down Expand Up @@ -238,7 +255,7 @@ public class AppDbContext : DbContext
public DbSet<Employee> Employees => Set<Employee>();
}
```
<sup><a href='/src/Tests/Snippets.cs#L93-L136' title='Snippet source file'>snippet source</a> | <a href='#snippet-CompleteExample' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L104-L147' title='Snippet source file'>snippet source</a> | <a href='#snippet-CompleteExample' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down
9 changes: 9 additions & 0 deletions src/EfOrderBy/OrderByBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ public sealed class OrderByBuilder<TEntity>
const int maxIndexNameLength = 128;

Configuration configuration;
IMutableModel model;

internal OrderByBuilder(EntityTypeBuilder<TEntity> builder, PropertyInfo propertyInfo, bool descending)
{
model = builder.Metadata.Model;
configuration = new(typeof(TEntity));
configuration.AddClause(propertyInfo, descending, isThenBy: false);

Expand Down Expand Up @@ -45,6 +47,13 @@ public OrderByBuilder<TEntity> ThenByDescending<TProperty>(Expression<Func<TEnti
/// </summary>
public OrderByBuilder<TEntity> WithIndexName(string indexName)
{
if (model.FindAnnotation(OrderByExtensions.IndexCreationDisabledAnnotation) != null)
{
throw new InvalidOperationException(
"WithIndexName() cannot be used when index creation is disabled. " +
"Remove the createIndexes: false option from UseDefaultOrderBy() or remove the WithIndexName() call.");
}

if (string.IsNullOrWhiteSpace(indexName))
{
throw new ArgumentException("Index name cannot be null or whitespace.", nameof(indexName));
Expand Down
10 changes: 8 additions & 2 deletions src/EfOrderBy/OrderByExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public static class OrderByExtensions
{
internal const string AnnotationName = "DefaultOrderBy:Configuration";
internal const string InterceptorRegisteredAnnotation = "DefaultOrderBy:InterceptorRegistered";
internal const string IndexCreationDisabledAnnotation = "DefaultOrderBy:IndexCreationDisabled";
static Interceptor interceptor = new();

/// <summary>
Expand All @@ -19,15 +20,20 @@ public static class OrderByExtensions
/// in the model doesn't have default ordering configured. Validation occurs
/// once per DbContext type.
/// </param>
/// <param name="createIndexes">
/// When true (default), automatically creates database indexes for configured orderings.
/// Set to false to disable automatic index creation.
/// </param>
public static DbContextOptionsBuilder UseDefaultOrderBy(
this DbContextOptionsBuilder builder,
bool requireOrderingForAllEntities = false)
bool requireOrderingForAllEntities = false,
bool? createIndexes = true)
{
builder.AddInterceptors(interceptor);

// Always add the extension to register the convention that marks the model
((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(
new OrderRequiredExtension(requireOrderingForAllEntities));
new OrderRequiredExtension(requireOrderingForAllEntities, createIndexes ?? true));

return builder;
}
Expand Down
27 changes: 18 additions & 9 deletions src/EfOrderBy/OrderRequiredExtension.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
/// <summary>
/// Options extension to store default ordering configuration.
/// </summary>
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities) :
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool createIndexes) :
IDbContextOptionsExtension
{
public bool RequireOrderingForAllEntities { get; } = requireOrderingForAllEntities;
public bool CreateIndexes { get; } = createIndexes;

public DbContextOptionsExtensionInfo Info => new ExtensionInfo(this);

public void ApplyServices(IServiceCollection services) =>
services.AddSingleton<IConventionSetPlugin, UseDefaultOrderByConventionPlugin>();
public void ApplyServices(IServiceCollection services)
{
var createIndexes = CreateIndexes;
services.AddSingleton<IConventionSetPlugin>(_ => new UseDefaultOrderByConventionPlugin(createIndexes));
}

public void Validate(IDbContextOptions options)
{
Expand All @@ -22,18 +26,23 @@ class ExtensionInfo(IDbContextOptionsExtension extension) : DbContextOptionsExte
public override bool IsDatabaseProvider => false;

public override string LogFragment =>
Extension.RequireOrderingForAllEntities
? "RequireOrderingForAllEntities "
: "";
$"{(Extension.RequireOrderingForAllEntities ? "RequireOrderingForAllEntities " : "")}" +
$"{(Extension.CreateIndexes ? "" : "CreateIndexes=false ")}";

public override int GetServiceProviderHashCode() => Extension.RequireOrderingForAllEntities.GetHashCode();
public override int GetServiceProviderHashCode() =>
HashCode.Combine(Extension.RequireOrderingForAllEntities, Extension.CreateIndexes);

public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) =>
other is ExtensionInfo otherInfo &&
Extension.RequireOrderingForAllEntities == otherInfo.Extension.RequireOrderingForAllEntities;
Extension.RequireOrderingForAllEntities == otherInfo.Extension.RequireOrderingForAllEntities &&
Extension.CreateIndexes == otherInfo.Extension.CreateIndexes;

public override void PopulateDebugInfo(IDictionary<string, string> debugInfo) =>
public override void PopulateDebugInfo(IDictionary<string, string> debugInfo)
{
debugInfo["DefaultOrderBy:RequireOrderingForAllEntities"] =
Extension.RequireOrderingForAllEntities.ToString();
debugInfo["DefaultOrderBy:CreateIndexes"] =
Extension.CreateIndexes.ToString();
}
}
}
25 changes: 19 additions & 6 deletions src/EfOrderBy/UseDefaultOrderByConventionPlugin.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
/// <summary>
/// Convention plugin that marks the model as having UseDefaultOrderBy() configured.
/// </summary>
sealed class UseDefaultOrderByConventionPlugin : IConventionSetPlugin
sealed class UseDefaultOrderByConventionPlugin(bool createIndexes) : IConventionSetPlugin
{
public ConventionSet ModifyConventions(ConventionSet conventionSet)
{
conventionSet.ModelInitializedConventions.Add(new UseDefaultOrderByConvention());
conventionSet.ModelFinalizingConventions.Add(new OrderByIndexConvention());
conventionSet.ModelInitializedConventions.Add(new UseDefaultOrderByConvention(createIndexes));

if (createIndexes)
{
conventionSet.ModelFinalizingConventions.Add(new OrderByIndexConvention());
}

return conventionSet;
}
}

/// <summary>
/// Convention that sets an annotation on the model indicating UseDefaultOrderBy() was called.
/// Convention that sets annotations on the model indicating UseDefaultOrderBy() was called
/// and whether index creation is enabled.
/// </summary>
sealed class UseDefaultOrderByConvention : IModelInitializedConvention
sealed class UseDefaultOrderByConvention(bool createIndexes) : IModelInitializedConvention
{
public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context) =>
public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
{
modelBuilder.HasAnnotation(OrderByExtensions.InterceptorRegisteredAnnotation, true);

if (!createIndexes)
{
modelBuilder.HasAnnotation(OrderByExtensions.IndexCreationDisabledAnnotation, true);
}
}
}

/// <summary>
Expand Down
54 changes: 52 additions & 2 deletions src/Tests/IndexNameTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[TestFixture]
public class IndexNameTests
{
static DbContextOptions CreateOptions() =>
static DbContextOptions CreateOptions(bool? createIndexes = true) =>
new DbContextOptionsBuilder()
.UseSqlServer("Server=.;Database=Test;Trusted_Connection=True")
.UseDefaultOrderBy()
.UseDefaultOrderBy(createIndexes: createIndexes)
.Options;

[Test]
Expand Down Expand Up @@ -72,6 +72,29 @@ public void CustomIndexName_NullOrEmpty_ThrowsArgumentException()
Assert.That(exception.Message, Does.Contain("cannot be null or whitespace"));
}

[Test]
public void CreateIndexes_False_NoIndexCreated()
{
using var context = new ContextWithIndexCreationDisabled(CreateOptions(createIndexes: false));
_ = context.Model;

var entityType = context.Model.FindEntityType(typeof(ShortEntity))!;
var indexes = entityType.GetIndexes().ToList();
Assert.That(indexes, Is.Empty);
}

[Test]
public void CreateIndexes_False_WithIndexName_ThrowsInvalidOperationException()
{
var exception = Assert.Throws<InvalidOperationException>(() =>
{
using var context = new ContextWithIndexCreationDisabledAndWithIndexName(CreateOptions(createIndexes: false));
_ = context.Model;
})!;

Assert.That(exception.Message, Does.Contain("index creation is disabled"));
}

[Test]
public void WithIndexName_CanBeChainedWithThenBy()
{
Expand Down Expand Up @@ -171,6 +194,33 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
}
}

class ContextWithIndexCreationDisabled(DbContextOptions options)
: DbContext(options)
{
public DbSet<ShortEntity> Entities => Set<ShortEntity>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ShortEntity>()
.OrderBy(_ => _.Name);
}
}

class ContextWithIndexCreationDisabledAndWithIndexName(DbContextOptions options)
: DbContext(options)
{
public DbSet<ShortEntity> Entities => Set<ShortEntity>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ShortEntity>()
.OrderBy(_ => _.Name)
.WithIndexName("IX_Custom");
}
}

public class ShortEntity
{
public int Id { get; set; }
Expand Down
11 changes: 11 additions & 0 deletions src/Tests/Snippets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
#endregion
}

public class DisableIndexCreationExample : DbContext
{
#region DisableIndexCreation

protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
createIndexes: false);

#endregion
}

public class SnippetExamples
{
static async Task QueryWithoutOrderBy()
Expand Down
Loading