@@ -645,6 +645,51 @@ public void OrderBy_NavigationPropertyIdConvertToObject_ShouldUseFkColumn()
645645 sql . Contains ( "[Person]" ) . AssertTrue ( $ "Expected FK column [Person] in ORDER BY, got: { sql } ") ;
646646 }
647647
648+ /// <summary>
649+ /// Sorting by a non-Id member of a [RelationSingle] navigation
650+ /// (e.g., t.Person.Name) used to leak the leaf column unaliased,
651+ /// producing SQL like ORDER BY [Name] which fails on the host
652+ /// table that doesn't have a [Name] column. The translator must
653+ /// register an implicit JOIN to the navigation table and qualify
654+ /// the column with the join alias.
655+ /// </summary>
656+ [ TestMethod ]
657+ public void OrderBy_NavigationPropertyMember_ShouldRegisterJoinAndQualifyColumn ( )
658+ {
659+ EnsureFkEntitiesRegistered ( ) ;
660+ var tasks = CreateQueryable < TestTask > ( ) ;
661+
662+ var query = tasks . OrderBy ( t => t . Person . Name ) ;
663+
664+ var sql = GenerateSql < TestTask > ( query ) ;
665+
666+ sql . ContainsIgnoreCase ( "ORDER BY" ) . AssertTrue ( $ "Expected ORDER BY clause, got: { sql } ") ;
667+ sql . Contains ( "[Person].[Name]" ) . AssertTrue (
668+ $ "Expected qualified [Person].[Name] in ORDER BY (implicit JOIN required), got: { sql } ") ;
669+ sql . ContainsIgnoreCase ( "INNER JOIN" ) . AssertTrue (
670+ $ "Expected INNER JOIN to Person table for navigation OrderBy, got: { sql } ") ;
671+ }
672+
673+ /// <summary>
674+ /// OrderByDescending variant of <see cref="OrderBy_NavigationPropertyMember_ShouldRegisterJoinAndQualifyColumn"/>.
675+ /// </summary>
676+ [ TestMethod ]
677+ public void OrderByDescending_NavigationPropertyMember_ShouldRegisterJoinAndQualifyColumn ( )
678+ {
679+ EnsureFkEntitiesRegistered ( ) ;
680+ var tasks = CreateQueryable < TestTask > ( ) ;
681+
682+ var query = tasks . OrderByDescending ( t => t . Person . Name ) ;
683+
684+ var sql = GenerateSql < TestTask > ( query ) ;
685+
686+ sql . Contains ( "[Person].[Name]" ) . AssertTrue (
687+ $ "Expected qualified [Person].[Name] in ORDER BY DESC, got: { sql } ") ;
688+ sql . ContainsIgnoreCase ( "DESC" ) . AssertTrue ( $ "Expected DESC, got: { sql } ") ;
689+ sql . ContainsIgnoreCase ( "INNER JOIN" ) . AssertTrue (
690+ $ "Expected INNER JOIN to Person table, got: { sql } ") ;
691+ }
692+
648693 #endregion
649694
650695 #region String Parameterization Tests
@@ -692,6 +737,73 @@ public void StringConstant_WithMultipleSingleQuotes_ShouldBeParameterized()
692737
693738 #endregion
694739
740+ #region Closure-capture Chain Parameterization Tests
741+
742+ // Reproduces the EXACT closure shape generated by an async method with two levels
743+ // of nested if-blocks that capture an outer-method local. Mirrors
744+ // <c>BrokerPortfolioRepository.SearchAsync</c>: outer state-machine field
745+ // (tenantId), first if-block DC (referencing the state-machine), and a nested
746+ // else-block DC (referencing the first DC). The lambda inside the inner block
747+ // then reads tenantId through a 3-link chain: innerDC → outerDC → methodDC →
748+ // tenantId. The compiler-generated tree shape is:
749+ // MemberAccess(tenantId)
750+ // MemberAccess(CS$<>8__locals1)
751+ // MemberAccess(CS$<>8__locals2)
752+ // ConstantExpression(innerDC instance)
753+ // Pre-fix VisitMember handled only 1- and 2-link closure chains; this 3-link
754+ // chain fell through to column-emission branches and leaked raw field names
755+ // (e.g. <c>[e].[tenantId]</c>) as if they were SQL columns.
756+ private static IQueryable < TestTask > BuildDeepClosureQueryProductionShape ( IQueryable < TestTask > tasks , long ownerId , string user )
757+ {
758+ var dummyToken = string . IsNullOrEmpty ( user ) ? null : user ;
759+ if ( dummyToken is not null )
760+ {
761+ if ( long . TryParse ( dummyToken , out var uid ) )
762+ {
763+ return tasks . Where ( t => t . Id == uid ) ;
764+ }
765+ else
766+ {
767+ var like = "%" + dummyToken + "%" ;
768+ // This Where lambda captures BOTH `ownerId` (method scope) and `like` (else-block scope).
769+ // Compiler emits: innerDC has `like`, parent is firstIfDC which parents the methodDC.
770+ // → 3-level MemberExpression chain to reach `ownerId` from the constant.
771+ return tasks . Where ( t => t . Person . Id == ownerId && ( t . Title == like || t . Title == like ) ) ;
772+ }
773+ }
774+ return tasks ;
775+ }
776+
777+ /// <summary>
778+ /// Captures across three nested DisplayClass levels used to fall through every
779+ /// short-circuit branch in VisitMember and emit raw field names like
780+ /// <c>[ownerId]</c> as if they were SQL columns on the default alias.
781+ /// The translator must walk the full <c>[CompilerGenerated]</c> chain,
782+ /// evaluate via reflection, and parameterize the captured values.
783+ /// </summary>
784+ [ TestMethod ]
785+ public void Where_NestedClosureCaptureChain_ShouldParameterizeAndNotLeakFieldNames ( )
786+ {
787+ EnsureFkEntitiesRegistered ( ) ;
788+ var tasks = CreateQueryable < TestTask > ( ) ;
789+
790+ var query = BuildDeepClosureQueryProductionShape ( tasks , 42L , "alice" ) ;
791+
792+ var ( sql , parameters ) = TranslateSql < TestTask > ( query ) ;
793+
794+ sql . Contains ( "[ownerId]" ) . AssertFalse (
795+ $ "Captured local 'ownerId' must not appear as a SQL column, got: { sql } ") ;
796+ sql . Contains ( "[like]" ) . AssertFalse (
797+ $ "Captured local 'like' must not appear as a SQL column, got: { sql } ") ;
798+
799+ parameters . Values . Any ( p => Equals ( p . Item2 , 42L ) ) . AssertTrue (
800+ $ "Expected captured ownerId=42 to be parameterized, got params: { string . Join ( ", " , parameters . Select ( p => $ "{ p . Key } ={ p . Value . Item2 } ") ) } ") ;
801+ parameters . Values . Any ( p => Equals ( p . Item2 , "%alice%" ) ) . AssertTrue (
802+ $ "Expected captured like='%alice%' to be parameterized, got params: { string . Join ( ", " , parameters . Select ( p => $ "{ p . Key } ={ p . Value . Item2 } ") ) } ") ;
803+ }
804+
805+ #endregion
806+
695807 #region Anonymous-type Select Projection Tests
696808
697809 /// <summary>
0 commit comments