From ed2d2077b5cc2b1b42fbaf87b7aca2c3b6e7344f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 1 Aug 2026 10:30:28 +1000 Subject: [PATCH] Add JSON column support for default ordering OrderBy/ThenBy now accept a property path, so a property of a ToJson() mapped column can be used for default ordering. EF Core translates the path to a read of the JSON document: builder.Entity() .OrderByDescending(_ => _.Metadata.Priority); ORDER BY CAST(JSON_VALUE([o].[Metadata], '$.Priority') AS int) DESC Paths of any depth work, and JSON properties mix with ordinary columns in the same chain. Cross context conflict detection, inherited ordering, and redundant ordering detection all understand the dotted path. Index creation is skipped for an ordering that reaches into JSON, since a JSON property is not a column of the entity's table. This follows the existing string column behaviour: the index is skipped, the ordering still applies. JSON mapped collections are left in document order. EF Core throws on an ordered Include over one in a tracking query, so ordering them would break queries that work today. --- readme.md | 84 +++- src/EfOrderBy/Configuration.cs | 56 ++- .../Conventions/FinalizingConvention.cs | 4 + src/EfOrderBy/IncludeOrderingApplicator.cs | 8 + src/EfOrderBy/OrderByBuilder.cs | 23 +- src/EfOrderBy/OrderByClause.cs | 17 +- src/EfOrderBy/OrderByExtensions.cs | 16 +- src/EfOrderBy/PropertyPath.cs | 50 +++ src/EfOrderBy/RedundantOrder.cs | 26 +- ...nProperty_AppliesDefaultOrder.verified.txt | 49 +++ ...roperties_AppliesDefaultOrder.verified.txt | 39 ++ ...nProperty_AppliesDefaultOrder.verified.txt | 40 ++ src/Tests/JsonColumnTests.cs | 410 ++++++++++++++++++ src/Tests/Snippets.cs | 30 ++ 14 files changed, 779 insertions(+), 73 deletions(-) create mode 100644 src/EfOrderBy/PropertyPath.cs create mode 100644 src/Tests/JsonColumnTests.JsonProperty_AppliesDefaultOrder.verified.txt create mode 100644 src/Tests/JsonColumnTests.MixedColumnAndJsonProperties_AppliesDefaultOrder.verified.txt create mode 100644 src/Tests/JsonColumnTests.NestedJsonProperty_AppliesDefaultOrder.verified.txt create mode 100644 src/Tests/JsonColumnTests.cs diff --git a/readme.md b/readme.md index 08e638f..f308b7d 100644 --- a/readme.md +++ b/readme.md @@ -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 @@ -79,7 +80,7 @@ var employeesByName = await context.Employees .OrderBy(_ => _.Name) .ToListAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -104,7 +105,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 +128,7 @@ var departments = await context.Departments .Include(_ => _.Employees) .ToListAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -181,7 +182,7 @@ public class InheritanceDbContext : DbContext public DbSet DerivedEntitiesB => Set(); } ``` -snippet source | anchor +snippet source | anchor Behavior: @@ -204,10 +205,73 @@ builder.Entity() .ThenBy(_ => _.Name) .ThenByDescending(_ => _.Price); ``` -snippet source | anchor +snippet source | anchor +## 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: + + + +```cs +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity() + .OwnsOne(_ => _.Metadata, _ => _.ToJson()); + + // Orders by a property of the JSON document rather than a column + builder.Entity() + .OrderByDescending(_ => _.Metadata.Priority) + .ThenBy(_ => _.Reference); +} +``` +snippet source | anchor + + +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
() + .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() + .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. @@ -287,7 +351,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`. @@ -304,7 +368,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseDefaultOrderBy( requireOrderingForAllEntities: true); ``` -snippet source | anchor +snippet source | anchor This throws an exception during the first query if any entity type lacks default ordering configuration: @@ -331,7 +395,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseDefaultOrderBy( throwOnRedundantOrderBy: true); ``` -snippet source | anchor +snippet source | anchor Given this configuration: @@ -412,7 +476,7 @@ protected override void OnConfiguring(DbContextOptionsBuilder builder) => builder.UseDefaultOrderBy( throwOnRedundantOrderBy: false); ``` -snippet source | anchor +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. @@ -484,7 +548,7 @@ public class AppDbContext : DbContext public DbSet Employees => Set(); } ``` -snippet source | anchor +snippet source | anchor diff --git a/src/EfOrderBy/Configuration.cs b/src/EfOrderBy/Configuration.cs index b15c299..01b2359 100644 --- a/src/EfOrderBy/Configuration.cs +++ b/src/EfOrderBy/Configuration.cs @@ -32,9 +32,17 @@ internal static void Cache(Type entityType, Configuration configuration) /// /// Property names in order, used for creating composite indexes. + /// Only meaningful when is false. /// internal List PropertyNames { get; } = []; + /// + /// 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. + /// + internal bool HasNestedPath { get; private set; } + internal string? CustomIndexName { get; set; } /// @@ -48,11 +56,17 @@ internal static void Cache(Type entityType, Configuration configuration) /// internal List 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)); } /// @@ -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); + /// + /// A clause as configured, with dotted when the ordering + /// reaches through an owned type, for example "Metadata.Rank" for a JSON mapped column. + /// + internal readonly record struct ClauseMetadata(string PropertyPath, bool Descending, bool IsThenBy); } diff --git a/src/EfOrderBy/Conventions/FinalizingConvention.cs b/src/EfOrderBy/Conventions/FinalizingConvention.cs index 793a777..9d15141 100644 --- a/src/EfOrderBy/Conventions/FinalizingConvention.cs +++ b/src/EfOrderBy/Conventions/FinalizingConvention.cs @@ -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"; diff --git a/src/EfOrderBy/IncludeOrderingApplicator.cs b/src/EfOrderBy/IncludeOrderingApplicator.cs index 26819e0..31e9c26 100644 --- a/src/EfOrderBy/IncludeOrderingApplicator.cs +++ b/src/EfOrderBy/IncludeOrderingApplicator.cs @@ -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); } } diff --git a/src/EfOrderBy/OrderByBuilder.cs b/src/EfOrderBy/OrderByBuilder.cs index 4d4ad00..4400fb8 100644 --- a/src/EfOrderBy/OrderByBuilder.cs +++ b/src/EfOrderBy/OrderByBuilder.cs @@ -11,11 +11,11 @@ public sealed class OrderByBuilder Configuration configuration; IMutableModel model; - internal OrderByBuilder(EntityTypeBuilder builder, PropertyInfo propertyInfo, bool descending) + internal OrderByBuilder(EntityTypeBuilder 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); } @@ -25,8 +25,7 @@ internal OrderByBuilder(EntityTypeBuilder builder, PropertyInfo propert /// public OrderByBuilder ThenBy(Expression> property) { - var propertyInfo = GetPropertyInfo(property); - configuration.AddClause(propertyInfo, descending: false, isThenBy: true); + configuration.AddClause(PropertyPath.Resolve(property), descending: false, isThenBy: true); return this; } @@ -35,8 +34,7 @@ public OrderByBuilder ThenBy(Expression public OrderByBuilder ThenByDescending(Expression> property) { - var propertyInfo = GetPropertyInfo(property); - configuration.AddClause(propertyInfo, descending: true, isThenBy: true); + configuration.AddClause(PropertyPath.Resolve(property), descending: true, isThenBy: true); return this; } @@ -64,17 +62,4 @@ public OrderByBuilder WithIndexName(string indexName) configuration.CustomIndexName = indexName; return this; } - - static PropertyInfo GetPropertyInfo(Expression> property) - { - if (property.Body is MemberExpression - { - Member: PropertyInfo propertyInfo - }) - { - return propertyInfo; - } - - throw new ArgumentException("Expression must be a property access expression", nameof(property)); - } } diff --git a/src/EfOrderBy/OrderByClause.cs b/src/EfOrderBy/OrderByClause.cs index 52c47be..1ff0ca8 100644 --- a/src/EfOrderBy/OrderByClause.cs +++ b/src/EfOrderBy/OrderByClause.cs @@ -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; @@ -21,8 +27,9 @@ internal OrderByClause(Type elementType, ParameterExpression parameter, Property } // Pre-compute the fully generic methods (e.g., OrderBy) - 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); } diff --git a/src/EfOrderBy/OrderByExtensions.cs b/src/EfOrderBy/OrderByExtensions.cs index fee4bfd..c9e013b 100644 --- a/src/EfOrderBy/OrderByExtensions.cs +++ b/src/EfOrderBy/OrderByExtensions.cs @@ -63,8 +63,7 @@ public static OrderByBuilder OrderBy( { ThrowIfInterceptorNotRegistered(builder); ThrowIfOrderingAlreadyConfigured(builder); - var propertyInfo = GetPropertyInfo(property); - return new(builder, propertyInfo, descending: false); + return new(builder, PropertyPath.Resolve(property), descending: false); } /// @@ -86,8 +85,7 @@ public static OrderByBuilder OrderByDescending( { 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 @@ -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(Expression> 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(EntityTypeBuilder builder) where TEntity : class { diff --git a/src/EfOrderBy/PropertyPath.cs b/src/EfOrderBy/PropertyPath.cs new file mode 100644 index 0000000..7431b17 --- /dev/null +++ b/src/EfOrderBy/PropertyPath.cs @@ -0,0 +1,50 @@ +/// +/// Resolves the chain of properties a key selector reads. +/// +/// +/// A chain of more than one property reaches through an owned type, which is how a property of a +/// JSON mapped column is addressed, for example _ => _.Metadata.Rank. EF Core translates +/// those to a read of the JSON document, so the whole chain is kept rather than only its last +/// property. +/// +static class PropertyPath +{ + public static PropertyInfo[] Resolve(Expression> property) + { + if (TryResolve(property.Body) is { } path) + { + return path; + } + + throw new ArgumentException("Expression must be a property access expression", nameof(property)); + } + + /// + /// Returns the properties of a member chain rooted at the lambda parameter, outermost last, + /// or null when the expression is not such a chain. + /// + public static PropertyInfo[]? TryResolve(Expression? expression) + { + var segments = new List(); + + while (expression is MemberExpression {Member: PropertyInfo property} member) + { + segments.Add(property); + expression = member.Expression; + } + + // A chain that does not bottom out at the parameter, for example one rooted at a captured + // variable, a constant, or a method call, cannot be expressed as ordering configuration + if (segments.Count == 0 || + expression is not ParameterExpression) + { + return null; + } + + segments.Reverse(); + return [..segments]; + } + + public static string Describe(IEnumerable path) => + string.Join('.', path.Select(_ => _.Name)); +} diff --git a/src/EfOrderBy/RedundantOrder.cs b/src/EfOrderBy/RedundantOrder.cs index b04ad4a..77d4f47 100644 --- a/src/EfOrderBy/RedundantOrder.cs +++ b/src/EfOrderBy/RedundantOrder.cs @@ -50,12 +50,12 @@ public static void Validate(Expression expression) return null; } - if (FindPropertyName(call.Arguments[1]) is not { } propertyName) + if (FindPropertyPath(call.Arguments[1]) is not { } propertyPath) { return null; } - clauses.Add(new(propertyName, descending, isThenBy)); + clauses.Add(new(propertyPath, descending, isThenBy)); elementType = method.GetGenericArguments()[0]; expression = call.Arguments[0]; continue; @@ -154,7 +154,7 @@ static bool IsOrderingMethod(MethodInfo method, out bool descending, out bool is } // Queryable methods take a quoted lambda, Enumerable methods take the lambda directly - static string? FindPropertyName(Expression keySelector) + static string? FindPropertyPath(Expression keySelector) { if (keySelector is UnaryExpression { @@ -165,23 +165,21 @@ static bool IsOrderingMethod(MethodInfo method, out bool descending, out bool is keySelector = quoted; } - if (keySelector is LambdaExpression - { - Body: MemberExpression - { - Expression: ParameterExpression, - Member: PropertyInfo property - } - }) + if (keySelector is not LambdaExpression lambda) + { + return null; + } + + if (PropertyPath.TryResolve(lambda.Body) is not { } path) { - return property.Name; + return null; } - return null; + return PropertyPath.Describe(path); } static string Describe(List clauses) => - string.Join('.', clauses.Select(_ => $"{MethodName(_)}({_.PropertyName})")); + string.Join('.', clauses.Select(_ => $"{MethodName(_)}({_.PropertyPath})")); static string MethodName(Configuration.ClauseMetadata clause) => (clause.IsThenBy, clause.Descending) switch diff --git a/src/Tests/JsonColumnTests.JsonProperty_AppliesDefaultOrder.verified.txt b/src/Tests/JsonColumnTests.JsonProperty_AppliesDefaultOrder.verified.txt new file mode 100644 index 0000000..69f28d2 --- /dev/null +++ b/src/Tests/JsonColumnTests.JsonProperty_AppliesDefaultOrder.verified.txt @@ -0,0 +1,49 @@ +{ + target: [ + { + Id: 2, + Name: Gadget, + Metadata: { + Rank: 1, + Label: + } + }, + { + Id: 3, + Name: Doohickey, + Metadata: { + Rank: 2, + Label: + } + }, + { + Id: 1, + Name: Widget, + Metadata: { + Rank: 3, + Label: + }, + Tags: [ + { + Value: gamma + }, + { + Value: alpha + }, + { + Value: beta + } + ] + } + ], + sql: { + Text: +select p.Id, + p.Name, + p.Metadata, + p.Tags +from Products as p +order by cast (JSON_VALUE(p.Metadata, '$.Rank') as int), + HasTransaction: false + } +} \ No newline at end of file diff --git a/src/Tests/JsonColumnTests.MixedColumnAndJsonProperties_AppliesDefaultOrder.verified.txt b/src/Tests/JsonColumnTests.MixedColumnAndJsonProperties_AppliesDefaultOrder.verified.txt new file mode 100644 index 0000000..b0c01c3 --- /dev/null +++ b/src/Tests/JsonColumnTests.MixedColumnAndJsonProperties_AppliesDefaultOrder.verified.txt @@ -0,0 +1,39 @@ +{ + target: [ + { + Id: 2, + Name: A-heavy, + Category: A, + Details: { + Weight: 9 + } + }, + { + Id: 3, + Name: A-light, + Category: A, + Details: { + Weight: 2 + } + }, + { + Id: 1, + Name: B-light, + Category: B, + Details: { + Weight: 1 + } + } + ], + sql: { + Text: +select i.Id, + i.Category, + i.Name, + i.Details +from Items as i +order by i.Category, + cast (JSON_VALUE(i.Details, '$.Weight') as int) desc, + HasTransaction: false + } +} \ No newline at end of file diff --git a/src/Tests/JsonColumnTests.NestedJsonProperty_AppliesDefaultOrder.verified.txt b/src/Tests/JsonColumnTests.NestedJsonProperty_AppliesDefaultOrder.verified.txt new file mode 100644 index 0000000..048722e --- /dev/null +++ b/src/Tests/JsonColumnTests.NestedJsonProperty_AppliesDefaultOrder.verified.txt @@ -0,0 +1,40 @@ +{ + target: [ + { + Id: 2, + Title: Newest, + Info: { + Audit: { + Modified: 2025-01-01 + } + } + }, + { + Id: 3, + Title: Middle, + Info: { + Audit: { + Modified: 2024-01-01 + } + } + }, + { + Id: 1, + Title: Oldest, + Info: { + Audit: { + Modified: 2023-01-01 + } + } + } + ], + sql: { + Text: +select a.Id, + a.Title, + a.Info +from Articles as a +order by cast (JSON_VALUE(a.Info, '$.Audit.Modified') as datetime2) desc, + HasTransaction: false + } +} \ No newline at end of file diff --git a/src/Tests/JsonColumnTests.cs b/src/Tests/JsonColumnTests.cs new file mode 100644 index 0000000..0a8a40b --- /dev/null +++ b/src/Tests/JsonColumnTests.cs @@ -0,0 +1,410 @@ +// Ordering that reaches into a JSON mapped column. EF Core translates the property path to a +// read of the JSON document, so the default ordering applies the same way it does to a column. +[TestFixture] +public class JsonColumnTests +{ + static readonly SqlInstance sqlInstance = new( + constructInstance: builder => + { + builder.UseDefaultOrderBy(); + return new(builder.Options); + }, + buildTemplate: async context => + { + await context.Database.EnsureCreatedAsync(); + + context.Products.AddRange( + new() + { + Name = "Widget", + Metadata = new() + { + Rank = 3 + }, + Tags = + [ + new() { Value = "gamma" }, + new() { Value = "alpha" }, + new() { Value = "beta" } + ] + }, + new() + { + Name = "Gadget", + Metadata = new() + { + Rank = 1 + } + }, + new() + { + Name = "Doohickey", + Metadata = new() + { + Rank = 2 + } + }); + + context.Articles.AddRange( + new() + { + Title = "Oldest", + Info = new() + { + Audit = new() + { + Modified = new(2023, 1, 1) + } + } + }, + new() + { + Title = "Newest", + Info = new() + { + Audit = new() + { + Modified = new(2025, 1, 1) + } + } + }, + new() + { + Title = "Middle", + Info = new() + { + Audit = new() + { + Modified = new(2024, 1, 1) + } + } + }); + + context.Items.AddRange( + new() + { + Name = "B-light", + Category = "B", + Details = new() + { + Weight = 1 + } + }, + new() + { + Name = "A-heavy", + Category = "A", + Details = new() + { + Weight = 9 + } + }, + new() + { + Name = "A-light", + Category = "A", + Details = new() + { + Weight = 2 + } + }); + + await context.SaveChangesAsync(); + }); + + [Test] + public async Task JsonProperty_AppliesDefaultOrder() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + Recording.Start(); + var results = await context.Products.ToListAsync(); + + Assert.That(results.Select(_ => _.Name), Is.EqualTo(["Gadget", "Doohickey", "Widget"])); + await Verify(results); + } + + [Test] + public async Task NestedJsonProperty_AppliesDefaultOrder() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + Recording.Start(); + var results = await context.Articles.ToListAsync(); + + // Info.Audit.Modified descending, two owned types deep + Assert.That(results.Select(_ => _.Title), Is.EqualTo(["Newest", "Middle", "Oldest"])); + await Verify(results); + } + + [Test] + public async Task MixedColumnAndJsonProperties_AppliesDefaultOrder() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + Recording.Start(); + var results = await context.Items.ToListAsync(); + + // Category ascending, then Details.Weight descending + Assert.That(results.Select(_ => _.Name), Is.EqualTo(["A-heavy", "A-light", "B-light"])); + await Verify(results); + } + + [Test] + public async Task ExplicitOrdering_SuppressesJsonDefault() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + var results = await context.Products + .OrderBy(_ => _.Name) + .ToListAsync(); + + Assert.That(results.Select(_ => _.Name), Is.EqualTo(["Doohickey", "Gadget", "Widget"])); + } + + [Test] + public async Task JsonProperty_AppliesBeneathTake() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + // The default ordering has to be applied before Take, otherwise an arbitrary row is taken + var results = await context.Products + .Take(1) + .ToListAsync(); + + Assert.That(results.Single().Name, Is.EqualTo("Gadget")); + } + + // A JSON mapped collection is read out of its parent's JSON document. EF Core throws on an + // ordered Include over one in a tracking query, so it has to be left in document order. + [Test] + public async Task JsonCollection_KeepsDocumentOrder() + { + await using var database = await sqlInstance.Build(); + await using var context = database.NewDbContext(); + + var results = await context.Products + .Include(_ => _.Tags) + .ToListAsync(); + + var widget = results.Single(_ => _.Name == "Widget"); + Assert.That(widget.Tags.Select(_ => _.Value), Is.EqualTo(["gamma", "alpha", "beta"])); + } + + [Test] + public void JsonPath_SkipsIndexCreation() + { + using var context = NewModelOnlyContext(); + + // A JSON property is not a column of the entity's table, so there is nothing to index + var product = context.Model.FindEntityType(typeof(JsonProduct))!; + Assert.That(product.GetIndexes(), Is.Empty); + + var article = context.Model.FindEntityType(typeof(JsonArticle))!; + Assert.That(article.GetIndexes(), Is.Empty); + + // A composite ordering is skipped whole when any one of its clauses reaches into JSON + var item = context.Model.FindEntityType(typeof(JsonItem))!; + Assert.That(item.GetIndexes(), Is.Empty); + } + + // Validation runs on the first query rather than when the model is built + [Test] + public void JsonOwnedTypes_DoNotRequireOrdering() + { + var builder = new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;"); + builder.UseDefaultOrderBy(requireOrderingForAllEntities: true); + + using var context = new JsonRequiredContext(builder.Options); + + // The owned types behind a JSON column are not separately queryable, + // so ordering must not be demanded for them + Assert.DoesNotThrow(() => context.Entities.ToQueryString()); + } + + [Test] + public void RedundantJsonOrderBy_Throws() + { + using var context = new JsonRedundantContext(redundantOptions); + + var exception = Assert.Throws( + () => context.Entities + .OrderBy(_ => _.Meta.Rank) + .ToQueryString())!; + + Assert.That(exception.Message, Does.Contain("JsonRedundantEntity")); + Assert.That(exception.Message, Does.Contain("OrderBy(Meta.Rank)")); + } + + [Test] + public void DifferentJsonOrderBy_DoesNotThrow() + { + using var context = new JsonRedundantContext(redundantOptions); + + // A different property of the same JSON column is not the configured ordering + Assert.DoesNotThrow( + () => context.Entities + .OrderBy(_ => _.Meta.Label) + .ToQueryString()); + } + + static readonly DbContextOptions redundantOptions = BuildRedundantOptions(); + + static DbContextOptions BuildRedundantOptions() + { + var builder = new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;"); + builder.UseDefaultOrderBy(throwOnRedundantOrderBy: true); + return builder.Options; + } + + static JsonDbContext NewModelOnlyContext() + { + var builder = new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=Test;"); + builder.UseDefaultOrderBy(); + return new(builder.Options); + } +} + +public class JsonProduct +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public JsonProductMetadata Metadata { get; set; } = new(); + public List Tags { get; set; } = []; +} + +public class JsonProductMetadata +{ + public int Rank { get; set; } + public string Label { get; set; } = ""; +} + +public class JsonProductTag +{ + public string Value { get; set; } = ""; +} + +public class JsonArticle +{ + public int Id { get; set; } + public string Title { get; set; } = ""; + public JsonArticleInfo Info { get; set; } = new(); +} + +public class JsonArticleInfo +{ + public JsonArticleAudit Audit { get; set; } = new(); +} + +public class JsonArticleAudit +{ + public DateTime Modified { get; set; } +} + +public class JsonItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public JsonItemDetails Details { get; set; } = new(); +} + +public class JsonItemDetails +{ + public int Weight { get; set; } +} + +public class JsonRedundantEntity +{ + public int Id { get; set; } + public JsonRedundantMeta Meta { get; set; } = new(); +} + +public class JsonRedundantMeta +{ + public int Rank { get; set; } + public string Label { get; set; } = ""; +} + +public class JsonRequiredEntity +{ + public int Id { get; set; } + public JsonRequiredMeta Meta { get; set; } = new(); +} + +public class JsonRequiredMeta +{ + public int Rank { get; set; } +} + +class JsonRedundantContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + base.OnModelCreating(model); + + var entity = model.Entity(); + entity.OwnsOne(_ => _.Meta, _ => _.ToJson()); + entity.OrderBy(_ => _.Meta.Rank); + } +} + +class JsonRequiredContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Entities => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + base.OnModelCreating(model); + + var entity = model.Entity(); + entity.OwnsOne(_ => _.Meta, _ => _.ToJson()); + entity.OrderBy(_ => _.Meta.Rank); + } +} + +public class JsonDbContext(DbContextOptions options) : + DbContext(options) +{ + public DbSet Products => Set(); + public DbSet Articles => Set(); + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder model) + { + base.OnModelCreating(model); + + var product = model.Entity(); + product.OwnsOne(_ => _.Metadata, _ => _.ToJson()); + product.OwnsMany(_ => _.Tags, _ => _.ToJson()); + product.OrderBy(_ => _.Metadata.Rank); + + var article = model.Entity(); + article.OwnsOne( + _ => _.Info, + owned => + { + owned.ToJson(); + owned.OwnsOne(_ => _.Audit); + }); + article.OrderByDescending(_ => _.Info.Audit.Modified); + + var item = model.Entity(); + item.OwnsOne(_ => _.Details, _ => _.ToJson()); + item.Property(_ => _.Category).HasMaxLength(450); + item.OrderBy(_ => _.Category) + .ThenByDescending(_ => _.Details.Weight); + } +} diff --git a/src/Tests/Snippets.cs b/src/Tests/Snippets.cs index 355d294..18aee2d 100644 --- a/src/Tests/Snippets.cs +++ b/src/Tests/Snippets.cs @@ -29,6 +29,24 @@ protected override void OnModelCreating(ModelBuilder builder) #endregion } +public class JsonColumnExample : DbContext +{ + #region JsonColumnOrdering + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .OwnsOne(_ => _.Metadata, _ => _.ToJson()); + + // Orders by a property of the JSON document rather than a column + builder.Entity() + .OrderByDescending(_ => _.Metadata.Priority) + .ThenBy(_ => _.Reference); + } + + #endregion +} + public class RequireOrderingExample : DbContext { #region RequireOrdering @@ -195,6 +213,18 @@ class Product public decimal Price { get; set; } } +public class Order +{ + public int Id { get; set; } + public string Reference { get; set; } = ""; + public OrderMetadata Metadata { get; set; } = new(); +} + +public class OrderMetadata +{ + public int Priority { get; set; } +} + #region InheritanceOrdering public class BaseEntity