Skip to content

Commit 9b5a7db

Browse files
committed
Scope sub-query Context and inherit outer aliases for correlated projections
1 parent 6e1a79b commit 9b5a7db

2 files changed

Lines changed: 149 additions & 40 deletions

File tree

Data.ORM/Sql/Expression/ExpressionQueryTranslator.cs

Lines changed: 94 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,21 @@ private Expression VisitSelectManyCall(MethodCallExpression m)
236236
var retVal = Visit(m.Arguments[0]);
237237
Context.LeftJoinAlias = leftJoinAlias;
238238

239+
// Bind the result-selector parameters to their backing aliases so
240+
// downstream visitors (and any sub-query that inherits Aliases)
241+
// can resolve `<transparentId>.<field>` and `<rightSide>` chains.
242+
// First param projects onto the running outer scope (FROM table or
243+
// previous transparent identifier); second param maps to the LEFT
244+
// JOIN alias produced by the source GroupJoin.
245+
if (resultSelector.Parameters.Count > 0)
246+
{
247+
var outerAlias = Context.TableAlias.IsEmpty() ? Extensions.DefaultAlias : Context.TableAlias;
248+
Context.Aliases[resultSelector.Parameters[0]] = outerAlias;
249+
}
250+
251+
if (resultSelector.Parameters.Count > 1)
252+
Context.Aliases[resultSelector.Parameters[1]] = resultSelector.Parameters[1].Name;
253+
239254
if (resultSelector.Body is MemberInitExpression)
240255
{
241256
// final projection folded into SelectMany result selector
@@ -527,42 +542,32 @@ private Expression ProcessInitExpression(Expression maExp)
527542
Curr.OpenBracket();
528543
Curr.NewLine();
529544

530-
Context = new() { ParamCountOffset = ctx.Parameters.Count + ctx.ParamCountOffset };
531-
532-
if (visitor?.GetType().GetGenericType(typeof(CountVisitor<>)) != null)
533-
Context.Count = true;
534-
}
535-
536-
Visit(maExp);
545+
// Pre-analyse the sub-query so the element type and the
546+
// lambda-parameter alias are known BEFORE Visit. Setting
547+
// TableAlias up front lets RegisterLambdaParameters bind the
548+
// inner lambda's parameter to the right alias instead of the
549+
// default one — without this the inner WHERE references "e"
550+
// while FROM declares "x" and the SQL fails to bind.
551+
AnalyseSubqueryShape(maExp, out var subItemType, out var subAlias);
537552

538-
if (isSubquery)
539-
{
540-
Type itemType;
541-
542-
if (maExp is MethodCallExpression mca)
553+
Context = new()
543554
{
544-
var args = mca.Method.GetGenericArguments();
545-
546-
itemType = args.Length > 0 ? args[0] : typeof(int);
547-
548-
while (mca.Arguments[0] is MethodCallExpression mca1)
549-
mca = mca1;
555+
ParamCountOffset = ctx.Parameters.Count + ctx.ParamCountOffset,
556+
TableAlias = subAlias,
557+
};
550558

551-
Context.TableAlias = (mca.Arguments[1] is MemberExpression
552-
? mca.Arguments[2]
553-
: mca.Arguments[1])
554-
.GetOperand().Parameters[0].Name;
559+
// Inherit outer alias bindings so a correlated reference like
560+
// `outerParam.Field` inside the sub-query resolves to the outer
561+
// FROM alias instead of leaking the transparent-id name.
562+
foreach (var kv in ctx.Aliases)
563+
Context.Aliases[kv.Key] = kv.Value;
555564

556-
if (itemType.IsSerializablePrimitive())
557-
{
558-
itemType = ((MemberExpression)mca.Arguments[0]).Member.GetMemberType().GetGenericArguments()[0];
559-
}
560-
}
561-
else
562-
itemType = _meta.EntityType;
565+
if (visitor?.GetType().GetGenericType(typeof(CountVisitor<>)) != null)
566+
Context.Count = true;
563567

564-
var subquery = Context.Build(SchemaRegistry.Get(itemType));
568+
Visit(maExp);
565569

570+
var subquery = Context.Build(SchemaRegistry.Get(subItemType ?? _meta.EntityType));
566571
var subParams = Context.Parameters;
567572

568573
Context = ctx;
@@ -571,10 +576,43 @@ private Expression ProcessInitExpression(Expression maExp)
571576

572577
Context.AddParamsFromSubquery(subParams, false);
573578
}
579+
else
580+
{
581+
Visit(maExp);
582+
}
574583

575584
return maExp;
576585
}
577586

587+
/// <summary>
588+
/// Walks the outermost LINQ chain in <paramref name="maExp"/> to extract
589+
/// (a) the element type of the sub-query result and (b) the lambda
590+
/// parameter name of the deepest source. Both are needed to set up the
591+
/// sub-query <see cref="Context"/> before the visitor descends.
592+
/// </summary>
593+
private static void AnalyseSubqueryShape(Expression maExp, out Type itemType, out string alias)
594+
{
595+
itemType = null;
596+
alias = null;
597+
598+
if (maExp is not MethodCallExpression mca)
599+
return;
600+
601+
var args = mca.Method.GetGenericArguments();
602+
itemType = args.Length > 0 ? args[0] : typeof(int);
603+
604+
while (mca.Arguments[0] is MethodCallExpression mca1)
605+
mca = mca1;
606+
607+
alias = (mca.Arguments[1] is MemberExpression
608+
? mca.Arguments[2]
609+
: mca.Arguments[1])
610+
.GetOperand().Parameters[0].Name;
611+
612+
if (itemType.IsSerializablePrimitive())
613+
itemType = ((MemberExpression)mca.Arguments[0]).Member.GetMemberType().GetGenericArguments()[0];
614+
}
615+
578616
protected override Expression VisitMemberInit(MemberInitExpression i)
579617
{
580618
var selectColumns = new ContextSelectColumns();
@@ -1069,14 +1107,19 @@ private string GetAlias(string alias)
10691107
/// <summary>
10701108
/// Lookup overload that resolves a <see cref="ParameterExpression"/>
10711109
/// directly through <see cref="Context.Aliases"/> when the parameter
1072-
/// has been registered by the enclosing LINQ-method handler. Falls
1073-
/// back to the string-based <see cref="GetAlias(string)"/> otherwise
1074-
/// so callers do not need to know whether registration happened.
1110+
/// has been registered by the enclosing LINQ-method handler. The
1111+
/// stored value is already the canonical alias and must NOT be piped
1112+
/// through the string-based fallback — that would remap every alias
1113+
/// to <see cref="Context.TableAlias"/> and erase outer-scope
1114+
/// references in sub-queries that inherit the parent's alias map.
1115+
/// Falls back to the string-based <see cref="GetAlias(string)"/> when
1116+
/// the parameter is not registered, so callers do not need to know
1117+
/// whether registration happened.
10751118
/// </summary>
10761119
private string GetAlias(ParameterExpression parameter)
10771120
{
10781121
if (parameter is not null && Context.Aliases.TryGetValue(parameter, out var alias))
1079-
return GetAlias(alias);
1122+
return alias;
10801123

10811124
return GetAlias(parameter?.Name);
10821125
}
@@ -1170,10 +1213,13 @@ protected override Expression VisitMember(MemberExpression m)
11701213

11711214
if (!TryEmitFromResolver(m.Update(me)))
11721215
{
1173-
// Last-resort fallback: emit through the legacy alias-name
1174-
// derivation so cases the resolver does not yet recognise
1175-
// continue to work as before.
1176-
Curr.Column(me.Member.Name, m.Member.Name);
1216+
// Walk the transparent-id chain to the actual lambda
1217+
// parameter and use its registered alias — using
1218+
// <c>me.Member.Name</c> (the field name on the
1219+
// compiler-generated transparent identifier) leaks as
1220+
// a fake SQL alias and breaks any chain longer than
1221+
// one hop.
1222+
Curr.Column(ResolveTransIdAlias(me), m.Member.Name);
11771223
}
11781224
}
11791225
}
@@ -1459,9 +1505,17 @@ void AddColumnFromMember(MemberExpression member)
14591505
/// </summary>
14601506
private void EmitMaterialisedValue(MemberInfo leafMember, object value)
14611507
{
1462-
if (value is IQueryable q && q.Expression is not ConstantExpression)
1508+
if (value is IQueryable q)
14631509
{
1464-
Visit(q.Expression);
1510+
// Captured local IQueryable<T>: re-visit the wrapped expression
1511+
// when it carries query operators, otherwise emit nothing.
1512+
// Constant-rooted queryables (the common Query<T>() shape)
1513+
// represent the bare table — the surrounding sub-query handler
1514+
// resolves the element type to a FROM, so a value-emit here
1515+
// would corrupt the sub-query's WHERE with a non-bindable
1516+
// IQueryable parameter.
1517+
if (q.Expression is not ConstantExpression)
1518+
Visit(q.Expression);
14651519
return;
14661520
}
14671521

Tests/Data/OrmIntegrationTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2873,6 +2873,61 @@ where ids.Contains(p.Id) && (t2 == null || t2.Priority > 0)
28732873
(result.Length >= 2).AssertTrue();
28742874
}
28752875

2876+
/// <summary>
2877+
/// View-processor shape: GroupJoin + DefaultIfEmpty followed by a
2878+
/// projection that contains a sub-query referencing the outer source
2879+
/// (`i.Id`). The sub-query's reference to `i.Id` must resolve to the
2880+
/// original outer FROM alias — without the fix the translator leaks
2881+
/// the compiler-generated transparent identifier and SQL Server reports
2882+
/// "The multi-part identifier &lt;&gt;h__TransparentIdentifier2.Id could not be bound."
2883+
///
2884+
/// Generated SQL fragment (faulty):
2885+
/// ... ([b1].[File] = [&lt;&gt;h__TransparentIdentifier2].[Id]) ...
2886+
/// Should be:
2887+
/// ... ([b1].[File] = [e].[Id]) ...
2888+
/// </summary>
2889+
[TestMethod]
2890+
[DataRow(DatabaseProviderRegistry.SqlServer)]
2891+
[DataRow(DatabaseProviderRegistry.PostgreSql)]
2892+
[DataRow(DatabaseProviderRegistry.SQLite)]
2893+
public async Task ViewProcessorShape_GroupJoin_ProjectionWithSubqueryReferencingOuter(string provider)
2894+
{
2895+
SetUp(provider);
2896+
var i1 = await InsertItem("WithCat");
2897+
var i2 = await InsertItem("NoCat");
2898+
var cat = await InsertCategory("Cat1");
2899+
await InsertItemCategory(i1, cat);
2900+
2901+
await ClearCache();
2902+
2903+
// Sub-query sources are captured as locals before the main
2904+
// from-block — the translator cannot rewrite Query<T>() method
2905+
// calls inside an expression tree.
2906+
var itemCategories = Query<TestItemCategory>();
2907+
2908+
var view =
2909+
from i in Query<TestItem>()
2910+
join ic in itemCategories on i.Id equals ic.Item.Id into ics
2911+
from ic1 in ics.DefaultIfEmpty()
2912+
select new TestItem
2913+
{
2914+
Id = i.Id,
2915+
Name = i.Name,
2916+
// Sub-query references the outer `i.Id` — count-of-related
2917+
// pattern that view processors commonly emit.
2918+
Priority = (
2919+
from x in itemCategories
2920+
where x.Item.Id == i.Id
2921+
select x
2922+
).Count(),
2923+
};
2924+
2925+
var ids = new[] { i1.Id, i2.Id };
2926+
var results = await view.Where(e => ids.Contains(e.Id)).ToArrayAsyncEx(CancellationToken);
2927+
2928+
results.Length.AssertEqual(2);
2929+
}
2930+
28762931
#endregion
28772932
}
28782933

0 commit comments

Comments
 (0)