@@ -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}
0 commit comments