From 48bb34a12199ccdd81331e1d245047550ed8b63f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 24 Jul 2026 18:13:49 +1000 Subject: [PATCH 1/3] Add a process wide default for throwOnRedundantOrderBy Enabling redundant ordering detection meant editing every UseDefaultOrderBy call, which is tedious in a test project with several DbContext types. OrderBySettings.ThrowOnRedundantOrderBy is a static that a module initializer can set once for the whole assembly. For a context to be able to opt out of that, the parameter has to distinguish "not set" from "set to false", so it is now bool?. Null means the setting decides, and any value passed wins over it. The setting is read during query compilation rather than when the options are built, so a module initializer still takes effect for options that were built before it ran. --- src/EfOrderBy/Interceptor.cs | 5 ++++- src/EfOrderBy/OrderByExtensions.cs | 4 +++- src/EfOrderBy/OrderBySettings.cs | 18 ++++++++++++++++++ src/EfOrderBy/OrderRequiredExtension.cs | 18 ++++++++++++++---- 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 src/EfOrderBy/OrderBySettings.cs diff --git a/src/EfOrderBy/Interceptor.cs b/src/EfOrderBy/Interceptor.cs index 63ab96a..fee34d9 100644 --- a/src/EfOrderBy/Interceptor.cs +++ b/src/EfOrderBy/Interceptor.cs @@ -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); diff --git a/src/EfOrderBy/OrderByExtensions.cs b/src/EfOrderBy/OrderByExtensions.cs index 06c43e2..fee4bfd 100644 --- a/src/EfOrderBy/OrderByExtensions.cs +++ b/src/EfOrderBy/OrderByExtensions.cs @@ -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), is used, + /// so passing false here opts this context out of that process wide setting. /// public static DbContextOptionsBuilder UseDefaultOrderBy( this DbContextOptionsBuilder builder, bool requireOrderingForAllEntities = false, bool? createIndexes = true, - bool throwOnRedundantOrderBy = false) + bool? throwOnRedundantOrderBy = null) { builder.AddInterceptors(interceptor); diff --git a/src/EfOrderBy/OrderBySettings.cs b/src/EfOrderBy/OrderBySettings.cs new file mode 100644 index 0000000..db8e121 --- /dev/null +++ b/src/EfOrderBy/OrderBySettings.cs @@ -0,0 +1,18 @@ +namespace EfOrderBy; + +/// +/// Process wide defaults for . +/// +/// +/// Intended to be set from a module initializer in a test project, so every +/// in that project opts in without each call +/// having to be changed. A value passed to that method always wins, whether true or false. +/// +public static class OrderBySettings +{ + /// + /// The default used when the throwOnRedundantOrderBy parameter of + /// is left null. Defaults to false. + /// + public static bool ThrowOnRedundantOrderBy { get; set; } +} diff --git a/src/EfOrderBy/OrderRequiredExtension.cs b/src/EfOrderBy/OrderRequiredExtension.cs index 8d5d61d..0cdfcf7 100644 --- a/src/EfOrderBy/OrderRequiredExtension.cs +++ b/src/EfOrderBy/OrderRequiredExtension.cs @@ -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); @@ -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); @@ -49,7 +59,7 @@ public override void PopulateDebugInfo(IDictionary 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"; } } } From 4c965fb49b1d8ee25c310572ad0d416310192e4a Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 24 Jul 2026 18:13:59 +1000 Subject: [PATCH 2/3] Add a test project for the process wide setting OrderBySettings is process wide, so it cannot be exercised in the existing Tests assembly, where several tests expect redundant ordering to be allowed. A second assembly sets it in a module initializer and covers the three states: not set on the context so the setting applies, false on the context so it overrides the setting, and true on the context. Include ordering is covered too, since the flag is passed down to it. Named Settings.Tests rather than SettingsTests because ProjectDefaults only infers IsPackageProject=false for a project named Tests or ending in .Tests, and otherwise requires XML docs on every public member. --- src/EntityFramework.OrderBy.slnx | 1 + src/Settings.Tests/GlobalUsings.cs | 4 + src/Settings.Tests/ModuleInitializer.cs | 13 ++ src/Settings.Tests/Settings.Tests.csproj | 17 ++ .../ThrowOnRedundantOrderBySettingTests.cs | 163 ++++++++++++++++++ 5 files changed, 198 insertions(+) create mode 100644 src/Settings.Tests/GlobalUsings.cs create mode 100644 src/Settings.Tests/ModuleInitializer.cs create mode 100644 src/Settings.Tests/Settings.Tests.csproj create mode 100644 src/Settings.Tests/ThrowOnRedundantOrderBySettingTests.cs diff --git a/src/EntityFramework.OrderBy.slnx b/src/EntityFramework.OrderBy.slnx index 1d9b83c..c20f208 100644 --- a/src/EntityFramework.OrderBy.slnx +++ b/src/EntityFramework.OrderBy.slnx @@ -8,5 +8,6 @@ + diff --git a/src/Settings.Tests/GlobalUsings.cs b/src/Settings.Tests/GlobalUsings.cs new file mode 100644 index 0000000..bc303a0 --- /dev/null +++ b/src/Settings.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Runtime.CompilerServices; +global using EfOrderBy; +global using Microsoft.EntityFrameworkCore; +global using NUnit.Framework; diff --git a/src/Settings.Tests/ModuleInitializer.cs b/src/Settings.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..9631cb4 --- /dev/null +++ b/src/Settings.Tests/ModuleInitializer.cs @@ -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 +} diff --git a/src/Settings.Tests/Settings.Tests.csproj b/src/Settings.Tests/Settings.Tests.csproj new file mode 100644 index 0000000..3bb5837 --- /dev/null +++ b/src/Settings.Tests/Settings.Tests.csproj @@ -0,0 +1,17 @@ + + + + false + true + + + + + + + + + + + + diff --git a/src/Settings.Tests/ThrowOnRedundantOrderBySettingTests.cs b/src/Settings.Tests/ThrowOnRedundantOrderBySettingTests.cs new file mode 100644 index 0000000..00edbd7 --- /dev/null +++ b/src/Settings.Tests/ThrowOnRedundantOrderBySettingTests.cs @@ -0,0 +1,163 @@ +[TestFixture] +public class ThrowOnRedundantOrderBySettingTests +{ + static DbContextOptions unset = + new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;") + .UseDefaultOrderBy() + .Options; + + static DbContextOptions optedOut = + new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;") + .UseDefaultOrderBy(throwOnRedundantOrderBy: false) + .Options; + + static DbContextOptions optedIn = + new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;") + .UseDefaultOrderBy(throwOnRedundantOrderBy: true) + .Options; + + [Test] + public void ModuleInitializerAppliesTheSetting() => + Assert.That(OrderBySettings.ThrowOnRedundantOrderBy, Is.True); + + [Test] + public void NotSetOnTheContext_UsesTheSetting() + { + using var context = new UnsetContext(unset); + + var exception = Assert.Throws( + () => context.Entities + .OrderBy(_ => _.Name) + .ThenByDescending(_ => _.Priority) + .ToQueryString()); + + Assert.That(exception!.Message, Does.Contain("SettingsEntity")); + Assert.That(exception.Message, Does.Contain("OrderBy(Name).ThenByDescending(Priority)")); + } + + [Test] + public void FalseOnTheContext_OverridesTheSetting() + { + using var context = new OptedOutContext(optedOut); + + Assert.DoesNotThrow( + () => context.Entities + .OrderBy(_ => _.Name) + .ThenByDescending(_ => _.Priority) + .ToQueryString()); + } + + [Test] + public void TrueOnTheContext_AgreesWithTheSetting() + { + using var context = new OptedInContext(optedIn); + + Assert.Throws( + () => context.Entities + .OrderBy(_ => _.Name) + .ThenByDescending(_ => _.Priority) + .ToQueryString()); + } + + [Test] + public void NotSetOnTheContext_UsesTheSettingForIncludes() + { + using var context = new UnsetContext(unset); + + Assert.Throws( + () => context.Entities + .Include(_ => _.Children.OrderBy(child => child.SortOrder)) + .ToQueryString()); + } + + [Test] + public void FalseOnTheContext_OverridesTheSettingForIncludes() + { + using var context = new OptedOutContext(optedOut); + + Assert.DoesNotThrow( + () => context.Entities + .Include(_ => _.Children.OrderBy(child => child.SortOrder)) + .ToQueryString()); + } + + [Test] + public void NonRedundantOrdering_DoesNotThrow() + { + using var context = new UnsetContext(unset); + + Assert.DoesNotThrow( + () => context.Entities + .OrderByDescending(_ => _.Name) + .ToQueryString()); + } +} + +public class SettingsEntity +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public int Priority { get; set; } + public List Children { get; set; } = []; +} + +public class SettingsChild +{ + public int Id { get; set; } + public int SettingsEntityId { get; set; } + public SettingsEntity Parent { get; set; } = null!; + public int SortOrder { get; set; } +} + +public class UnsetContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder builder) => + builder.ConfigureSettingsEntities(); +} + +public class OptedOutContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder builder) => + builder.ConfigureSettingsEntities(); +} + +public class OptedInContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder builder) => + builder.ConfigureSettingsEntities(); +} + +static class SettingsModelBuilder +{ + // All three contexts share these entities, so the configuration must match + public static void ConfigureSettingsEntities(this ModelBuilder builder) + { + builder.Entity() + .HasMany(_ => _.Children) + .WithOne(_ => _.Parent) + .HasForeignKey(_ => _.SettingsEntityId) + .IsRequired(); + + builder.Entity() + .Property(_ => _.Name).HasMaxLength(450); + + builder.Entity() + .OrderBy(_ => _.Name) + .ThenByDescending(_ => _.Priority); + + builder.Entity() + .OrderBy(_ => _.SortOrder); + } +} From 07a079da8cf1599d699e9cae563912bc46d67199 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 24 Jul 2026 18:14:10 +1000 Subject: [PATCH 3/3] Document the process wide redundant ordering setting The module initializer snippet is sourced from the real one in Settings.Tests rather than a copy in Snippets.cs. A copy there would be a live ModuleInitializer in the Tests assembly and would turn the setting on for that whole project, which broke Disabled_DoesNotThrow. --- readme.md | 46 +++++++++++++++++++++++++++++++++++-------- src/Tests/Snippets.cs | 12 +++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index a06a96b..2a0095b 100644 --- a/readme.md +++ b/readme.md @@ -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 @@ -79,7 +79,7 @@ var employeesByName = await context.Employees .OrderBy(_ => _.Name) .ToListAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -104,7 +104,7 @@ var secondPage = await context.Employees var first = await context.Employees .FirstAsync(); ``` -snippet source | anchor +snippet source | anchor Without this, a page would be an arbitrary set of rows that happens to be sorted, and pages @@ -127,7 +127,7 @@ var departments = await context.Departments .Include(_ => _.Employees) .ToListAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -181,7 +181,7 @@ public class InheritanceDbContext : DbContext public DbSet DerivedEntitiesB => Set(); } ``` -snippet source | anchor +snippet source | anchor Behavior: @@ -204,7 +204,7 @@ builder.Entity() .ThenBy(_ => _.Name) .ThenByDescending(_ => _.Price); ``` -snippet source | anchor +snippet source | anchor @@ -287,7 +287,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseDefaultOrderBy( createIndexes: false); ``` -snippet source | anchor +snippet source | anchor When index creation is disabled, calling `WithIndexName()` throws an `Exception`. @@ -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: + + + +```cs +[ModuleInitializer] +public static void Initialize() => + OrderBySettings.ThrowOnRedundantOrderBy = true; +``` +snippet source | anchor + + +`UseDefaultOrderBy` reads this setting only when its `throwOnRedundantOrderBy` parameter is left unset. Passing a value always wins, so an individual context can opt out: + + + +```cs +// Passing false explicitly overrides OrderBySettings.ThrowOnRedundantOrderBy +protected override void OnConfiguring(DbContextOptionsBuilder builder) => + builder.UseDefaultOrderBy( + throwOnRedundantOrderBy: false); +``` +snippet source | anchor + + +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`: @@ -454,7 +484,7 @@ public class AppDbContext : DbContext public DbSet Employees => Set(); } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/Tests/Snippets.cs b/src/Tests/Snippets.cs index 149aafd..355d294 100644 --- a/src/Tests/Snippets.cs +++ b/src/Tests/Snippets.cs @@ -51,6 +51,18 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => #endregion } +public class OptOutOfThrowOnRedundantOrderByExample : DbContext +{ + #region OptOutOfThrowOnRedundantOrderBy + + // Passing false explicitly overrides OrderBySettings.ThrowOnRedundantOrderBy + protected override void OnConfiguring(DbContextOptionsBuilder builder) => + builder.UseDefaultOrderBy( + throwOnRedundantOrderBy: false); + + #endregion +} + public class DisableIndexCreationExample : DbContext { #region DisableIndexCreation