Skip to content

Commit cdfc7c1

Browse files
committed
Fix expression cloning when table changes in SelectExpression.VisitChildren
Fixes #32234
1 parent 32c6133 commit cdfc7c1

File tree

6 files changed

+156
-5
lines changed

6 files changed

+156
-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 false only if we're already being called from
4810+
// ColumnTableReferenceUpdater 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
@@ -4614,6 +4614,14 @@ await AssertTranslationFailed(
46144614
AssertSql();
46154615
}
46164616

4617+
public override async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
4618+
{
4619+
await AssertTranslationFailed(
4620+
() => base.Parameter_collection_Contains_with_projection_and_ordering(async));
4621+
4622+
AssertSql();
4623+
}
4624+
46174625
private void AssertSql(params string[] expected)
46184626
=> Fixture.TestSqlLoggerFactory.AssertBaseline(expected);
46194627

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5659,4 +5659,20 @@ public virtual Task Subquery_with_navigation_inside_inline_collection(bool async
56595659
=> AssertQuery(
56605660
async,
56615661
ss => ss.Set<Customer>().Where(c => new[] { 100, c.Orders.Count }.Sum() > 101));
5662+
5663+
[ConditionalTheory] // #32234
5664+
[MemberData(nameof(IsAsyncData))]
5665+
public virtual async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
5666+
{
5667+
var ids = new[] { 10248, 10249 };
5668+
5669+
await AssertQuery(
5670+
async,
5671+
ss => ss.Set<OrderDetail>()
5672+
.Where(e => ids.Contains(e.OrderID))
5673+
.GroupBy(e => e.Quantity)
5674+
.Select(g => new { g.Key, MaxTimestamp = g.Select(e => e.Order.OrderDate).Max() })
5675+
.OrderBy(x => x.MaxTimestamp)
5676+
.Select(x => x));
5677+
}
56625678
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7275,6 +7275,46 @@ SELECT COUNT(*)
72757275
FROM [Orders] AS [o]
72767276
WHERE [c].[CustomerID] = [o].[CustomerID]))) AS [v]([Value])) > 101
72777277
""");
7278+
7279+
public override async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
7280+
{
7281+
#if DEBUG
7282+
// GroupBy debug assert. Issue #26104.
7283+
Assert.StartsWith(
7284+
"Missing alias in the list",
7285+
(await Assert.ThrowsAsync<InvalidOperationException>(
7286+
() => base.Parameter_collection_Contains_with_projection_and_ordering(async))).Message);
7287+
#else
7288+
await base.Parameter_collection_Contains_with_projection_and_ordering(async);
7289+
7290+
AssertSql(
7291+
"""
7292+
@__ids_0='[10248,10249]' (Size = 4000)
7293+
7294+
SELECT [o].[Quantity] AS [Key], (
7295+
SELECT MAX([o3].[OrderDate])
7296+
FROM [Order Details] AS [o2]
7297+
INNER JOIN [Orders] AS [o3] ON [o2].[OrderID] = [o3].[OrderID]
7298+
WHERE [o2].[OrderID] IN (
7299+
SELECT [i1].[value]
7300+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i1]
7301+
) AND [o].[Quantity] = [o2].[Quantity]) AS [MaxTimestamp]
7302+
FROM [Order Details] AS [o]
7303+
WHERE [o].[OrderID] IN (
7304+
SELECT [i].[value]
7305+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
7306+
)
7307+
GROUP BY [o].[Quantity]
7308+
ORDER BY (
7309+
SELECT MAX([o3].[OrderDate])
7310+
FROM [Order Details] AS [o2]
7311+
INNER JOIN [Orders] AS [o3] ON [o2].[OrderID] = [o3].[OrderID]
7312+
WHERE [o2].[OrderID] IN (
7313+
SELECT [i0].[value]
7314+
FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i0]
7315+
) AND [o].[Quantity] = [o2].[Quantity])
7316+
""");
7317+
#endif
72787318
}
72797319

72807320
private void AssertSql(params string[] expected)

test/EFCore.Sqlite.FunctionalTests/Query/NorthwindMiscellaneousQuerySqliteTest.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,19 @@ public override async Task Correlated_collection_with_distinct_without_default_i
438438
public override Task Max_on_empty_sequence_throws(bool async)
439439
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Max_on_empty_sequence_throws(async));
440440

441+
public override async Task Parameter_collection_Contains_with_projection_and_ordering(bool async)
442+
{
443+
#if DEBUG
444+
// GroupBy debug assert. Issue #26104.
445+
Assert.StartsWith(
446+
"Missing alias in the list",
447+
(await Assert.ThrowsAsync<InvalidOperationException>(
448+
() => base.Parameter_collection_Contains_with_projection_and_ordering(async))).Message);
449+
#else
450+
await base.Parameter_collection_Contains_with_projection_and_ordering(async);
451+
#endif
452+
}
453+
441454
[ConditionalFact]
442455
public async Task Single_Predicate_Cancellation()
443456
=> await Assert.ThrowsAnyAsync<OperationCanceledException>(

0 commit comments

Comments
 (0)