Skip to content

Commit 0752b60

Browse files
committed
Parameterize multi-level closure captures and support nav OrderBy via JOIN
1 parent 451e9ae commit 0752b60

3 files changed

Lines changed: 225 additions & 5 deletions

File tree

Data.ORM/Sql/Expression/Context.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class Context
2929

3030
public long? Skip;
3131
public long? Take;
32-
public readonly List<(MemberInfo member, bool asc)> OrderBy = [];
32+
public readonly List<(string joinAlias, MemberInfo member, bool asc)> OrderBy = [];
3333
public string SelectAlias;
3434
public bool Count;
3535
public bool Distinct;
@@ -356,14 +356,16 @@ public Query Build(Schema schema)
356356

357357
var isFirstColumn = true;
358358

359-
foreach (var (member, asc) in OrderBy)
359+
foreach (var (joinAlias, member, asc) in OrderBy)
360360
{
361361
if (isFirstColumn)
362362
isFirstColumn = false;
363363
else
364364
query.Comma();
365365

366-
if (schema.EntityType == member.DeclaringType)
366+
if (joinAlias is not null)
367+
query.Column(joinAlias, member.Name);
368+
else if (schema.EntityType == member.DeclaringType)
367369
query.Column(TableAlias, member.Name);
368370
else
369371
query.Column(member.Name);

Data.ORM/Sql/Expression/ExpressionQueryTranslator.cs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,17 @@ protected override Expression VisitMember(MemberExpression m)
10831083
return exp;
10841084
}
10851085

1086+
// Compiler-generated DisplayClass chains of arbitrary depth (e.g. async
1087+
// state machine + nested if-block locals) must be evaluated up-front
1088+
// and emitted as parameters; otherwise the chain falls through to the
1089+
// column-emission branches and the captured local name (e.g. `tenantId`)
1090+
// leaks as a raw SQL column on the default alias.
1091+
if (TryEvaluateClosureCapture(m, out var capturedValue))
1092+
{
1093+
EmitClosureCaptureValue(m.Member, capturedValue);
1094+
return m;
1095+
}
1096+
10861097
if (m.Expression is ConstantExpression ce)
10871098
{
10881099
var memberValue = m.Member.GetMemberValue(ce.Value);
@@ -1329,11 +1340,19 @@ private void ParseOrderByExpression(MethodCallExpression expression, bool asc)
13291340
&& body.Expression is MemberExpression navExpr
13301341
&& !navExpr.Member.DeclaringType.IsAutoGenerated())
13311342
{
1332-
Context.OrderBy.Insert(0, (navExpr.Member, asc));
1343+
Context.OrderBy.Insert(0, (null, navExpr.Member, asc));
1344+
}
1345+
else if (body.Expression is MemberExpression navOwner
1346+
&& TryEnsureImplicitJoin(navOwner) is { } navAlias)
1347+
{
1348+
// OrderBy(t => t.Person.Name): register implicit JOIN to Person
1349+
// and qualify the column as [Person].[Name] so it resolves at
1350+
// SQL level instead of emitting bare [Name].
1351+
Context.OrderBy.Insert(0, (navAlias, body.Member, asc));
13331352
}
13341353
else
13351354
{
1336-
Context.OrderBy.Insert(0, (body.Member, asc));
1355+
Context.OrderBy.Insert(0, (null, body.Member, asc));
13371356
}
13381357
}
13391358
else if (lambdaBody is MethodCallExpression call)
@@ -1354,4 +1373,91 @@ private void ParseOrderByExpression(MethodCallExpression expression, bool asc)
13541373
Curr = curr;
13551374
}
13561375
}
1376+
1377+
/// <summary>
1378+
/// Tries to evaluate a chain of MemberExpression accesses that terminates at
1379+
/// a <see cref="ConstantExpression"/> rooted on a compiler-generated closure
1380+
/// DisplayClass. Returns true and the captured value when every link in the
1381+
/// chain is a closure access (intermediate types are <c>[CompilerGenerated]</c>).
1382+
/// Used to short-circuit nested closure captures (e.g. inner DC -> outer DC
1383+
/// -> field) that would otherwise leak through column-emission branches and
1384+
/// produce SQL like <c>[e].[tenantId]</c>.
1385+
/// </summary>
1386+
private static bool TryEvaluateClosureCapture(MemberExpression m, out object value)
1387+
{
1388+
value = null;
1389+
1390+
var stack = new Stack<MemberInfo>();
1391+
Expression current = m;
1392+
1393+
while (current is MemberExpression cur)
1394+
{
1395+
// Reject the chain as soon as a non-closure type appears: anything that is
1396+
// not [CompilerGenerated] belongs to user/entity territory and must continue
1397+
// through the existing visitor branches (Members substitution, implicit JOIN,
1398+
// inner-schema flattening, etc.).
1399+
if (cur.Member.DeclaringType is null || !cur.Member.DeclaringType.IsAutoGenerated())
1400+
return false;
1401+
1402+
stack.Push(cur.Member);
1403+
current = cur.Expression;
1404+
}
1405+
1406+
if (current is not ConstantExpression ce)
1407+
return false;
1408+
1409+
var instance = ce.Value;
1410+
1411+
while (stack.Count > 0)
1412+
{
1413+
var member = stack.Pop();
1414+
1415+
if (instance is null)
1416+
return false;
1417+
1418+
instance = member.GetMemberValue(instance);
1419+
}
1420+
1421+
value = instance;
1422+
return true;
1423+
}
1424+
1425+
/// <summary>
1426+
/// Emits a value resolved from a closure-capture chain into the current query
1427+
/// builder, mirroring <see cref="Context.TryAddParam(MemberInfo, object)"/> so
1428+
/// scalars become parameters, arrays expand into comma-separated parameter
1429+
/// lists, IQueryable values are re-visited to inline their expression tree,
1430+
/// and nulls collapse to <c>NULL</c>.
1431+
/// </summary>
1432+
private void EmitClosureCaptureValue(MemberInfo member, object value)
1433+
{
1434+
if (value is IQueryable q && q.Expression is not ConstantExpression)
1435+
{
1436+
Visit(q.Expression);
1437+
return;
1438+
}
1439+
1440+
if (value is null)
1441+
{
1442+
Curr.Null();
1443+
return;
1444+
}
1445+
1446+
if (value is Array arr && value is not byte[])
1447+
{
1448+
var itemType = arr.GetType().GetItemType();
1449+
1450+
for (var i = 0; i < arr.Length; i++)
1451+
{
1452+
if (i > 0)
1453+
Curr.Comma();
1454+
1455+
Curr.Param(Context.TryAddParam(member.Name, itemType, arr.GetValue(i)));
1456+
}
1457+
return;
1458+
}
1459+
1460+
Curr.Param(Context.TryAddParam(member.Name, member.GetMemberType(), value));
1461+
}
1462+
13571463
}

Tests/Data/ExpressionQueryTranslatorTests.cs

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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$&lt;&gt;8__locals1)
751+
// MemberAccess(CS$&lt;&gt;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

Comments
 (0)