Skip to content

Commit 0205a46

Browse files
committed
Translator fixes: alias resolution for joins, GroupBy projections, ORDER BY, PG bool cast
- Parameter-rooted member alias in sub-queries - GroupBy projection inherits outer aliases for paged Count - Rebind ORDER BY to CTE alias when wrapping grouped query for paging - ConditionalExpression sub-query inherits outer alias so g.Key.X resolves to FROM alias - g.Key.X resolves to JOIN alias inside grouped projections with conditionals - Anonymous-projection member alias mapping for Join result selectors and Select bodies - Where after Join resolves anonymous-projection member to underlying alias - Member chain rooted at JOIN parameter uses parameter's registered alias - ORDER BY for projected (computed) alias emitted unqualified - ContainsVisitor sets sub-query alias before visiting inner expression - DISTINCT fallback ORDER BY uses first projected column; SelectMany alias pre-registered - Skip 'as bit' cast for PostgreSQL (native boolean type, no bit coercion)
1 parent 9b5a7db commit 0205a46

7 files changed

Lines changed: 724 additions & 13 deletions

File tree

Data.ORM/Sql/Expression/Context.cs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ class Context
99
{
1010
public (string origAlias, string modifiedAlias) CurrJoinAlias;
1111
public readonly List<(string tableAlias, Query query)> JoinParts = [];
12+
13+
/// <summary>
14+
/// Names of join inner aliases discovered by a pre-walk of the source
15+
/// chain. Used to resolve transparent-id chains during projection-time
16+
/// visits — before <see cref="JoinParts"/> itself is populated by the
17+
/// recursive source visit at the end of <see cref="ExpressionQueryTranslator.VisitSelectCall"/>.
18+
/// </summary>
19+
public readonly HashSet<string> PreknownJoinAliases = new(StringComparer.OrdinalIgnoreCase);
20+
21+
/// <summary>
22+
/// Records the SQL alias backing each member of an anonymous projection
23+
/// produced by a Join's result selector (e.g. <c>(a, b) =&gt; new { A=a, B=b }</c>).
24+
/// Lets a downstream <c>Where(p =&gt; p.B.Field)</c> resolve <c>p.B</c>
25+
/// through the underlying join alias instead of the main FROM alias.
26+
/// </summary>
27+
public readonly Dictionary<(Type AnonType, MemberInfo Member), string> AnonProjectionAliases = [];
1228
public readonly Query FromPart = new();
1329
public readonly List<Query> WhereParts = [];
1430
public readonly Query OrderByPart = new();
@@ -137,9 +153,17 @@ private void EmitExistsPrologue(Query query)
137153
if (!Exists)
138154
return;
139155

156+
// Open `cast(` only when the dialect actually wraps the boolean
157+
// expression in a cast. PostgreSQL represents booleans natively and
158+
// rejects `cast(<bool> as bit)`, so the prologue is a no-op there
159+
// and the epilogue stops short of emitting `as bit )`.
160+
query.AddAction((d, sb) =>
161+
{
162+
if (d.BooleanCastSqlType is not null)
163+
sb.Append("cast(");
164+
});
165+
140166
query
141-
.Cast()
142-
.OpenBracket()
143167
.NewLine()
144168
.Case()
145169
.NewLine()
@@ -158,7 +182,12 @@ private void EmitExistsEpilogue(Query query)
158182
.Then().AddAction((d, sb) => sb.Append(d.TrueLiteral))
159183
.NewLine()
160184
.Else().AddAction((d, sb) => sb.Append(d.FalseLiteral))
161-
.NewLine().End().As().Raw("bit").CloseBracket();
185+
.NewLine().End()
186+
.AddAction((d, sb) =>
187+
{
188+
if (d.BooleanCastSqlType is not null)
189+
sb.Append(" as ").Append(d.BooleanCastSqlType).Append(')');
190+
});
162191
}
163192

164193
private void EmitSelectClause(Query query)
@@ -387,6 +416,16 @@ private Query WrapInCtePassthrough(Query inner)
387416
.Table(tableRes, TableAlias = "p")
388417
.NewLine();
389418

419+
// ORDER BY collected from the LINQ tree still references inner table
420+
// aliases (e.g. [e].[Id]), but those aliases are now hidden inside
421+
// the CTE. Rebind every entry to the CTE outer alias so the final
422+
// query reads `order by [p].[Column]`.
423+
for (var i = 0; i < OrderBy.Count; i++)
424+
{
425+
var (_, name, asc) = OrderBy[i];
426+
OrderBy[i] = ("p", name, asc);
427+
}
428+
390429
return cte;
391430
}
392431

@@ -406,7 +445,13 @@ private void EmitOrderByAndPagination(Query query, Schema schema)
406445
{
407446
if (Skip is not null || Take is not null)
408447
{
409-
if (schema.Identity is not null)
448+
// SELECT DISTINCT requires every ORDER BY column to also be
449+
// in the SELECT list. A blind `[TableAlias].[Identity]`
450+
// fallback usually picks a column that isn't in the
451+
// projection — pick the first projected member name instead.
452+
if (Distinct && SelectColumns.Count > 0 && SelectColumns[0].Keys.FirstOrDefault() is { } firstKey)
453+
query.OrderBy().Column(firstKey.Name);
454+
else if (schema.Identity is not null)
410455
query.OrderBy().Column(TableAlias, schema.Identity.Name);
411456
else
412457
query.AddAction((d, sb) => d.AppendFallbackOrderBy(sb));
@@ -425,7 +470,16 @@ private void EmitOrderByAndPagination(Query query, Schema schema)
425470
else
426471
query.Comma();
427472

428-
if (alias is not null)
473+
// If the column matches a projected output (SELECT-list alias)
474+
// from a view processor or explicit Select, emit unqualified —
475+
// `[e].[MessageCount]` would fail because MessageCount is a
476+
// computed projection, not a real column on the underlying
477+
// table. SQL's ORDER BY allows referencing SELECT-list aliases
478+
// by bare name. Check every SelectColumns layer because
479+
// chained Select(...).Distinct() pushes the projection deeper.
480+
var isProjectedAlias = SelectColumns.Any(layer => layer.Keys.Any(k => k.Name == columnName));
481+
482+
if (alias is not null && !isProjectedAlias)
429483
query.Column(alias, columnName);
430484
else
431485
query.Column(columnName);

0 commit comments

Comments
 (0)