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
84 changes: 74 additions & 10 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ https://nuget.org/packages/EfOrderBy/
- **Inheritance support**: Ordering configured on a base entity type is automatically inherited by derived types (TPH)
- **Fluent configuration**: Configure default ordering using the familiar EF Core fluent API
- **Multi-column ordering**: Chain multiple ordering clauses with `ThenBy` and `ThenByDescending`
- **JSON column support**: Order by a property inside a `ToJson()` mapped column, at any nesting depth
- **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, per context or process wide
Expand Down Expand Up @@ -79,7 +80,7 @@ var employeesByName = await context.Employees
.OrderBy(_ => _.Name)
.ToListAsync();
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L101-L112' title='Snippet source file'>snippet source</a> | <a href='#snippet-QueryWithoutOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand All @@ -104,7 +105,7 @@ var secondPage = await context.Employees
var first = await context.Employees
.FirstAsync();
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L119-L133' 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 +128,7 @@ var departments = await context.Departments
.Include(_ => _.Employees)
.ToListAsync();
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L140-L148' 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 +182,7 @@ public class InheritanceDbContext : DbContext
public DbSet<DerivedEntityB> DerivedEntitiesB => Set<DerivedEntityB>();
}
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L228-L273' title='Snippet source file'>snippet source</a> | <a href='#snippet-InheritanceOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

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


## JSON Columns

Default ordering can target a property inside a column mapped with `ToJson()`. Pass the full property path, and EF Core translates it to a read of the JSON document:

<!-- snippet: JsonColumnOrdering -->
<a id='snippet-JsonColumnOrdering'></a>
```cs
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Order>()
.OwnsOne(_ => _.Metadata, _ => _.ToJson());

// Orders by a property of the JSON document rather than a column
builder.Entity<Order>()
.OrderByDescending(_ => _.Metadata.Priority)
.ThenBy(_ => _.Reference);
}
```
<sup><a href='/src/Tests/Snippets.cs#L34-L47' title='Snippet source file'>snippet source</a> | <a href='#snippet-JsonColumnOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The above produces:

```sql
ORDER BY CAST(JSON_VALUE([o].[Metadata], '$.Priority') AS int) DESC, [o].[Reference]
```

Paths of any depth are supported, so an owned type nested inside another owned type works the same way:

```cs
builder.Entity<Article>()
.OrderByDescending(_ => _.Info.Audit.Modified);

// ORDER BY CAST(JSON_VALUE([a].[Info], '$.Audit.Modified') AS datetime2) DESC
```

JSON properties can be mixed freely with ordinary columns in the same ordering chain.

Everything else behaves as it does for a column: the ordering is applied before `Skip`/`Take`/`First`, explicit ordering in a query still takes precedence, and [redundant ordering detection](#detect-redundant-ordering) recognises the path.

### Indexes for JSON properties

A JSON property is not a column of the entity's table, so there is nothing to name in an index. Automatic index creation is skipped for any ordering that reaches into JSON — the ordering itself still applies. As with [string columns](#string-column-indexes), a composite ordering is skipped whole when any one of its clauses reaches into JSON.

To index a JSON property, map a computed column over it and index that using the provider's own tooling.

### JSON collections

A collection mapped with `ToJson()` is read out of its parent's JSON document rather than joined, and EF Core rejects an ordered `Include` over one on a tracking query. Applying default ordering to such a collection would break queries that work today, so JSON collections are left in document order:

```cs
builder.Entity<Product>()
.OwnsMany(_ => _.Tags, _ => _.ToJson());

// Tags come back in the order they are stored in the JSON array
var products = await context.Products
.Include(_ => _.Tags)
.ToListAsync();
```

To control the order of a JSON collection, store it ordered, or sort it after materialization.


## Automatic Index Creation

When configuring default ordering, a database index is automatically created for the ordering columns. This improves query performance since the database can use the index when sorting.
Expand Down Expand Up @@ -287,7 +351,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
createIndexes: false);
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L86-L92' 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 All @@ -304,7 +368,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) =>
builder.UseDefaultOrderBy(
requireOrderingForAllEntities: true);
```
<sup><a href='/src/Tests/Snippets.cs#L34-L40' title='Snippet source file'>snippet source</a> | <a href='#snippet-RequireOrdering' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Tests/Snippets.cs#L52-L58' title='Snippet source file'>snippet source</a> | <a href='#snippet-RequireOrdering' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

This throws an exception during the first query if any entity type lacks default ordering configuration:
Expand All @@ -331,7 +395,7 @@ 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>
<sup><a href='/src/Tests/Snippets.cs#L63-L69' title='Snippet source file'>snippet source</a> | <a href='#snippet-ThrowOnRedundantOrderBy' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Given this configuration:
Expand Down Expand Up @@ -412,7 +476,7 @@ 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>
<sup><a href='/src/Tests/Snippets.cs#L74-L81' 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.
Expand Down Expand Up @@ -484,7 +548,7 @@ public class AppDbContext : DbContext
public DbSet<Employee> Employees => Set<Employee>();
}
```
<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>
<sup><a href='/src/Tests/Snippets.cs#L164-L207' title='Snippet source file'>snippet source</a> | <a href='#snippet-CompleteExample' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->


Expand Down
56 changes: 45 additions & 11 deletions src/EfOrderBy/Configuration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ internal static void Cache(Type entityType, Configuration configuration)

/// <summary>
/// Property names in order, used for creating composite indexes.
/// Only meaningful when <see cref="HasNestedPath" /> is false.
/// </summary>
internal List<string> PropertyNames { get; } = [];

/// <summary>
/// Whether any clause orders by a property reached through an owned type, for example a
/// property of a JSON mapped column. Those are not columns of the entity's own table, so
/// they cannot be named in an index.
/// </summary>
internal bool HasNestedPath { get; private set; }

internal string? CustomIndexName { get; set; }

/// <summary>
Expand All @@ -48,11 +56,17 @@ internal static void Cache(Type entityType, Configuration configuration)
/// </summary>
internal List<ClauseMetadata> ClauseMetadataList { get; } = [];

internal void AddClause(PropertyInfo propertyInfo, bool descending, bool isThenBy)
internal void AddClause(PropertyInfo[] path, bool descending, bool isThenBy)
{
Clauses.Add(new(elementType, parameter, propertyInfo, descending, isThenBy));
PropertyNames.Add(propertyInfo.Name);
ClauseMetadataList.Add(new(propertyInfo.Name, descending, isThenBy));
Clauses.Add(new(elementType, parameter, path, descending, isThenBy));

if (path.Length > 1)
{
HasNestedPath = true;
}

PropertyNames.Add(path[0].Name);
ClauseMetadataList.Add(new(PropertyPath.Describe(path), descending, isThenBy));
}

/// <summary>
Expand All @@ -71,18 +85,38 @@ internal Configuration CreateForDerivedType(Type derivedType)
var derived = new Configuration(derivedType) { IsInherited = true };
foreach (var meta in ClauseMetadataList)
{
var property = derivedType.GetProperty(meta.PropertyName, propertyFlags);
if (property != null)
derived.AddClause(ResolvePath(derivedType, meta.PropertyPath), meta.Descending, meta.IsThenBy);
}

return derived;
}

// Only the first segment is declared by the entity, so only it changes on a derived type.
// The rest hang off the owned types the path reaches through and resolve the same way.
PropertyInfo[] ResolvePath(Type derivedType, string path)
{
var names = path.Split('.');
var resolved = new PropertyInfo[names.Length];
var declaring = derivedType;

for (var index = 0; index < names.Length; index++)
{
var property = declaring.GetProperty(names[index], propertyFlags);
if (property == null)
{
derived.AddClause(property, meta.Descending, meta.IsThenBy);
continue;
throw new($"Property '{path}' not found on derived type '{derivedType.Name}'. Cannot inherit ordering from base type '{elementType.Name}'.");
}

throw new($"Property '{meta.PropertyName}' not found on derived type '{derivedType.Name}'. Cannot inherit ordering from base type '{elementType.Name}'.");
resolved[index] = property;
declaring = property.PropertyType;
}

return derived;
return resolved;
}

internal readonly record struct ClauseMetadata(string PropertyName, bool Descending, bool IsThenBy);
/// <summary>
/// A clause as configured, with <paramref name="PropertyPath" /> dotted when the ordering
/// reaches through an owned type, for example "Metadata.Rank" for a JSON mapped column.
/// </summary>
internal readonly record struct ClauseMetadata(string PropertyPath, bool Descending, bool IsThenBy);
}
4 changes: 4 additions & 0 deletions src/EfOrderBy/Conventions/FinalizingConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConven
continue;
}

// A nested path orders by a property of an owned type, for example a property of a
// JSON column. That is not a column of this entity's table, so there is nothing to
// name in an index. The ordering itself still applies, the same as the skip below.
if (createIndexes &&
!config.IsInherited &&
!config.HasNestedPath &&
!HasLargeStringProperty(entity, config, maxIndexableStringLength))
{
var index = config.CustomIndexName ?? $"IX_{entity.ClrType.Name}_DefaultOrder";
Expand Down
8 changes: 8 additions & 0 deletions src/EfOrderBy/IncludeOrderingApplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ static Expression ApplyOrdering(Expression source, Configuration configuration)
return null;
}

// A JSON mapped collection is read out of its parent's JSON document rather than joined,
// and EF Core rejects an ordered Include over one on a tracking query. Ordering it would
// break queries that work today, so the document order is left as it is.
if (entity.IsMappedToJson())
{
return null;
}

return Configuration.TryGet(element);
}
}
23 changes: 4 additions & 19 deletions src/EfOrderBy/OrderByBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ public sealed class OrderByBuilder<TEntity>
Configuration configuration;
IMutableModel model;

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

builder.Metadata.SetOrderByConfiguration(configuration);
}
Expand All @@ -25,8 +25,7 @@ internal OrderByBuilder(EntityTypeBuilder<TEntity> builder, PropertyInfo propert
/// </summary>
public OrderByBuilder<TEntity> ThenBy<TProperty>(Expression<Func<TEntity, TProperty>> property)
{
var propertyInfo = GetPropertyInfo(property);
configuration.AddClause(propertyInfo, descending: false, isThenBy: true);
configuration.AddClause(PropertyPath.Resolve(property), descending: false, isThenBy: true);
return this;
}

Expand All @@ -35,8 +34,7 @@ public OrderByBuilder<TEntity> ThenBy<TProperty>(Expression<Func<TEntity, TPrope
/// </summary>
public OrderByBuilder<TEntity> ThenByDescending<TProperty>(Expression<Func<TEntity, TProperty>> property)
{
var propertyInfo = GetPropertyInfo(property);
configuration.AddClause(propertyInfo, descending: true, isThenBy: true);
configuration.AddClause(PropertyPath.Resolve(property), descending: true, isThenBy: true);
return this;
}

Expand Down Expand Up @@ -64,17 +62,4 @@ public OrderByBuilder<TEntity> WithIndexName(string indexName)
configuration.CustomIndexName = indexName;
return this;
}

static PropertyInfo GetPropertyInfo<TProperty>(Expression<Func<TEntity, TProperty>> property)
{
if (property.Body is MemberExpression
{
Member: PropertyInfo propertyInfo
})
{
return propertyInfo;
}

throw new ArgumentException("Expression must be a property access expression", nameof(property));
}
}
17 changes: 12 additions & 5 deletions src/EfOrderBy/OrderByClause.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
sealed record OrderByClause
{
internal OrderByClause(Type elementType, ParameterExpression parameter, PropertyInfo propertyInfo, bool descending, bool isThenBy)
internal OrderByClause(Type elementType, ParameterExpression parameter, PropertyInfo[] path, bool descending, bool isThenBy)
{
// Pre-build the property access and lambda expression
var property = Expression.Property(parameter, propertyInfo);
// Pre-build the property access and lambda expression. The path has more than one entry
// when the ordering reaches through an owned type, for example into a JSON column.
Expression property = parameter;
foreach (var segment in path)
{
property = Expression.Property(property, segment);
}

lambda = Expression.Lambda(property, parameter);

MethodInfo genericQueryableMethod;
Expand All @@ -21,8 +27,9 @@ internal OrderByClause(Type elementType, ParameterExpression parameter, Property
}

// Pre-compute the fully generic methods (e.g., OrderBy<ParentEntity, string>)
queryableMethod = genericQueryableMethod.MakeGenericMethod(elementType, propertyInfo.PropertyType);
enumerableMethod = genericEnumerableMethod.MakeGenericMethod(elementType, propertyInfo.PropertyType);
var keyType = path[^1].PropertyType;
queryableMethod = genericQueryableMethod.MakeGenericMethod(elementType, keyType);
enumerableMethod = genericEnumerableMethod.MakeGenericMethod(elementType, keyType);
quotedLambda = Expression.Quote(lambda);
}

Expand Down
16 changes: 2 additions & 14 deletions src/EfOrderBy/OrderByExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ public static OrderByBuilder<TEntity> OrderBy<TEntity, TProperty>(
{
ThrowIfInterceptorNotRegistered(builder);
ThrowIfOrderingAlreadyConfigured(builder);
var propertyInfo = GetPropertyInfo(property);
return new(builder, propertyInfo, descending: false);
return new(builder, PropertyPath.Resolve(property), descending: false);
}

/// <summary>
Expand All @@ -86,8 +85,7 @@ public static OrderByBuilder<TEntity> OrderByDescending<TEntity, TProperty>(
{
ThrowIfInterceptorNotRegistered(builder);
ThrowIfOrderingAlreadyConfigured(builder);
var propertyInfo = GetPropertyInfo(property);
return new(builder, propertyInfo, descending: true);
return new(builder, PropertyPath.Resolve(property), descending: true);
}

// Entity-level annotation helpers
Expand Down Expand Up @@ -129,16 +127,6 @@ internal static void MarkInterceptorRegistered(this IConventionModelBuilder buil
internal static void MarkIndexCreationDisabled(this IConventionModelBuilder builder) =>
builder.HasAnnotation(indexCreationDisabledAnnotation, true);

static PropertyInfo GetPropertyInfo<TEntity, TProperty>(Expression<Func<TEntity, TProperty>> property)
{
if (property.Body is MemberExpression { Member: PropertyInfo propertyInfo })
{
return propertyInfo;
}

throw new ArgumentException("Expression must be a property access expression", nameof(property));
}

static void ThrowIfInterceptorNotRegistered<TEntity>(EntityTypeBuilder<TEntity> builder)
where TEntity : class
{
Expand Down
Loading
Loading