Skip to content

Commit 2ac3090

Browse files
committed
Fix expression cloning when table changes in SelectExpression.VisitChildren
Fixes #32234
1 parent 74cc719 commit 2ac3090

File tree

5 files changed

+149
-5
lines changed

5 files changed

+149
-5
lines changed

src/EFCore.Relational/Query/SqlExpressions/SelectExpression.Helper.cs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,53 @@ public TableReferenceUpdatingExpressionVisitor(SelectExpression oldSelect, Selec
370370
}
371371
}
372372

373+
// Note: this is conceptually the same as ColumnExpressionReplacingExpressionVisitor; I duplicated it since this is for a patch,
374+
// and we want to limit the potential risk (note that this calls the special SelectExpression.VisitChildren() with updateColumns: false,
375+
// to avoid infinite recursion).
376+
private sealed class ColumnTableReferenceUpdater : ExpressionVisitor
377+
{
378+
private readonly SelectExpression _oldSelect;
379+
private readonly SelectExpression _newSelect;
380+
381+
public ColumnTableReferenceUpdater(SelectExpression oldSelect, SelectExpression newSelect)
382+
{
383+
_oldSelect = oldSelect;
384+
_newSelect = newSelect;
385+
}
386+
387+
[return: NotNullIfNotNull("expression")]
388+
public override Expression? Visit(Expression? expression)
389+
{
390+
if (expression is ConcreteColumnExpression columnExpression
391+
&& _oldSelect._tableReferences.Find(t => ReferenceEquals(t.Table, columnExpression.Table)) is TableReferenceExpression
392+
oldTableReference
393+
&& _newSelect._tableReferences.Find(t => t.Alias == columnExpression.TableAlias) is TableReferenceExpression
394+
newTableReference
395+
&& newTableReference != oldTableReference)
396+
{
397+
return new ConcreteColumnExpression(
398+
columnExpression.Name,
399+
newTableReference,
400+
columnExpression.Type,
401+
columnExpression.TypeMapping!,
402+
columnExpression.IsNullable);
403+
}
404+
405+
return base.Visit(expression);
406+
}
407+
408+
protected override Expression VisitExtension(Expression node)
409+
{
410+
if (node is SelectExpression select)
411+
{
412+
Check.DebugAssert(!select._mutable, "Visiting mutable select expression in ColumnTableReferenceUpdater");
413+
return select.VisitChildren(this, updateColumns: false);
414+
}
415+
416+
return base.VisitExtension(node);
417+
}
418+
}
419+
373420
private sealed class IdentifierComparer : IEqualityComparer<(ColumnExpression Column, ValueComparer Comparer)>
374421
{
375422
public bool Equals((ColumnExpression Column, ValueComparer Comparer) x, (ColumnExpression Column, ValueComparer Comparer) y)

src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4609,6 +4609,9 @@ private static string GenerateUniqueAlias(HashSet<string> usedAliases, string cu
46094609

46104610
/// <inheritdoc />
46114611
protected override Expression VisitChildren(ExpressionVisitor visitor)
4612+
=> VisitChildren(visitor, updateColumns: true);
4613+
4614+
private Expression VisitChildren(ExpressionVisitor visitor, bool updateColumns)
46124615
{
46134616
if (_mutable)
46144617
{
@@ -4797,14 +4800,38 @@ protected override Expression VisitChildren(ExpressionVisitor visitor)
47974800
newSelectExpression._childIdentifiers.AddRange(
47984801
childIdentifier.Zip(_childIdentifiers).Select(e => (e.First, e.Second.Comparer)));
47994802

4800-
// Remap tableReferences in new select expression
4801-
foreach (var tableReference in newTableReferences)
4803+
// We duplicated the SelectExpression, and must therefore also update all table reference expressions to point to it.
4804+
// If any tables have changed, we must duplicate the TableReferenceExpressions and replace all ColumnExpressions to use
4805+
// them; otherwise we end up two SelectExpressions sharing the same TableReferenceExpression instance, and if that's later
4806+
// mutated, both SelectExpressions are affected (this happened in AliasUniquifier, see #32234).
4807+
4808+
// Otherwise, if no tables have changed, we mutate the TableReferenceExpressions (this was the previous code, left it for
4809+
// a more low-risk fix). Note that updateColumns is true only if we're already being called from ColumnTableReferenceUpdater
4810+
// to replace the ColumnExpressions, in which case we avoid infinite recursion.
4811+
if (tablesChanged && updateColumns)
48024812
{
4803-
tableReference.UpdateTableReference(this, newSelectExpression);
4813+
for (var i = 0; i < newTableReferences.Count; i++)
4814+
{
4815+
newTableReferences[i] = new TableReferenceExpression(newSelectExpression, _tableReferences[i].Alias);
4816+
}
4817+
4818+
var columnTableReferenceUpdater = new ColumnTableReferenceUpdater(this, newSelectExpression);
4819+
newSelectExpression = (SelectExpression)columnTableReferenceUpdater.Visit(newSelectExpression);
48044820
}
4821+
else
4822+
{
4823+
// Remap tableReferences in new select expression
4824+
foreach (var tableReference in newTableReferences)
4825+
{
4826+
tableReference.UpdateTableReference(this, newSelectExpression);
4827+
}
48054828

4806-
var tableReferenceUpdatingExpressionVisitor = new TableReferenceUpdatingExpressionVisitor(this, newSelectExpression);
4807-
tableReferenceUpdatingExpressionVisitor.Visit(newSelectExpression);
4829+
// TODO: Why does need to be done? We've already updated all table references on the new select just above, and
4830+
// no ColumnExpression in the query is every supposed to reference a TableReferenceExpression that isn't in the
4831+
// select's list... The same thing is done in all other places where TableReferenceUpdatingExpressionVisitor is used.
4832+
var tableReferenceUpdatingExpressionVisitor = new TableReferenceUpdatingExpressionVisitor(this, newSelectExpression);
4833+
tableReferenceUpdatingExpressionVisitor.Visit(newSelectExpression);
4834+
}
48084835

48094836
return newSelectExpression;
48104837
}

test/EFCore.Cosmos.FunctionalTests/Query/NorthwindMiscellaneousQueryCosmosTest.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4606,6 +4606,14 @@ await AssertTranslationFailed(
46064606
AssertSql();
46074607
}
46084608

4609+
public override async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
4610+
{
4611+
await AssertTranslationFailed(
4612+
() => base.Parameter_collection_Contains_with_projection_and_ordering(async));
4613+
4614+
AssertSql();
4615+
}
4616+
46094617
private void AssertSql(params string[] expected)
46104618
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
46114619

test/EFCore.Specification.Tests/Query/NorthwindMiscellaneousQueryTestBase.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5652,4 +5652,28 @@ public virtual Task Collection_navigation_equal_to_null_for_subquery_using_Eleme
56525652
ss => ss.Set<Customer>().Where(c => c.Orders.OrderBy(o => o.OrderID).ElementAtOrDefault(prm).OrderDetails == null),
56535653
ss => ss.Set<Customer>().Where(c => c.Orders.OrderBy(o => o.OrderID).ElementAtOrDefault(prm) == null));
56545654
}
5655+
5656+
[ConditionalTheory] // #32234
5657+
[MemberData(nameof(IsAsyncData))]
5658+
public virtual async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
5659+
{
5660+
var ids = new[] { 10248, 10249 };
5661+
5662+
var query = (ISetSource ss) => ss.Set<OrderDetail>()
5663+
.Where(e => ids.Contains(e.OrderID))
5664+
.GroupBy(e => e.Quantity)
5665+
.Select(g => new { g.Key, MaxTimestamp = g.Select(e => e.Order.OrderDate).Max() })
5666+
.OrderBy(x => x.MaxTimestamp)
5667+
.Select(x => x);
5668+
5669+
#if DEBUG
5670+
// GroupBy debug assert. Issue #26104.
5671+
Assert.StartsWith(
5672+
"Missing alias in the list",
5673+
(await Assert.ThrowsAsync<InvalidOperationException>(
5674+
() => AssertQuery(async, query))).Message);
5675+
#else
5676+
await AssertQuery(async, query);
5677+
#endif
5678+
}
56555679
}

test/EFCore.SqlServer.FunctionalTests/Query/NorthwindMiscellaneousQuerySqlServerTest.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7260,6 +7260,44 @@ ORDER BY [o].[OrderID]
72607260
""");
72617261
}
72627262

7263+
public override async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
7264+
{
7265+
await base.Parameter_collection_Contains_with_projection_and_ordering(async);
7266+
7267+
#if DEBUG
7268+
// GroupBy debug assert. Issue #26104.
7269+
AssertSql();
7270+
#else
7271+
AssertSql(
7272+
"""
7273+
@__ids_0='[10248,10249]' (Size = 4000)
7274+
7275+
SELECT [o].[Quantity] AS [Key], (
7276+
SELECT MAX([o3].[OrderDate])
7277+
FROM [Order Details] AS [o2]
7278+
INNER JOIN [Orders] AS [o3] ON [o2].[OrderID] = [o3].[OrderID]
7279+
WHERE [o2].[OrderID] IN (
7280+
SELECT [i1].[value]
7281+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i1]
7282+
) AND [o].[Quantity] = [o2].[Quantity]) AS [MaxTimestamp]
7283+
FROM [Order Details] AS [o]
7284+
WHERE [o].[OrderID] IN (
7285+
SELECT [i].[value]
7286+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
7287+
)
7288+
GROUP BY [o].[Quantity]
7289+
ORDER BY (
7290+
SELECT MAX([o3].[OrderDate])
7291+
FROM [Order Details] AS [o2]
7292+
INNER JOIN [Orders] AS [o3] ON [o2].[OrderID] = [o3].[OrderID]
7293+
WHERE [o2].[OrderID] IN (
7294+
SELECT [i0].[value]
7295+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i0]
7296+
) AND [o].[Quantity] = [o2].[Quantity])
7297+
""");
7298+
#endif
7299+
}
7300+
72637301
private void AssertSql(params string[] expected)
72647302
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
72657303

0 commit comments

Comments
 (0)