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
83 changes: 77 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ https://nuget.org/packages/EfOrderBy/
- **Multi-column ordering**: Chain multiple ordering clauses with `ThenBy` and `ThenByDescending`
- **Automatic indexes**: Database indexes are automatically created for ordering columns
- **Validation mode**: Optionally require all entities to have default ordering configured
- **Redundant ordering detection**: Optionally throw when a query explicitly applies the same ordering as the configured default


## Usage
Expand Down Expand Up @@ -77,7 +78,7 @@ var employeesByName = await context.Employees
.OrderBy(_ => _.Name)
.ToListAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L60-L71' 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#L71-L82' title='Snippet source file'>snippet source</a> | <a href='#snippet-QueryWithoutOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -94,7 +95,7 @@ var departments = await context.Departments
.Include(_ => _.Employees)
.ToListAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L78-L86' 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#L89-L97' title='Snippet source file'>snippet source</a> | <a href='#snippet-IncludeSupport' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -148,7 +149,7 @@ public class InheritanceDbContext : DbContext
public DbSet<DerivedEntityB> DerivedEntitiesB => Set<DerivedEntityB>();
}
```
<sup><a href='/src/Tests/Snippets.cs#L154-L199' title='Snippet source file'>snippet source</a> | <a href='#snippet-InheritanceOrdering' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L165-L210' title='Snippet source file'>snippet source</a> | <a href='#snippet-InheritanceOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Behavior:
Expand All @@ -171,7 +172,7 @@ builder.Entity<Product>()
.ThenBy(_ => _.Name)
.ThenByDescending(_ => _.Price);
```
<sup><a href='/src/Tests/Snippets.cs#L91-L98' 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#L102-L109' title='Snippet source file'>snippet source</a> | <a href='#snippet-MultiColumnOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -254,7 +255,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
createIndexes: false);
```
<sup><a href='/src/Tests/Snippets.cs#L45-L51' title='Snippet source file'>snippet source</a> | <a href='#snippet-DisableIndexCreation' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L56-L62' 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 `Exception`.
Expand Down Expand Up @@ -285,6 +286,76 @@ Use modelBuilder.Entity<T>().OrderBy() to configure default ordering.
Validation occurs once per `DbContext` type for performance.


## Detect Redundant Ordering

Explicit ordering in a query takes precedence over the configured default. When that explicit ordering is an exact match for the default, it is redundant. This is usually a leftover from before the default ordering was configured.

Enable detection to throw when a query does this. This is primarily useful in tests:

<!-- snippet: ThrowOnRedundantOrderBy -->
<a id='snippet-ThrowOnRedundantOrderBy'></a>
```cs
protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
throwOnRedundantOrderBy: true);
```
<sup><a href='/src/Tests/Snippets.cs#L45-L51' title='Snippet source file'>snippet source</a> | <a href='#snippet-ThrowOnRedundantOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Given this configuration:

```cs
builder.Entity<Employee>()
.OrderBy(_ => _.HireDate)
.ThenByDescending(_ => _.Salary);
```

The following query throws, since it applies exactly the default ordering:

```cs
var employees = await context.Employees
.OrderBy(_ => _.HireDate)
.ThenByDescending(_ => _.Salary)
.ToListAsync();
```

```
The query explicitly orders 'Employee' by OrderBy(HireDate).ThenByDescending(Salary),
which exactly matches the default ordering configured for that entity.
Remove the ordering from the query since it is applied automatically,
or change it to a different ordering.
```

The match must be exact: the same properties, in the same sequence, with the same directions. Anything else is treated as an intentional override and does not throw:

```cs
// Only part of the default ordering
.OrderBy(_ => _.HireDate)

// Different direction
.OrderByDescending(_ => _.HireDate)
.ThenByDescending(_ => _.Salary)

// Additional ordering column
.OrderBy(_ => _.HireDate)
.ThenByDescending(_ => _.Salary)
.ThenBy(_ => _.Name)
```

Ordering of nested collections in `Include()` is checked the same way:

```cs
// Throws, since it matches the default ordering for Employee
var departments = await context.Departments
.Include(_ => _.Employees
.OrderBy(employee => employee.HireDate)
.ThenByDescending(employee => employee.Salary))
.ToListAsync();
```

Detection happens during query compilation, so it applies to every distinct query the application executes, whether or not the query hits the database.


## Configuration Errors

Calling `OrderBy` or `OrderByDescending` multiple times for the same entity type throws an `Exception`:
Expand Down Expand Up @@ -351,7 +422,7 @@ public class AppDbContext : DbContext
public DbSet<Employee> Employees => Set<Employee>();
}
```
<sup><a href='/src/Tests/Snippets.cs#L102-L145' 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#L113-L156' title='Snippet source file'>snippet source</a> | <a href='#snippet-CompleteExample' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down
7 changes: 6 additions & 1 deletion src/EfOrderBy/IncludeOrderingApplicator.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <summary>
/// Visitor that applies default ordering to nested collections in Include().
/// </summary>
sealed class IncludeOrderingApplicator(IModel model) :
sealed class IncludeOrderingApplicator(IModel model, bool detectRedundantOrdering) :
ExpressionVisitor
{
static readonly ConcurrentDictionary<Type, Type?> collectionElementTypeCache = new();
Expand Down Expand Up @@ -48,6 +48,11 @@ MethodCallExpression ProcessInclude(MethodCallExpression includeCall)
if (Interceptor.HasOrdering(lambda.Body))
{
// Already has explicit ordering, don't apply default
if (detectRedundantOrdering)
{
RedundantOrder.Validate(lambda.Body);
}

return includeCall;
}

Expand Down
9 changes: 8 additions & 1 deletion src/EfOrderBy/Interceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ public Expression QueryCompilationStarting(Expression query, QueryExpressionEven
var model = context.Model;
RequiredOrder.Validate(context);

var detectRedundantOrdering = RedundantOrder.IsEnabled(context);

// First, process Include nodes to add ordering to nested collections
var visitor = new IncludeOrderingApplicator(model);
var visitor = new IncludeOrderingApplicator(model, detectRedundantOrdering);
var queryWithOrderedIncludes = visitor.Visit(query);

// Analyze the query for ordering and includes in a single pass
Expand All @@ -26,6 +28,11 @@ public Expression QueryCompilationStarting(Expression query, QueryExpressionEven

if (analyzer.HasOrdering)
{
if (detectRedundantOrdering)
{
RedundantOrder.Validate(query);
}

return queryWithOrderedIncludes;
}

Expand Down
10 changes: 8 additions & 2 deletions src/EfOrderBy/OrderByExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,22 @@ public static class OrderByExtensions
/// When true (default), automatically creates database indexes for configured orderings.
/// Set to false to disable automatic index creation.
/// </param>
/// <param name="throwOnRedundantOrderBy">
/// When true, throws an exception when a query explicitly orders by exactly the same
/// properties and directions as the configured default ordering. Useful in tests to
/// find query ordering that is redundant because the default ordering already applies it.
/// </param>
public static DbContextOptionsBuilder UseDefaultOrderBy(
this DbContextOptionsBuilder builder,
bool requireOrderingForAllEntities = false,
bool? createIndexes = true)
bool? createIndexes = true,
bool throwOnRedundantOrderBy = false)
{
builder.AddInterceptors(interceptor);

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

return builder;
}
Expand Down
12 changes: 8 additions & 4 deletions src/EfOrderBy/OrderRequiredExtension.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool createIndexes) :
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool createIndexes, bool throwOnRedundantOrderBy) :
IDbContextOptionsExtension
{
public bool RequireOrderingForAllEntities { get; } = requireOrderingForAllEntities;
public bool ThrowOnRedundantOrderBy { get; } = throwOnRedundantOrderBy;
bool CreateIndexes { get; } = createIndexes;

public DbContextOptionsExtensionInfo Info => new ExtensionInfo(this);
Expand Down Expand Up @@ -32,20 +33,23 @@ class ExtensionInfo(IDbContextOptionsExtension extension) :

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

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

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

public override void PopulateDebugInfo(IDictionary<string, string> debugInfo)
{
debugInfo["DefaultOrderBy:RequireOrderingForAllEntities"] = Extension.RequireOrderingForAllEntities.ToString();
debugInfo["DefaultOrderBy:CreateIndexes"] = Extension.CreateIndexes.ToString();
debugInfo["DefaultOrderBy:ThrowOnRedundantOrderBy"] = Extension.ThrowOnRedundantOrderBy.ToString();
}
}
}
Loading
Loading