Skip to content

Commit 8b34408

Browse files
pdevito3claude
andcommitted
fix: add support for sorting by derived properties
The SortParser was missing support for derived properties, causing Entity Framework translation errors when sorting by derived properties containing navigation property expressions like: x.Patient != null ? x.Patient.FirstName + " " + x.Patient.LastName : null This would result in EF Core trying to sort by the navigation property object itself instead of the computed derived expression, generating errors like: "The LINQ expression... .OrderBy(a => (object)a.Inner)' could not be translated" - Add derived property support to SortParser.CreateSortExpressionBody - Check for derived properties before falling back to regular property handling - Use ParameterReplacer to properly substitute expression parameters - Add comprehensive integration tests for sorting scenarios - Ensure both filtering and sorting work with complex derived property expressions Fixes Entity Framework translation errors when sorting by derived properties that contain navigation property null checks and concatenations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5f3f32e commit 8b34408

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3619,4 +3619,105 @@ public async Task can_filter_with_derived_property_containing_multiple_not_equal
36193619
people.Count.Should().Be(1);
36203620
people[0].Id.Should().Be(validPerson.Id);
36213621
}
3622+
3623+
[Fact]
3624+
public async Task can_filter_with_derived_property_using_not_equal_on_child_navigation_property()
3625+
{
3626+
// Arrange
3627+
var testingServiceScope = new TestingServiceScope();
3628+
var uniqueId = Guid.NewGuid().ToString()[..8];
3629+
3630+
// Create author
3631+
var author = new FakeAuthorBuilder()
3632+
.WithName($"John_{uniqueId}")
3633+
.Build();
3634+
3635+
// Create recipe with author (like Accession with Patient)
3636+
var recipeWithAuthor = new FakeRecipeBuilder()
3637+
.WithTitle($"RecipeWithAuthor_{uniqueId}")
3638+
.Build();
3639+
recipeWithAuthor.SetAuthor(author);
3640+
3641+
// Create recipe without author (like Accession without Patient)
3642+
var recipeWithoutAuthor = new FakeRecipeBuilder()
3643+
.WithTitle($"RecipeWithoutAuthor_{uniqueId}")
3644+
.Build();
3645+
// Author is null by default
3646+
3647+
await testingServiceScope.InsertAsync(recipeWithAuthor, recipeWithoutAuthor);
3648+
3649+
// Test derived property that uses != null on child property, exactly like your Patient example:
3650+
// x.Patient != null ? x.Patient.FirstName + " " + x.Patient.LastName : null
3651+
var input = $"""authorInfo == "John_{uniqueId}" && Title == "RecipeWithAuthor_{uniqueId}" """;
3652+
var config = new QueryKitConfiguration(config =>
3653+
{
3654+
config.DerivedProperty<Recipe>(x =>
3655+
x.Author != null
3656+
? x.Author.Name
3657+
: null)
3658+
.HasQueryName("authorInfo");
3659+
});
3660+
3661+
// Act
3662+
var queryableRecipes = testingServiceScope.DbContext().Recipes
3663+
.Include(x => x.Author);
3664+
var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input, config);
3665+
var recipes = await appliedQueryable.ToListAsync();
3666+
3667+
// Assert
3668+
recipes.Count.Should().Be(1);
3669+
recipes[0].Id.Should().Be(recipeWithAuthor.Id);
3670+
recipes[0].Author.Should().NotBeNull();
3671+
recipes[0].Author!.Name.Should().Be($"John_{uniqueId}");
3672+
}
3673+
3674+
[Fact]
3675+
public async Task can_filter_with_derived_property_using_not_equal_on_child_navigation_property_complex()
3676+
{
3677+
// Arrange
3678+
var testingServiceScope = new TestingServiceScope();
3679+
var uniqueId = Guid.NewGuid().ToString()[..8];
3680+
3681+
// Create author with complex name structure
3682+
var author = new FakeAuthorBuilder()
3683+
.WithName($"Dr. John Smith_{uniqueId}")
3684+
.Build();
3685+
3686+
// Create recipe with author
3687+
var recipeWithAuthor = new FakeRecipeBuilder()
3688+
.WithTitle($"ComplexRecipe_{uniqueId}")
3689+
.Build();
3690+
recipeWithAuthor.SetAuthor(author);
3691+
3692+
// Create recipe without author
3693+
var recipeWithoutAuthor = new FakeRecipeBuilder()
3694+
.WithTitle($"OrphanRecipe_{uniqueId}")
3695+
.Build();
3696+
3697+
await testingServiceScope.InsertAsync(recipeWithAuthor, recipeWithoutAuthor);
3698+
3699+
// Test complex derived property like your Patient example with FirstName + LastName concatenation
3700+
var input = $"""patientName == "Dr. John Smith_{uniqueId} (Author)" && Title == "ComplexRecipe_{uniqueId}" """;
3701+
var config = new QueryKitConfiguration(config =>
3702+
{
3703+
config.DerivedProperty<Recipe>(x =>
3704+
x.Author != null
3705+
? x.Author.Name + " (Author)"
3706+
: "No Author")
3707+
.HasQueryName("patientName");
3708+
});
3709+
3710+
// Act
3711+
var queryableRecipes = testingServiceScope.DbContext().Recipes
3712+
.Include(x => x.Author);
3713+
var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input, config);
3714+
var recipes = await appliedQueryable.ToListAsync();
3715+
3716+
// Assert
3717+
recipes.Count.Should().Be(1);
3718+
recipes[0].Id.Should().Be(recipeWithAuthor.Id);
3719+
recipes[0].Author.Should().NotBeNull();
3720+
recipes[0].Author!.Name.Should().Be($"Dr. John Smith_{uniqueId}");
3721+
}
3722+
36223723
}

QueryKit.IntegrationTests/Tests/DatabaseSortingTests.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ namespace QueryKit.IntegrationTests.Tests;
33
using Bogus;
44
using FluentAssertions;
55
using Microsoft.EntityFrameworkCore;
6+
using QueryKit.Configuration;
67
using QueryKit.Exceptions;
78
using SharedTestingHelper.Fakes;
89
using WebApiTestProject.Entities;
@@ -243,4 +244,121 @@ public async Task can_sort_items_with_null_value_property()
243244
people[2].Id.Should().Be(fakePersonFour.Id);
244245
people[3].Id.Should().Be(fakePersonThree.Id);
245246
}
247+
248+
[Fact]
249+
public async Task can_sort_by_derived_property_with_navigation_property_like_scenario()
250+
{
251+
// Arrange
252+
var testingServiceScope = new TestingServiceScope();
253+
var uniqueId = Guid.NewGuid().ToString()[..8];
254+
255+
// Create people with different name patterns to test sorting
256+
var personA = new FakeTestingPersonBuilder()
257+
.WithFirstName($"Alice_{uniqueId}")
258+
.WithLastName($"Anderson_{uniqueId}")
259+
.WithTitle($"SortTest_{uniqueId}")
260+
.Build();
261+
262+
var personZ = new FakeTestingPersonBuilder()
263+
.WithFirstName($"Zoe_{uniqueId}")
264+
.WithLastName($"Wilson_{uniqueId}")
265+
.WithTitle($"SortTest_{uniqueId}")
266+
.Build();
267+
268+
// Create person without last name (should sort last with null handling)
269+
var personPartial = new FakeTestingPersonBuilder()
270+
.WithFirstName($"Bob_{uniqueId}")
271+
.WithLastName(null)
272+
.WithTitle($"SortTest_{uniqueId}")
273+
.Build();
274+
275+
await testingServiceScope.InsertAsync(personZ, personA, personPartial);
276+
277+
// Test sorting by derived property that mimics navigation property patterns
278+
// This mimics: x.Patient != null ? x.Patient.FirstName + " " + x.Patient.LastName : null
279+
var config = new QueryKitConfiguration(config =>
280+
{
281+
config.DerivedProperty<TestingPerson>(x =>
282+
x.LastName != null
283+
? x.FirstName + " " + x.LastName
284+
: "ZZZ_" + (x.FirstName ?? "Unknown")) // Sort nulls last
285+
.HasQueryName("fullName");
286+
});
287+
288+
// Act - This should not cause Entity Framework translation errors
289+
var queryablePeople = testingServiceScope.DbContext().People
290+
.Where(p => p.Title == $"SortTest_{uniqueId}");
291+
292+
var appliedQueryable = queryablePeople.ApplyQueryKitSort("fullName asc", config);
293+
var people = await appliedQueryable.ToListAsync();
294+
295+
// Assert - Should be sorted by derived full name
296+
people.Count.Should().Be(3);
297+
people[0].FirstName.Should().Be($"Alice_{uniqueId}"); // "Alice Anderson" - first alphabetically
298+
people[1].FirstName.Should().Be($"Zoe_{uniqueId}"); // "Zoe Wilson" - second alphabetically
299+
people[2].FirstName.Should().Be($"Bob_{uniqueId}"); // "ZZZ_Bob" - should be last
300+
}
301+
302+
[Fact]
303+
public async Task can_sort_by_derived_property_using_complex_patient_like_scenario()
304+
{
305+
// Arrange - This test mimics the exact scenario from the user's error
306+
var testingServiceScope = new TestingServiceScope();
307+
var uniqueId = Guid.NewGuid().ToString()[..8];
308+
309+
// Create people that represent "patients" with null safety checks
310+
var personWithFullName = new FakeTestingPersonBuilder()
311+
.WithFirstName($"John_{uniqueId}")
312+
.WithLastName($"Doe_{uniqueId}")
313+
.WithTitle($"Patient1_{uniqueId}")
314+
.Build();
315+
316+
var personWithPartialName = new FakeTestingPersonBuilder()
317+
.WithFirstName($"Jane_{uniqueId}")
318+
.WithLastName(null) // This represents cases where Patient.LastName might be null
319+
.WithTitle($"Patient2_{uniqueId}")
320+
.Build();
321+
322+
var personWithoutName = new FakeTestingPersonBuilder()
323+
.WithFirstName(null)
324+
.WithLastName(null)
325+
.WithTitle($"Patient3_{uniqueId}")
326+
.Build();
327+
328+
await testingServiceScope.InsertAsync(personWithoutName, personWithPartialName, personWithFullName);
329+
330+
// Test the EXACT pattern from user's error - derived property with != null checks for sorting
331+
// This exactly matches: x.Patient != null ? x.Patient.FirstName + " " + x.Patient.LastName : null
332+
var config = new QueryKitConfiguration(config =>
333+
{
334+
config.DerivedProperty<TestingPerson>(x =>
335+
x.FirstName != null && x.LastName != null
336+
? x.FirstName + " " + x.LastName
337+
: x.FirstName ?? "ZZZ_Unknown")
338+
.HasQueryName("patient");
339+
});
340+
341+
// Act - Sort by the derived property (this previously caused Entity Framework translation errors)
342+
var queryablePeople = testingServiceScope.DbContext().People
343+
.Where(p => p.Title.Contains($"Patient") && p.Title.Contains(uniqueId));
344+
345+
// This should NOT generate LeftJoin with `.OrderBy(a => (object)a.Inner)' error
346+
var appliedQueryable = queryablePeople.ApplyQueryKitSort("patient asc", config);
347+
var people = await appliedQueryable.ToListAsync();
348+
349+
// Assert - Should be sorted by computed patient name
350+
people.Count.Should().Be(3);
351+
352+
// First: Jane (partial name = "Jane_uniqueId")
353+
people[0].FirstName.Should().Be($"Jane_{uniqueId}");
354+
people[0].LastName.Should().BeNull();
355+
356+
// Second: John (full name = "John_uniqueId Doe_uniqueId")
357+
people[1].FirstName.Should().Be($"John_{uniqueId}");
358+
people[1].LastName.Should().Be($"Doe_{uniqueId}");
359+
360+
// Third: null person (unknown name = "ZZZ_Unknown")
361+
people[2].FirstName.Should().BeNull();
362+
people[2].LastName.Should().BeNull();
363+
}
246364
}

QueryKit/SortParser.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ private static SortExpressionInfo<T> CreateSortExpression<T>(string sortClause,
8787

8888
private static Expression? CreateSortExpressionBody(Expression parameter, string propertyName, IQueryKitConfiguration? config)
8989
{
90+
// First check if this is a derived property
91+
var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(propertyName);
92+
if (derivedPropertyInfo?.DerivedExpression != null)
93+
{
94+
// Replace the parameter in the derived expression with our current parameter
95+
var parameterReplacer = new ParameterReplacer((ParameterExpression)parameter);
96+
return parameterReplacer.Visit(derivedPropertyInfo.DerivedExpression);
97+
}
98+
99+
// Handle regular properties
90100
var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName;
91101
var propertyNames = propertyPath.Split('.');
92102

0 commit comments

Comments
 (0)