Skip to content

Commit 7d12213

Browse files
committed
fix: sorting with nested nullables
1 parent bc4af5c commit 7d12213

3 files changed

Lines changed: 254 additions & 15 deletions

File tree

QueryKit.IntegrationTests/Tests/DatabaseSortingTests.cs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ namespace QueryKit.IntegrationTests.Tests;
66
using QueryKit.Configuration;
77
using QueryKit.Exceptions;
88
using SharedTestingHelper.Fakes;
9+
using SharedTestingHelper.Fakes.Author;
10+
using SharedTestingHelper.Fakes.Recipes;
911
using WebApiTestProject.Entities;
12+
using WebApiTestProject.Entities.Recipes;
1013

1114
public class DatabaseSortingTests : TestBase
1215
{
@@ -361,4 +364,102 @@ public async Task can_sort_by_derived_property_using_complex_patient_like_scenar
361364
people[2].FirstName.Should().BeNull();
362365
people[2].LastName.Should().BeNull();
363366
}
367+
368+
[Fact]
369+
public async Task can_sort_by_nullable_navigation_property()
370+
{
371+
// Arrange - This tests the scenario where a navigation property (Author) can be null
372+
// Similar to the user's scenario: MatchPlayer.Player.LastName where Player is nullable
373+
var testingServiceScope = new TestingServiceScope();
374+
var uniqueId = Guid.NewGuid().ToString()[..8];
375+
376+
// Create recipe WITH author (name starting with "A")
377+
var authorA = new FakeAuthorBuilder()
378+
.WithName($"Alice_{uniqueId}")
379+
.Build();
380+
var recipeWithAuthorA = new FakeRecipeBuilder()
381+
.WithTitle($"Recipe1_{uniqueId}")
382+
.Build();
383+
recipeWithAuthorA.SetAuthor(authorA);
384+
385+
// Create recipe WITH author (name starting with "Z")
386+
var authorZ = new FakeAuthorBuilder()
387+
.WithName($"Zoe_{uniqueId}")
388+
.Build();
389+
var recipeWithAuthorZ = new FakeRecipeBuilder()
390+
.WithTitle($"Recipe2_{uniqueId}")
391+
.Build();
392+
recipeWithAuthorZ.SetAuthor(authorZ);
393+
394+
// Create recipe WITHOUT author (null navigation property)
395+
var recipeWithoutAuthor = new FakeRecipeBuilder()
396+
.WithTitle($"Recipe3_{uniqueId}")
397+
.Build();
398+
// Note: No SetAuthor call - Author will be null
399+
400+
await testingServiceScope.InsertAsync(recipeWithAuthorZ, recipeWithoutAuthor, recipeWithAuthorA);
401+
402+
// Act - Try to sort by nested navigation property Author.Name when Author can be null
403+
var queryableRecipes = testingServiceScope.DbContext().Recipes
404+
.Include(r => r.Author)
405+
.Where(r => r.Title.Contains(uniqueId));
406+
407+
// This should handle null navigation properties gracefully
408+
var appliedQueryable = queryableRecipes.ApplyQueryKitSort("Author.Name asc");
409+
var recipes = await appliedQueryable.ToListAsync();
410+
411+
// Assert - Should sort without errors, with null authors sorted appropriately
412+
recipes.Count.Should().Be(3);
413+
414+
// In PostgreSQL with ASC, nulls are sorted LAST by default
415+
// So we expect: Alice, Zoe, then null
416+
recipes[0].Title.Should().Be($"Recipe1_{uniqueId}"); // Alice
417+
recipes[1].Title.Should().Be($"Recipe2_{uniqueId}"); // Zoe
418+
recipes[2].Title.Should().Be($"Recipe3_{uniqueId}"); // null author
419+
recipes[2].Author.Should().BeNull();
420+
}
421+
422+
[Fact]
423+
public async Task can_sort_descending_by_nullable_navigation_property()
424+
{
425+
// Arrange - Test DESC sorting with null navigation properties
426+
var testingServiceScope = new TestingServiceScope();
427+
var uniqueId = Guid.NewGuid().ToString()[..8];
428+
429+
var authorA = new FakeAuthorBuilder()
430+
.WithName($"Alice_{uniqueId}")
431+
.Build();
432+
var recipeWithAuthorA = new FakeRecipeBuilder()
433+
.WithTitle($"RecipeA_{uniqueId}")
434+
.Build();
435+
recipeWithAuthorA.SetAuthor(authorA);
436+
437+
var authorZ = new FakeAuthorBuilder()
438+
.WithName($"Zoe_{uniqueId}")
439+
.Build();
440+
var recipeWithAuthorZ = new FakeRecipeBuilder()
441+
.WithTitle($"RecipeZ_{uniqueId}")
442+
.Build();
443+
recipeWithAuthorZ.SetAuthor(authorZ);
444+
445+
var recipeWithoutAuthor = new FakeRecipeBuilder()
446+
.WithTitle($"RecipeNull_{uniqueId}")
447+
.Build();
448+
449+
await testingServiceScope.InsertAsync(recipeWithAuthorA, recipeWithoutAuthor, recipeWithAuthorZ);
450+
451+
// Act
452+
var queryableRecipes = testingServiceScope.DbContext().Recipes
453+
.Include(r => r.Author)
454+
.Where(r => r.Title.Contains(uniqueId));
455+
456+
var appliedQueryable = queryableRecipes.ApplyQueryKitSort("Author.Name desc");
457+
var recipes = await appliedQueryable.ToListAsync();
458+
459+
// Assert - In PostgreSQL with DESC, nulls are sorted FIRST by default
460+
recipes.Count.Should().Be(3);
461+
recipes[0].Title.Should().Be($"RecipeNull_{uniqueId}"); // null author first in DESC
462+
recipes[1].Title.Should().Be($"RecipeZ_{uniqueId}"); // Zoe
463+
recipes[2].Title.Should().Be($"RecipeA_{uniqueId}"); // Alice
464+
}
364465
}

QueryKit.UnitTests/SortParserTests.cs

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,115 @@ private string GetMemberName<T>(Expression<Func<T, object>>? expr)
301301

302302
private string GetMemberNameFromExpression(Expression expr)
303303
{
304+
// Handle conditional expressions (generated by null-safe navigation)
305+
// For x.A == null ? null : x.A.B, we want to extract "A.B"
306+
if (expr is ConditionalExpression conditional)
307+
{
308+
// The IfFalse branch contains the actual property access chain
309+
return GetMemberNameFromExpression(conditional.IfFalse);
310+
}
311+
312+
// Handle Convert expressions that wrap the actual value
313+
if (expr is UnaryExpression { NodeType: ExpressionType.Convert } convertExpr)
314+
{
315+
return GetMemberNameFromExpression(convertExpr.Operand);
316+
}
317+
304318
if (expr is MemberExpression memberExpr)
305319
{
306-
if (memberExpr.Expression.NodeType == ExpressionType.MemberAccess)
320+
if (memberExpr.Expression?.NodeType == ExpressionType.MemberAccess)
307321
{
308322
return GetMemberNameFromExpression(memberExpr.Expression) + "." + memberExpr.Member.Name;
309323
}
310324

325+
// Check for nested conditional in the expression
326+
if (memberExpr.Expression is ConditionalExpression nestedConditional)
327+
{
328+
var parentName = GetMemberNameFromExpression(nestedConditional);
329+
return parentName + "." + memberExpr.Member.Name;
330+
}
331+
311332
return memberExpr.Member.Name;
312333
}
313334

314335
throw new ArgumentException("Invalid expression type", nameof(expr));
315336
}
337+
338+
#region Nullable Navigation Property Tests
339+
340+
// Test entities for nullable navigation property scenarios
341+
public class Player
342+
{
343+
public string? FirstName { get; set; }
344+
public string? LastName { get; set; }
345+
}
346+
347+
public class MatchPlayer
348+
{
349+
public string? FirstName { get; set; }
350+
public string? LastName { get; set; }
351+
public Player? Player { get; set; } // Nullable navigation property
352+
}
353+
354+
public class PlayerStat
355+
{
356+
public string Id { get; set; } = Guid.NewGuid().ToString();
357+
public int Score { get; set; }
358+
public MatchPlayer MatchPlayer { get; set; } = new();
359+
}
360+
361+
[Fact]
362+
public void in_memory_sort_handles_null_navigation_property_gracefully()
363+
{
364+
// Arrange - Create data where MatchPlayer.Player is null for some items
365+
var stats = new List<PlayerStat>
366+
{
367+
new() { Score = 10, MatchPlayer = new MatchPlayer { FirstName = "Alice", Player = new Player { LastName = "Anderson" } } },
368+
new() { Score = 20, MatchPlayer = new MatchPlayer { FirstName = "Bob", Player = null } }, // null Player!
369+
new() { Score = 30, MatchPlayer = new MatchPlayer { FirstName = "Charlie", Player = new Player { LastName = "Clark" } } },
370+
};
371+
372+
// Act - Sorting by MatchPlayer.Player.LastName should handle null Player gracefully
373+
var sorted = stats.ApplyQueryKitSort("MatchPlayer.Player.LastName asc").ToList();
374+
375+
// Assert - Should not throw, nulls should sort first in ASC order
376+
sorted.Count.Should().Be(3);
377+
// Null values sort first, then alphabetically: null (Bob), Anderson (Alice), Clark (Charlie)
378+
sorted[0].MatchPlayer.FirstName.Should().Be("Bob"); // null Player -> null LastName sorts first
379+
sorted[1].MatchPlayer.FirstName.Should().Be("Alice"); // Anderson
380+
sorted[2].MatchPlayer.FirstName.Should().Be("Charlie"); // Clark
381+
}
382+
383+
[Fact]
384+
public void in_memory_sort_works_with_derived_property_null_handling()
385+
{
386+
// Arrange - The user's workaround uses a derived property with null coalescing
387+
var stats = new List<PlayerStat>
388+
{
389+
new() { Score = 10, MatchPlayer = new MatchPlayer { FirstName = "Alice", LastName = "Direct_A", Player = new Player { LastName = "Anderson" } } },
390+
new() { Score = 20, MatchPlayer = new MatchPlayer { FirstName = "Bob", LastName = "Direct_B", Player = null } }, // null Player - should use MatchPlayer.LastName
391+
new() { Score = 30, MatchPlayer = new MatchPlayer { FirstName = "Charlie", LastName = null, Player = new Player { LastName = "Clark" } } }, // null MatchPlayer.LastName - should use Player.LastName
392+
};
393+
394+
var config = new QueryKitConfiguration(c =>
395+
{
396+
// User's workaround pattern: coalesce MatchPlayer.LastName with Player.LastName
397+
c.DerivedProperty<PlayerStat>(x =>
398+
x.MatchPlayer.LastName ??
399+
(x.MatchPlayer.Player != null ? x.MatchPlayer.Player.LastName : "") ?? "")
400+
.HasQueryName("playerLastName");
401+
});
402+
403+
// Act - Should work without throwing
404+
var sorted = stats.ApplyQueryKitSort("playerLastName asc", config).ToList();
405+
406+
// Assert
407+
sorted.Count.Should().Be(3);
408+
// Order: "Clark", "Direct_A", "Direct_B" (alphabetical by coalesced last name)
409+
sorted[0].MatchPlayer.FirstName.Should().Be("Charlie"); // Clark
410+
sorted[1].MatchPlayer.FirstName.Should().Be("Alice"); // Direct_A
411+
sorted[2].MatchPlayer.FirstName.Should().Be("Bob"); // Direct_B
412+
}
413+
414+
#endregion
316415
}

QueryKit/SortParser.cs

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -96,25 +96,64 @@ private static SortExpressionInfo<T> CreateSortExpression<T>(string sortClause,
9696
return parameterReplacer.Visit(derivedPropertyInfo.DerivedExpression);
9797
}
9898

99-
// Handle regular properties
99+
// Handle regular properties with null-safe navigation
100100
var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName;
101101
var propertyNames = propertyPath.Split('.');
102102

103-
var propertyExpression = propertyNames.Aggregate(parameter, (expr, propName) =>
103+
var result = CreateNullSafePropertyExpression(parameter, propertyNames, 0);
104+
if (result == null)
105+
throw new SortParsingException(propertyName);
106+
107+
return result;
108+
}
109+
110+
private static Expression? CreateNullSafePropertyExpression(Expression currentExpr, string[] propertyNames, int index)
111+
{
112+
if (index >= propertyNames.Length)
113+
return currentExpr;
114+
115+
var propName = propertyNames[index];
116+
var propertyInfo = GetPropertyInfo(currentExpr.Type, propName);
117+
if (propertyInfo == null)
118+
return null;
119+
120+
var propertyAccess = Expression.PropertyOrField(currentExpr, propertyInfo.Name);
121+
122+
// If this is the last property in the chain, just return the access
123+
if (index == propertyNames.Length - 1)
124+
return propertyAccess;
125+
126+
// Get the rest of the property chain
127+
var restOfChain = CreateNullSafePropertyExpression(propertyAccess, propertyNames, index + 1);
128+
if (restOfChain == null)
129+
return null;
130+
131+
// Check if the current property type is a reference type (could be null)
132+
var propertyType = propertyInfo.PropertyType;
133+
var isNullableType = !propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) != null;
134+
135+
if (isNullableType)
104136
{
105-
var propertyInfo = GetPropertyInfo(expr.Type, propName);
106-
if (propertyInfo == null)
107-
{
108-
return null;
109-
}
110-
var actualPropertyName = propertyInfo?.Name ?? propName;
111-
return Expression.PropertyOrField(expr, actualPropertyName);
112-
});
137+
// It's a reference type or nullable value type - add null check
138+
// Generate: property == null ? (TargetType)null : <rest of chain>
139+
var targetType = restOfChain.Type;
140+
var nullableTargetType = targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null
141+
? typeof(Nullable<>).MakeGenericType(targetType)
142+
: targetType;
143+
144+
var nullCheck = Expression.Equal(propertyAccess, Expression.Constant(null, propertyType));
145+
var nullValue = Expression.Constant(null, nullableTargetType);
146+
147+
// Convert rest of chain to nullable if needed
148+
var convertedRest = targetType != nullableTargetType
149+
? Expression.Convert(restOfChain, nullableTargetType)
150+
: restOfChain;
151+
152+
return Expression.Condition(nullCheck, nullValue, convertedRest);
153+
}
113154

114-
if(propertyExpression == null)
115-
throw new SortParsingException(propertyName);
116-
117-
return propertyExpression;
155+
// Non-nullable value type - continue without null check
156+
return restOfChain;
118157
}
119158

120159
private static PropertyInfo? GetPropertyInfo(Type type, string propertyName)

0 commit comments

Comments
 (0)