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
46 changes: 38 additions & 8 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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
- **Redundant ordering detection**: Optionally throw when a query explicitly applies the same ordering as the configured default, per context or process wide


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


Expand All @@ -104,7 +104,7 @@ var secondPage = await context.Employees
var first = await context.Employees
.FirstAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L89-L103' title='Snippet source file'>snippet source</a> | <a href='#snippet-PagingAndSingleResults' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L101-L115' title='Snippet source file'>snippet source</a> | <a href='#snippet-PagingAndSingleResults' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Without this, a page would be an arbitrary set of rows that happens to be sorted, and pages
Expand All @@ -127,7 +127,7 @@ var departments = await context.Departments
.Include(_ => _.Employees)
.ToListAsync();
```
<sup><a href='/src/Tests/Snippets.cs#L110-L118' 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#L122-L130' title='Snippet source file'>snippet source</a> | <a href='#snippet-IncludeSupport' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down Expand Up @@ -181,7 +181,7 @@ public class InheritanceDbContext : DbContext
public DbSet<DerivedEntityB> DerivedEntitiesB => Set<DerivedEntityB>();
}
```
<sup><a href='/src/Tests/Snippets.cs#L186-L231' 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#L198-L243' title='Snippet source file'>snippet source</a> | <a href='#snippet-InheritanceOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

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


Expand Down Expand Up @@ -287,7 +287,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
createIndexes: false);
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L68-L74' 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 @@ -388,6 +388,36 @@ var departments = await context.Departments
Detection happens during query compilation, so it applies to every distinct query the application executes, whether or not the query hits the database.


### Enabling for a whole test project

Setting it on every `DbContext` gets tedious in a test project that has several. `OrderBySettings.ThrowOnRedundantOrderBy` is a process wide default, so a module initializer turns it on for all of them:

<!-- snippet: ThrowOnRedundantOrderBySetting -->
<a id='snippet-ThrowOnRedundantOrderBySetting'></a>
```cs
[ModuleInitializer]
public static void Initialize() =>
OrderBySettings.ThrowOnRedundantOrderBy = true;
```
<sup><a href='/src/Settings.Tests/ModuleInitializer.cs#L6-L12' title='Snippet source file'>snippet source</a> | <a href='#snippet-ThrowOnRedundantOrderBySetting' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

`UseDefaultOrderBy` reads this setting only when its `throwOnRedundantOrderBy` parameter is left unset. Passing a value always wins, so an individual context can opt out:

<!-- snippet: OptOutOfThrowOnRedundantOrderBy -->
<a id='snippet-OptOutOfThrowOnRedundantOrderBy'></a>
```cs
// Passing false explicitly overrides OrderBySettings.ThrowOnRedundantOrderBy
protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
throwOnRedundantOrderBy: false);
```
<sup><a href='/src/Tests/Snippets.cs#L56-L63' title='Snippet source file'>snippet source</a> | <a href='#snippet-OptOutOfThrowOnRedundantOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The setting is read during query compilation rather than when the options are built, so it applies to contexts whose options were built before the initializer ran.


## Configuration Errors

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


Expand Down
5 changes: 4 additions & 1 deletion src/EfOrderBy/Interceptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ public Expression QueryCompilationStarting(Expression query, QueryExpressionEven

RequiredOrder.Validate(context, extension);

var detectRedundantOrdering = extension?.ThrowOnRedundantOrderBy ?? false;
// Read per query rather than when the options are built, so a module initializer that
// runs after some options were already built still takes effect
var detectRedundantOrdering = extension?.ThrowOnRedundantOrderBy ??
OrderBySettings.ThrowOnRedundantOrderBy;

// Analyze the query for ordering and includes in a single pass
var (hasOrdering, hasInclude) = QueryAnalyzer.Analyze(query);
Expand Down
4 changes: 3 additions & 1 deletion src/EfOrderBy/OrderByExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ public static class OrderByExtensions
/// 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.
/// When null (default), <see cref="OrderBySettings.ThrowOnRedundantOrderBy" /> is used,
/// so passing false here opts this context out of that process wide setting.
/// </param>
public static DbContextOptionsBuilder UseDefaultOrderBy(
this DbContextOptionsBuilder builder,
bool requireOrderingForAllEntities = false,
bool? createIndexes = true,
bool throwOnRedundantOrderBy = false)
bool? throwOnRedundantOrderBy = null)
{
builder.AddInterceptors(interceptor);

Expand Down
18 changes: 18 additions & 0 deletions src/EfOrderBy/OrderBySettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace EfOrderBy;

/// <summary>
/// Process wide defaults for <see cref="OrderByExtensions.UseDefaultOrderBy" />.
/// </summary>
/// <remarks>
/// Intended to be set from a module initializer in a test project, so every <see cref="DbContext" />
/// in that project opts in without each <see cref="OrderByExtensions.UseDefaultOrderBy" /> call
/// having to be changed. A value passed to that method always wins, whether true or false.
/// </remarks>
public static class OrderBySettings
{
/// <summary>
/// The default used when the throwOnRedundantOrderBy parameter of
/// <see cref="OrderByExtensions.UseDefaultOrderBy" /> is left null. Defaults to false.
/// </summary>
public static bool ThrowOnRedundantOrderBy { get; set; }
}
18 changes: 14 additions & 4 deletions src/EfOrderBy/OrderRequiredExtension.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool createIndexes, bool throwOnRedundantOrderBy) :
sealed class OrderRequiredExtension(bool requireOrderingForAllEntities, bool createIndexes, bool? throwOnRedundantOrderBy) :
IDbContextOptionsExtension
{
public bool RequireOrderingForAllEntities { get; } = requireOrderingForAllEntities;
public bool ThrowOnRedundantOrderBy { get; } = throwOnRedundantOrderBy;

// Null means the caller did not set it, so OrderBySettings decides. An explicit false is
// not the same thing: it opts this context out of a process wide setting of true.
public bool? ThrowOnRedundantOrderBy { get; } = throwOnRedundantOrderBy;

bool CreateIndexes { get; } = createIndexes;

public DbContextOptionsExtensionInfo Info => new ExtensionInfo(this);
Expand Down Expand Up @@ -34,7 +38,13 @@ class ExtensionInfo(IDbContextOptionsExtension extension) :
public override string LogFragment =>
$"{(Extension.RequireOrderingForAllEntities ? "RequireOrderingForAllEntities " : "")}" +
$"{(Extension.CreateIndexes ? "" : "CreateIndexes=false ")}" +
$"{(Extension.ThrowOnRedundantOrderBy ? "ThrowOnRedundantOrderBy " : "")}";
// An explicit false is worth logging, since it overrides OrderBySettings
Extension.ThrowOnRedundantOrderBy switch
{
true => "ThrowOnRedundantOrderBy ",
false => "ThrowOnRedundantOrderBy=false ",
null => ""
};

public override int GetServiceProviderHashCode() =>
HashCode.Combine(Extension.RequireOrderingForAllEntities, Extension.CreateIndexes, Extension.ThrowOnRedundantOrderBy);
Expand All @@ -49,7 +59,7 @@ 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();
debugInfo["DefaultOrderBy:ThrowOnRedundantOrderBy"] = Extension.ThrowOnRedundantOrderBy?.ToString() ?? "null";
}
}
}
1 change: 1 addition & 0 deletions src/EntityFramework.OrderBy.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
<File Path="Directory.Packages.props" />
</Folder>
<Project Path="EfOrderBy/EfOrderBy.csproj" />
<Project Path="Settings.Tests/Settings.Tests.csproj" />
<Project Path="Tests/Tests.csproj" />
</Solution>
4 changes: 4 additions & 0 deletions src/Settings.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
global using System.Runtime.CompilerServices;
global using EfOrderBy;
global using Microsoft.EntityFrameworkCore;
global using NUnit.Framework;
13 changes: 13 additions & 0 deletions src/Settings.Tests/ModuleInitializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
public static class ModuleInitializer
{
// The whole point of this project. OrderBySettings is process wide, so it cannot be
// exercised alongside tests that expect redundant ordering to be allowed, which is why
// these tests live in their own assembly.
#region ThrowOnRedundantOrderBySetting

[ModuleInitializer]
public static void Initialize() =>
OrderBySettings.ThrowOnRedundantOrderBy = true;

#endregion
}
17 changes: 17 additions & 0 deletions src/Settings.Tests/Settings.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
<ProjectReference Include="..\EfOrderBy\EfOrderBy.csproj" />
</ItemGroup>

</Project>
Loading
Loading