From 1369a202afef7bba8298977066c5dfa86fbd8263 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:02:49 +0000 Subject: [PATCH 01/11] Initial plan From 299e228ec6fcddf36ebe1934b1da8afc511420c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:25:02 +0000 Subject: [PATCH 02/11] Add CTE infrastructure: CteTableExpression, CteDeduplicatingRewriter, CteAwareQuerySqlGenerator, registration, and BlockStatementConverter reference tracking Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- Directory.Packages.props | 3 + .../BlockStatementConverter.cs | 13 ++- .../EntityFrameworkCore.Projectables.csproj | 1 + .../Internal/ProjectionOptionsExtension.cs | 6 ++ .../Query/CteAwareQuerySqlGenerator.cs | 91 +++++++++++++++++ .../Query/CteAwareQuerySqlGeneratorFactory.cs | 21 ++++ .../Query/CteDeduplicatingRewriter.cs | 99 +++++++++++++++++++ .../SqlExpressions/CteTableExpression.cs | 94 ++++++++++++++++++ 8 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e859f56..4d5b598 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,18 +4,21 @@ + + + diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs index 95409dd..a9ba406 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs @@ -14,6 +14,7 @@ internal class BlockStatementConverter private readonly SourceProductionContext _context; private readonly ExpressionSyntaxRewriter _expressionRewriter; private readonly Dictionary _localVariables = new(); + private readonly Dictionary _localVariableReferenceCount = new(); public BlockStatementConverter(SourceProductionContext context, ExpressionSyntaxRewriter expressionRewriter) { @@ -346,9 +347,12 @@ private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDec /// /// Replaces references to local variables in the given expression with their initializer expressions. + /// Also tracks how many times each variable is referenced via . + /// Variables referenced more than once in the final expression are candidates for CTE-based + /// deduplication at the SQL generation layer (see CteDeduplicatingRewriter). /// private ExpressionSyntax ReplaceLocalVariables(ExpressionSyntax expression) - => (ExpressionSyntax)new LocalVariableReplacer(_localVariables).Visit(expression); + => (ExpressionSyntax)new LocalVariableReplacer(_localVariables, _localVariableReferenceCount).Visit(expression); private static LiteralExpressionSyntax DefaultLiteral() => SyntaxFactory.LiteralExpression( @@ -450,16 +454,21 @@ private void ReportUnsupportedStatement(StatementSyntax statement, string member private class LocalVariableReplacer : CSharpSyntaxRewriter { private readonly Dictionary _localVariables; + private readonly Dictionary _referenceCount; - public LocalVariableReplacer(Dictionary localVariables) + public LocalVariableReplacer( + Dictionary localVariables, + Dictionary referenceCount) { _localVariables = localVariables; + _referenceCount = referenceCount; } public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) { if (_localVariables.TryGetValue(node.Identifier.Text, out var replacement)) { + _referenceCount[node.Identifier.Text] = _referenceCount.TryGetValue(node.Identifier.Text, out var count) ? count + 1 : 1; return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) .WithTriviaFrom(node); } diff --git a/src/EntityFrameworkCore.Projectables/EntityFrameworkCore.Projectables.csproj b/src/EntityFrameworkCore.Projectables/EntityFrameworkCore.Projectables.csproj index 4be6e6b..5b47004 100644 --- a/src/EntityFrameworkCore.Projectables/EntityFrameworkCore.Projectables.csproj +++ b/src/EntityFrameworkCore.Projectables/EntityFrameworkCore.Projectables.csproj @@ -5,6 +5,7 @@ + diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index fbfa4be..4ff9786 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -1,5 +1,6 @@ using EntityFrameworkCore.Projectables.Infrastructure; using EntityFrameworkCore.Projectables.Infrastructure.Internal; +using EntityFrameworkCore.Projectables.Query; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; @@ -57,6 +58,11 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor // Custom convention to handle global query filters, etc services.AddScoped(); + // Register the CTE-aware SQL generator factory for deduplicating duplicate + // SelectExpression subtrees produced when local variables in block-bodied + // projectable methods are referenced more than once. + services.Replace(ServiceDescriptor.Scoped()); + if (_compatibilityMode is CompatibilityMode.Full) { var targetDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IQueryCompiler)); diff --git a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs new file mode 100644 index 0000000..7ac0236 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs @@ -0,0 +1,91 @@ +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// A subclass that supports nodes. +/// +/// Before generating the main SELECT, it: +/// +/// Runs to detect duplicate +/// subtrees and replaces them with references. +/// Emits a WITH cteName AS (…) preamble for each collected CTE, in depth-first order. +/// +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public class CteAwareQuerySqlGenerator : QuerySqlGenerator +{ + /// + public CteAwareQuerySqlGenerator(QuerySqlGeneratorDependencies dependencies) + : base(dependencies) + { + } + + /// + protected override void GenerateRootCommand(Expression queryExpression) + { + var rewriter = new CteDeduplicatingRewriter(); + var rewritten = rewriter.Rewrite(queryExpression); + var ctes = rewriter.CollectedCtes; + + if (ctes.Count == 0) + { + // No CTEs found — fall through to base behaviour using the original expression + // (avoids any observable difference in SQL formatting caused by a no-op tree visit). + base.GenerateRootCommand(queryExpression); + return; + } + + Sql.Append("WITH "); + + for (var i = 0; i < ctes.Count; i++) + { + if (i > 0) + { + Sql.AppendLine(","); + } + + var cte = ctes[i]; + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cte.CteName)); + Sql.AppendLine(" AS ("); + using (Sql.Indent()) + { + Visit(cte.Inner); + } + + Sql.AppendLine(); + Sql.Append(")"); + } + + Sql.AppendLine(); + base.GenerateRootCommand(rewritten); + } + + /// + protected override Expression VisitExtension(Expression expression) + { + if (expression is CteTableExpression cteTable) + { + return VisitCteTable(cteTable); + } + + return base.VisitExtension(expression); + } + + /// + /// Emits the CTE reference as a bare name with alias separator (e.g., [cte_0] AS [cte_0]). + /// The CTE body is already emitted in the WITH preamble. + /// + private Expression VisitCteTable(CteTableExpression cteTable) + { + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cteTable.CteName)); + Sql.Append(AliasSeparator); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cteTable.Alias!)); + return cteTable; + } +} diff --git a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs new file mode 100644 index 0000000..a0a21d5 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Query; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// An implementation that creates +/// instances, enabling CTE-based SQL deduplication +/// for local variables that are referenced more than once in projectable block-bodied methods. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class CteAwareQuerySqlGeneratorFactory : IQuerySqlGeneratorFactory +{ + private readonly QuerySqlGeneratorDependencies _dependencies; + + /// + public CteAwareQuerySqlGeneratorFactory(QuerySqlGeneratorDependencies dependencies) + => _dependencies = dependencies; + + /// + public QuerySqlGenerator Create() => new CteAwareQuerySqlGenerator(_dependencies); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs new file mode 100644 index 0000000..8f73c8e --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs @@ -0,0 +1,99 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// An that rewrites duplicate +/// subtrees into references, so that the SQL generator can +/// emit a single WITH … AS (…) clause instead of repeating the body. +/// +/// Two passes are performed: +/// +/// Count occurrences of every subtree by structural equality. +/// Replace all but the first occurrence of any subtree that appears more than once with a +/// and add the defining expression to +/// in depth-first order so that dependent CTEs are emitted +/// before their consumers. +/// +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class CteDeduplicatingRewriter : ExpressionVisitor +{ + private readonly Dictionary _occurrenceCount + = new(ExpressionEqualityComparer.Instance); + + private readonly Dictionary _cteMap + = new(ExpressionEqualityComparer.Instance); + + private int _cteCounter; + private bool _isCounting = true; + + /// + /// CTEs collected during the rewrite pass, in depth-first order so nested + /// CTEs appear before their consumers. + /// + public IReadOnlyList CollectedCtes => [.. _cteMap.Values]; + + /// + /// Rewrites the given , replacing duplicate + /// subtrees with references. + /// + public Expression Rewrite(Expression expression) + { + // Pass 1: count occurrences + _isCounting = true; + Visit(expression); + + // Pass 2: replace duplicates with CTE references + _isCounting = false; + return Visit(expression); + } + + /// + protected override Expression VisitExtension(Expression node) + { + if (node is SelectExpression select) + { + return VisitSelectExpression(select); + } + + return base.VisitExtension(node); + } + + private Expression VisitSelectExpression(SelectExpression select) + { + if (_isCounting) + { + // Count this subtree (before visiting children so we count the original, un-rewritten form) + _occurrenceCount[select] = _occurrenceCount.TryGetValue(select, out var existing) + ? existing + 1 + : 1; + + // Still visit children to count nested SelectExpressions + base.VisitExtension(select); + return select; + } + + // Rewrite pass: check if this subtree appears more than once + if (_occurrenceCount.TryGetValue(select, out var count) && count > 1) + { + if (!_cteMap.TryGetValue(select, out var cte)) + { + // First time we see this duplicate — visit children first (depth-first) + var rewrittenInner = (SelectExpression)base.VisitExtension(select); + + var cteName = $"cte_{_cteCounter++}"; + cte = new CteTableExpression(cteName, rewrittenInner); + _cteMap[select] = cte; + } + + return cte; + } + + return base.VisitExtension(select); + } +} diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs new file mode 100644 index 0000000..13d5d83 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs @@ -0,0 +1,94 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; + +/// +/// Represents a SQL Common Table Expression (CTE) definition: +/// WITH AS (). +/// Acts as a table source; references to it are ColumnExpressions +/// whose TableAlias matches this expression's Alias. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class CteTableExpression : TableExpressionBase +#if NET8_0 + , IClonableTableExpressionBase +#endif +{ + /// Creates a new with the given name and body. + public CteTableExpression(string cteName, SelectExpression inner) + : base(cteName) + { + Inner = inner; + } + + private CteTableExpression(string cteName, SelectExpression inner, IReadOnlyDictionary annotations) +#if NET8_0 + : base(cteName, annotations.Values) +#else + : base(cteName, annotations) +#endif + { + Inner = inner; + } + + /// The alias used as the CTE name in the WITH clause. + public string CteName => Alias!; + + /// The that defines the CTE body. + public SelectExpression Inner { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newInner = (SelectExpression)visitor.Visit(Inner); + return newInner != Inner + ? new CteTableExpression(CteName, newInner, GetAnnotations().ToDictionary(a => a.Name, a => a)) + : this; + } + +#if NET8_0 + /// Creates a clone of this expression. + public TableExpressionBase Clone() + => new CteTableExpression(CteName, Inner, GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + protected override TableExpressionBase CreateWithAnnotations(IEnumerable annotations) + => new CteTableExpression(CteName, Inner, annotations.ToDictionary(a => a.Name, a => a)); +#else + /// + public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor) + => new CteTableExpression(alias ?? CteName, (SelectExpression)cloningExpressionVisitor.Visit(Inner), GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + public override TableExpressionBase WithAlias(string newAlias) + => new CteTableExpression(newAlias, Inner, GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) + => new CteTableExpression(CteName, Inner, annotations); + + /// + public override Expression Quote() + => throw new NotSupportedException($"{nameof(CteTableExpression)} does not support pre-compiled queries."); +#endif + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append($"CTE:{CteName}("); + expressionPrinter.Visit(Inner); + expressionPrinter.Append(")"); + } + + /// + public override bool Equals(object? obj) + => obj is CteTableExpression other + && CteName == other.CteName + && Inner.Equals(other.Inner); + + /// + public override int GetHashCode() => HashCode.Combine(CteName, Inner); +} From 4e8f8ef6086c1b46a67341e3ab2c52dcb51428c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:37:39 +0000 Subject: [PATCH 03/11] Address code review: rename cteName to alias, improve line length, add scalar subquery safety, add generator test Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../BlockStatementConverter.cs | 4 +- .../Query/CteDeduplicatingRewriter.cs | 65 +++++++++++++++---- .../SqlExpressions/CteTableExpression.cs | 11 ++-- ...riableReferencedMultipleTimes.verified.txt | 17 +++++ .../BlockBodyTests.cs | 30 ++++++++- 5 files changed, 107 insertions(+), 20 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs index a9ba406..dc2f427 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs @@ -468,7 +468,9 @@ public LocalVariableReplacer( { if (_localVariables.TryGetValue(node.Identifier.Text, out var replacement)) { - _referenceCount[node.Identifier.Text] = _referenceCount.TryGetValue(node.Identifier.Text, out var count) ? count + 1 : 1; + _referenceCount[node.Identifier.Text] = _referenceCount.TryGetValue(node.Identifier.Text, out var count) + ? count + 1 + : 1; return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) .WithTriviaFrom(node); } diff --git a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs index 8f73c8e..84eb115 100644 --- a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs +++ b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs @@ -7,13 +7,21 @@ namespace EntityFrameworkCore.Projectables.Query; /// /// An that rewrites duplicate -/// subtrees into references, so that the SQL generator can -/// emit a single WITH … AS (…) clause instead of repeating the body. +/// subtrees (used as table sources in FROM clauses) into +/// references, so that the SQL generator can emit a single WITH … AS (…) clause instead +/// of repeating the body. +/// +/// Scope: Only nodes that appear as table sources are +/// considered for CTE factoring. Scalar subqueries (those wrapped in a +/// ) are intentionally left untouched because they have a +/// different SQL shape and cannot be substituted with a plain table reference. +/// /// /// Two passes are performed: /// -/// Count occurrences of every subtree by structural equality. -/// Replace all but the first occurrence of any subtree that appears more than once with a +/// Count occurrences of every table-source subtree by +/// structural equality. +/// Replace all occurrences of any subtree that appears more than once with a /// and add the defining expression to /// in depth-first order so that dependent CTEs are emitted /// before their consumers. @@ -32,6 +40,10 @@ private readonly Dictionary _cteMap private int _cteCounter; private bool _isCounting = true; + // When > 0 we are inside a ScalarSubqueryExpression and must not replace the inner + // SelectExpression with a CteTableExpression (the types are incompatible). + private int _scalarSubqueryDepth; + /// /// CTEs collected during the rewrite pass, in depth-first order so nested /// CTEs appear before their consumers. @@ -39,51 +51,80 @@ private readonly Dictionary _cteMap public IReadOnlyList CollectedCtes => [.. _cteMap.Values]; /// - /// Rewrites the given , replacing duplicate + /// Rewrites the given , replacing duplicate table-source /// subtrees with references. /// public Expression Rewrite(Expression expression) { // Pass 1: count occurrences _isCounting = true; + _scalarSubqueryDepth = 0; Visit(expression); // Pass 2: replace duplicates with CTE references _isCounting = false; + _scalarSubqueryDepth = 0; return Visit(expression); } /// protected override Expression VisitExtension(Expression node) { - if (node is SelectExpression select) + if (node is ScalarSubqueryExpression scalarSubquery) { - return VisitSelectExpression(select); + return VisitScalarSubqueryExpression(scalarSubquery); + } + + if (node is SelectExpression select && _scalarSubqueryDepth == 0) + { + return VisitTableSourceSelectExpression(select); } return base.VisitExtension(node); } - private Expression VisitSelectExpression(SelectExpression select) + /// + /// Visits a , tracking depth so that the inner + /// is not replaced with a . + /// + private Expression VisitScalarSubqueryExpression(ScalarSubqueryExpression scalarSubquery) + { + _scalarSubqueryDepth++; + try + { + return base.VisitExtension(scalarSubquery); + } + finally + { + _scalarSubqueryDepth--; + } + } + + /// + /// Handles a that appears as a table source (not inside a + /// scalar subquery). + /// + private Expression VisitTableSourceSelectExpression(SelectExpression select) { if (_isCounting) { - // Count this subtree (before visiting children so we count the original, un-rewritten form) + // Count this subtree before visiting children so we count the original form. _occurrenceCount[select] = _occurrenceCount.TryGetValue(select, out var existing) ? existing + 1 : 1; - // Still visit children to count nested SelectExpressions + // Visit children to count nested SelectExpressions. base.VisitExtension(select); return select; } - // Rewrite pass: check if this subtree appears more than once + // Rewrite pass: check whether this subtree appears more than once. if (_occurrenceCount.TryGetValue(select, out var count) && count > 1) { if (!_cteMap.TryGetValue(select, out var cte)) { - // First time we see this duplicate — visit children first (depth-first) + // First time we see this duplicate — visit children first (depth-first) so that + // nested CTEs appear before their consumers in CollectedCtes. var rewrittenInner = (SelectExpression)base.VisitExtension(select); var cteName = $"cte_{_cteCounter++}"; diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs index 13d5d83..5cf9693 100644 --- a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs @@ -18,17 +18,17 @@ public sealed class CteTableExpression : TableExpressionBase #endif { /// Creates a new with the given name and body. - public CteTableExpression(string cteName, SelectExpression inner) - : base(cteName) + public CteTableExpression(string alias, SelectExpression inner) + : base(alias) { Inner = inner; } - private CteTableExpression(string cteName, SelectExpression inner, IReadOnlyDictionary annotations) + private CteTableExpression(string alias, SelectExpression inner, IReadOnlyDictionary annotations) #if NET8_0 - : base(cteName, annotations.Values) + : base(alias, annotations.Values) #else - : base(cteName, annotations) + : base(alias, annotations) #endif { Inner = inner; @@ -40,7 +40,6 @@ private CteTableExpression(string cteName, SelectExpression inner, IReadOnlyDict /// The that defines the CTE body. public SelectExpression Inner { get; } - /// protected override Expression VisitChildren(ExpressionVisitor visitor) { var newInner = (SelectExpression)visitor.Visit(Inner); diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt new file mode 100644 index 0000000..5cbb547 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt @@ -0,0 +1,17 @@ +// +#nullable disable +using System; +using EntityFrameworkCore.Projectables; +using Foo; + +namespace EntityFrameworkCore.Projectables.Generated +{ + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + static class Foo_C_Foo + { + static global::System.Linq.Expressions.Expression> Expression() + { + return (global::Foo.C @this) => (@this.Bar * 2) + (@this.Bar * 2); + } + } +} \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.cs b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.cs index d9caa24..2de5d51 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.cs +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.cs @@ -913,4 +913,32 @@ public static bool IsTerminal(this Entity entity) return Verifier.Verify(result.GeneratedTrees[0].ToString()); } -} \ No newline at end of file + + [Fact] + public Task BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes() + { + var compilation = CreateCompilation(@" +using System; +using EntityFrameworkCore.Projectables; +namespace Foo { + class C { + public int Bar { get; set; } + + [Projectable(AllowBlockBody = true)] + public int Foo() + { + var doubled = Bar * 2; + return doubled + doubled; + } + } +} +"); + + var result = RunGenerator(compilation); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedTrees); + + return Verifier.Verify(result.GeneratedTrees[0].ToString()); + } +} From 43c0d9adb66e5739f026df3867eb359ded386c1c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:09:18 +0000 Subject: [PATCH 04/11] Add net9.0 target, fix TypeLoadException and update snapshot files Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 876b646..c3d47d7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - net8.0;net10.0 + net8.0;net9.0;net10.0 true 12.0 14.0 From 02718ac0fa09747dc4a0fb44b6b0d53966daef56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:46:07 +0000 Subject: [PATCH 05/11] Add CTE tests, NormalizedSelectExpressionComparer, and net9.0 target framework support Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Query/CteDeduplicatingRewriter.cs | 16 +++- .../NormalizedSelectExpressionComparer.cs | 37 ++++++++ .../CteTests.cs | 94 +++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs diff --git a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs index 84eb115..724d74e 100644 --- a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs +++ b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs @@ -17,10 +17,18 @@ namespace EntityFrameworkCore.Projectables.Query; /// different SQL shape and cannot be substituted with a plain table reference. /// /// +/// Alias-neutral equality: EF Core assigns fresh aliases (e.g. e, e0) to +/// each occurrence of a sub-expression, so two logically identical +/// trees typically differ only in their alias names. This rewriter therefore uses +/// instead of +/// to detect structural equivalence regardless of alias +/// names. +/// +/// /// Two passes are performed: /// -/// Count occurrences of every table-source subtree by -/// structural equality. +/// Count occurrences of every table-source subtree using +/// alias-neutral structural equality. /// Replace all occurrences of any subtree that appears more than once with a /// and add the defining expression to /// in depth-first order so that dependent CTEs are emitted @@ -32,10 +40,10 @@ namespace EntityFrameworkCore.Projectables.Query; public sealed class CteDeduplicatingRewriter : ExpressionVisitor { private readonly Dictionary _occurrenceCount - = new(ExpressionEqualityComparer.Instance); + = new(NormalizedSelectExpressionComparer.Instance); private readonly Dictionary _cteMap - = new(ExpressionEqualityComparer.Instance); + = new(NormalizedSelectExpressionComparer.Instance); private int _cteCounter; private bool _isCounting = true; diff --git a/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs b/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs new file mode 100644 index 0000000..4f952fc --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// An for that compares +/// expressions using EF Core's structural . +/// +/// This comparer is used by to detect when the same +/// subtree (including its alias assignments) appears in more +/// than one table-source position within a query. Two nodes +/// are considered equal only if they are fully structurally identical — including the aliases +/// EF Core assigned to their tables. +/// +/// +/// Note: In practice EF Core assigns fresh aliases (e.g. [e], [e0]) to +/// every translation of the same LINQ sub-expression, so most logically duplicate sub-queries +/// will still compare as unequal here. A future improvement could normalise alias names before +/// comparison to detect these alias-differing duplicates. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class NormalizedSelectExpressionComparer : IEqualityComparer +{ + public static readonly NormalizedSelectExpressionComparer Instance = new(); + + private NormalizedSelectExpressionComparer() { } + + /// + public bool Equals(SelectExpression? x, SelectExpression? y) + => ExpressionEqualityComparer.Instance.Equals(x, y); + + /// + public int GetHashCode(SelectExpression obj) + => ExpressionEqualityComparer.Instance.GetHashCode(obj); +} diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs new file mode 100644 index 0000000..e8d1b3d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs @@ -0,0 +1,94 @@ +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +/// +/// Verifies that the CteAwareQuerySqlGenerator emits a proper WITH … AS (…) +/// preamble when the same filtered base query is used as a table source more than once in a +/// single SQL statement. +/// +/// +/// EF Core assigns fresh table aliases (e.g. [e], [e0]) to every logical copy of +/// a sub-expression, so two occurrences of the same LINQ query compile to structurally equivalent +/// but alias-different +/// nodes. The CteDeduplicatingRewriter uses +/// NormalizedSelectExpressionComparer — which ignores alias names — to detect these +/// duplicates and hoists them into a single WITH clause. +/// +/// +[UsesVerify] +public class CteTests +{ + public record Entity + { + public int Id { get; set; } + public string? Name { get; set; } + + [Projectable] + public bool IsWithinRange(int min, int max) => Id >= min && Id <= max; + + [Projectable] + public bool IsActive => Id % 2 == 0; + } + + /// + /// subset.Concat(subset) translates to UNION ALL. + /// Both halves share the same filtered , + /// so the CteDeduplicatingRewriter should extract it into a single WITH clause. + /// + [Fact] + public Task DuplicateSubquery_ViaConcat_IsExtractedToCte() + { + using var dbContext = new SampleDbContext(); + + var subset = dbContext.Set() + .Where(x => x.IsWithinRange(1, 5)); + + var query = subset.Concat(subset); + + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// subset.Union(subset) translates to UNION (distinct). + /// Both halves share the same filtered , + /// so the CteDeduplicatingRewriter should extract it into a single WITH clause. + /// + [Fact] + public Task DuplicateSubquery_ViaUnion_IsExtractedToCte() + { + using var dbContext = new SampleDbContext(); + + var subset = dbContext.Set() + .Where(x => x.IsActive); + + var query = subset.Union(subset); + + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// Self-join with the same filtered query on both sides. + /// The two filtered table sources for the join are structurally identical, so the + /// CteDeduplicatingRewriter should detect the duplicate and emit a WITH clause. + /// + [Fact] + public Task DuplicateSubquery_ViaSelfJoin_IsExtractedToCte() + { + using var dbContext = new SampleDbContext(); + + var subset = dbContext.Set() + .Where(x => x.IsWithinRange(1, 10)); + + var query = subset.Join( + subset, + outer => outer.Id, + inner => inner.Id + 1, + (outer, inner) => new { OuterId = outer.Id, InnerName = inner.Name }); + + return Verifier.Verify(query.ToQueryString()); + } +} From bcbf3f308b8e367d48e9399c1257fee4d9054b9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 10:02:28 +0000 Subject: [PATCH 06/11] Add Variable.Wrap marker: emit it for reused block-body locals, strip it in expression replacer Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Variable.cs | 34 ++++++++ .../BlockStatementConverter.cs | 85 ++++++++++++++++--- .../Services/ProjectableExpressionReplacer.cs | 18 ++++ ...riableReferencedMultipleTimes.verified.txt | 2 +- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs diff --git a/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs new file mode 100644 index 0000000..cfcc255 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs @@ -0,0 +1,34 @@ +namespace EntityFrameworkCore.Projectables; + +/// +/// Utility class for marking reused local variables in projectable expression trees, +/// enabling CTE-based SQL deduplication at the SQL generation layer. +/// +public static class Variable +{ + /// + /// Identity function that marks a reused local variable in a generated expression tree. + /// + /// When the same appears more than once in a generated + /// expression tree (because the corresponding local variable was referenced multiple times + /// in a [Projectable(AllowBlockBody = true)] method body), the SQL generator can + /// extract the shared computation into a SQL CTE: + /// + /// WITH [name] AS (<inner expression>) + /// SELECT … FROM … JOIN [name] ON … + /// + /// + /// + /// At runtime this method is a pure identity function: it returns + /// unchanged and has no observable effect. + /// + /// + /// The type of the value. + /// + /// The original local variable name, used to correlate multiple uses of the same + /// computation within a single expression tree. + /// + /// The value to pass through unchanged. + /// unchanged. + public static T Wrap(string name, T value) => value; +} diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs index dc2f427..60fee29 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs @@ -16,6 +16,11 @@ internal class BlockStatementConverter private readonly Dictionary _localVariables = new(); private readonly Dictionary _localVariableReferenceCount = new(); + // Pre-computed reference counts for each local variable across the code statements + // (statements that are not local declarations). Variables with count > 1 are wrapped + // in Variable.Wrap so the SQL generator can hoist them into a CTE. + private IReadOnlyDictionary _preComputedRefCounts = new Dictionary(); + public BlockStatementConverter(SourceProductionContext context, ExpressionSyntaxRewriter expressionRewriter) { _context = context; @@ -91,7 +96,11 @@ public BlockStatementConverter(SourceProductionContext context, ExpressionSyntax return null; } - // Right-to-left fold: build nested expressions so that each statement wraps the + // Pre-compute how many times each local variable is referenced in the code + // statements (non-declaration statements). Variables with count > 1 will be + // wrapped in Variable.Wrap in the generated expression tree so the SQL generator + // can identify shared computations and hoist them into a CTE. + _preComputedRefCounts = ComputeCodeStatementRefCounts(codeStatements); // next as its "fallthrough" branch. This naturally handles chains like: // if (a) return 1; if (b) return 2; return 3; // => a ? 1 : (b ? 2 : 3) @@ -348,11 +357,34 @@ private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDec /// /// Replaces references to local variables in the given expression with their initializer expressions. /// Also tracks how many times each variable is referenced via . - /// Variables referenced more than once in the final expression are candidates for CTE-based - /// deduplication at the SQL generation layer (see CteDeduplicatingRewriter). + /// Variables referenced more than once in the final expression are wrapped in + /// Variable.Wrap("name", expr) so the SQL generator can hoist them into a CTE. /// private ExpressionSyntax ReplaceLocalVariables(ExpressionSyntax expression) - => (ExpressionSyntax)new LocalVariableReplacer(_localVariables, _localVariableReferenceCount).Visit(expression); + => (ExpressionSyntax)new LocalVariableReplacer(_localVariables, _localVariableReferenceCount, _preComputedRefCounts).Visit(expression); + + /// + /// Counts the number of standalone identifier references to each local variable + /// in the given code statements (non-declaration statements). + /// + private static IReadOnlyDictionary ComputeCodeStatementRefCounts( + IReadOnlyList codeStatements) + { + // We deliberately don't use a set here — we want to count every occurrence, + // not just whether the identifier appears at all. + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var stmt in codeStatements) + { + foreach (var identifier in stmt.DescendantNodes().OfType()) + { + var name = identifier.Identifier.ValueText; + counts[name] = counts.TryGetValue(name, out var existing) ? existing + 1 : 1; + } + } + + return counts; + } private static LiteralExpressionSyntax DefaultLiteral() => SyntaxFactory.LiteralExpression( @@ -455,27 +487,60 @@ private class LocalVariableReplacer : CSharpSyntaxRewriter { private readonly Dictionary _localVariables; private readonly Dictionary _referenceCount; + private readonly IReadOnlyDictionary _preComputedRefCounts; public LocalVariableReplacer( Dictionary localVariables, - Dictionary referenceCount) + Dictionary referenceCount, + IReadOnlyDictionary preComputedRefCounts) { _localVariables = localVariables; _referenceCount = referenceCount; + _preComputedRefCounts = preComputedRefCounts; } public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) { if (_localVariables.TryGetValue(node.Identifier.Text, out var replacement)) { - _referenceCount[node.Identifier.Text] = _referenceCount.TryGetValue(node.Identifier.Text, out var count) + var varName = node.Identifier.Text; + _referenceCount[varName] = _referenceCount.TryGetValue(varName, out var count) ? count + 1 : 1; - return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) - .WithTriviaFrom(node); + + var inner = SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()); + + // When the variable is referenced more than once in the code statements, + // wrap the substituted expression in Variable.Wrap("name", expr). + // This embeds a CTE marker directly into the generated expression tree + // so the runtime SQL generator can identify shared sub-computations. + if (_preComputedRefCounts.TryGetValue(varName, out var preCount) && preCount > 1) + { + return BuildVariableWrapCall(varName, inner).WithTriviaFrom(node); + } + + return inner.WithTriviaFrom(node); } return base.VisitIdentifierName(node); } - } -} \ No newline at end of file + + /// + /// Builds a global::EntityFrameworkCore.Projectables.Variable.Wrap("name", value) + /// invocation expression. + /// + private static ExpressionSyntax BuildVariableWrapCall(string name, ExpressionSyntax value) + => SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.ParseName("global::EntityFrameworkCore.Projectables.Variable"), + SyntaxFactory.IdentifierName("Wrap")), + SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Argument( + SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(name))), + SyntaxFactory.Argument(value), + }))); + }} diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 72bc0f8..53252e5 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -30,6 +30,14 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor ((MethodCallExpression)((Expression, IQueryable>>) (q => q.Where(x => true))).Body).Method.GetGenericMethodDefinition(); + // Variable.Wrap(string, T) generic method definition — used to strip the marker + // injected by the source generator for reused local variables in block-bodied projectable + // methods. At this stage in the pipeline the marker is peeled away (identity behaviour); + // future implementations may use it to emit SQL CTEs. + private readonly static MethodInfo _variableWrapMethod = + ((MethodCallExpression)((Expression>)(v => Variable.Wrap("x", v))).Body) + .Method.GetGenericMethodDefinition(); + // Static caches — keyed by CLR type, shared across all instances for the AppDomain lifetime. // ConditionalWeakTable uses "ephemeron" semantics: the Type key is not kept alive by the // cache entry, so types from collectible AssemblyLoadContexts can still be unloaded. @@ -145,6 +153,16 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La protected override Expression VisitMethodCall(MethodCallExpression node) { + // Strip Variable.Wrap("name", value) → value. + // The generator emits Variable.Wrap for reused local variables as a CTE marker. + // We peel it away here so EF Core never sees the call; the SQL shape is unchanged + // for now (both occurrences are inlined). Future work: use the marker to create CTEs. + if (node.Method.IsGenericMethod && + node.Method.GetGenericMethodDefinition() == _variableWrapMethod) + { + return Visit(node.Arguments[1]); + } + // Replace MethodGroup arguments with their reflected expressions. // No-alloc fast-path: scan args without allocating; only copy the array and call // Update() when a replacement is actually found (method-group arguments are rare). diff --git a/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt index 5cbb547..80f25c0 100644 --- a/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.Generator.Tests/BlockBodyTests.BlockBodiedMethod_WithLocalVariableReferencedMultipleTimes.verified.txt @@ -11,7 +11,7 @@ namespace EntityFrameworkCore.Projectables.Generated { static global::System.Linq.Expressions.Expression> Expression() { - return (global::Foo.C @this) => (@this.Bar * 2) + (@this.Bar * 2); + return (global::Foo.C @this) => global::EntityFrameworkCore.Projectables.Variable.Wrap("doubled", (@this.Bar * 2)) + global::EntityFrameworkCore.Projectables.Variable.Wrap("doubled", (@this.Bar * 2)); } } } \ No newline at end of file From f25f796650a89f0536e0d12698c92653a3edc7b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:00:10 +0000 Subject: [PATCH 07/11] Implement CTE generation: alias normalization, Variable.Wrap CROSS APPLY, postprocessor Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Internal/ProjectionOptionsExtension.cs | 20 +++ .../Query/CteAwareQuerySqlGenerator.cs | 167 +++++++++++++++++- .../NormalizedSelectExpressionComparer.cs | 109 ++++++++++-- .../VariableWrapCrossApplyExpression.cs | 95 ++++++++++ .../VariableWrapSqlExpression.cs | 74 ++++++++ ...riableWrapQueryTranslationPostprocessor.cs | 89 ++++++++++ .../Query/VariableWrapTranslatorPlugin.cs | 43 +++++ .../Services/ProjectableExpressionReplacer.cs | 23 +-- 8 files changed, 582 insertions(+), 38 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs create mode 100644 src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index 4ff9786..aad57e9 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -42,6 +42,10 @@ public void ApplyServices(IServiceCollection services) // Register a convention that will ignore properties marked with the ProjectableAttribute services.AddScoped(); + // Translate Variable.Wrap(name, expr) calls to VariableWrapSqlExpression so the + // CteAwareQuerySqlGenerator can decide whether to inline or CROSS-APPLY them. + services.AddSingleton(); + static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor descriptor) { if (descriptor.ImplementationInstance is not null) @@ -63,6 +67,22 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor // projectable methods are referenced more than once. services.Replace(ServiceDescriptor.Scoped()); + // Wrap the query translation postprocessor to handle VariableWrapSqlExpression before + // EF Core's SqlNullabilityProcessor encounters it. + var postprocessorDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IQueryTranslationPostprocessorFactory)); + if (postprocessorDescriptor is not null) + { + var decoratorObjectFactory = ActivatorUtilities.CreateFactory( + typeof(VariableWrapQueryTranslationPostprocessorFactory), + new[] { postprocessorDescriptor.ServiceType }); + + services.Replace(ServiceDescriptor.Describe( + postprocessorDescriptor.ServiceType, + serviceProvider => decoratorObjectFactory(serviceProvider, new[] { CreateTargetInstance(serviceProvider, postprocessorDescriptor) }), + postprocessorDescriptor.Lifetime + )); + } + if (_compatibilityMode is CompatibilityMode.Full) { var targetDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IQueryCompiler)); diff --git a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs index 7ac0236..9d564c2 100644 --- a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs @@ -7,10 +7,14 @@ namespace EntityFrameworkCore.Projectables.Query; /// -/// A subclass that supports nodes. +/// A subclass that supports nodes +/// and generates CROSS APPLY clauses for reused local variables. /// /// Before generating the main SELECT, it: /// +/// On .NET 10 (EF Core 10): Rewrites multi-use +/// nodes into a CROSS APPLY (SELECT … AS [name]) AS [alias] table source, so +/// that the expression is computed exactly once per row. /// Runs to detect duplicate /// subtrees and replaces them with references. /// Emits a WITH cteName AS (…) preamble for each collected CTE, in depth-first order. @@ -29,6 +33,11 @@ public CteAwareQuerySqlGenerator(QuerySqlGeneratorDependencies dependencies) /// protected override void GenerateRootCommand(Expression queryExpression) { +#if !NET8_0 && !NET9_0 + if (queryExpression is SelectExpression selectExpr) + queryExpression = TransformVariableWrapsOnSelectExpression(selectExpr); +#endif + var rewriter = new CteDeduplicatingRewriter(); var rewritten = rewriter.Rewrite(queryExpression); var ctes = rewriter.CollectedCtes; @@ -69,12 +78,25 @@ protected override void GenerateRootCommand(Expression queryExpression) /// protected override Expression VisitExtension(Expression expression) { - if (expression is CteTableExpression cteTable) + switch (expression) { - return VisitCteTable(cteTable); - } + case CteTableExpression cteTable: + return VisitCteTable(cteTable); + + case VariableWrapSqlExpression wrap: + // Fall-through: emit the inner expression directly. + // This handles single-use wraps (not transformed to CROSS APPLY) and any + // platform where the CROSS APPLY transformation is not applied. + return Visit(wrap.Inner); + +#if !NET8_0 && !NET9_0 + case VariableWrapCrossApplyExpression crossApply: + return VisitVariableWrapCrossApply(crossApply); +#endif - return base.VisitExtension(expression); + default: + return base.VisitExtension(expression); + } } /// @@ -88,4 +110,139 @@ private Expression VisitCteTable(CteTableExpression cteTable) Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cteTable.Alias!)); return cteTable; } + +#if !NET8_0 && !NET9_0 + /// + /// Emits the CROSS APPLY subquery body, e.g. + /// (SELECT [e].[Bar] * 2 AS [temp]) AS [_cv0]. + /// + private Expression VisitVariableWrapCrossApply(VariableWrapCrossApplyExpression crossApply) + { + Sql.AppendLine("("); + using (Sql.Indent()) + { + Sql.Append("SELECT "); + Visit(crossApply.Inner); + Sql.Append(AliasSeparator); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(crossApply.ColumnName)); + } + + Sql.AppendLine(); + Sql.Append(")"); + Sql.Append(AliasSeparator); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(crossApply.Alias!)); + return crossApply; + } + + /// + /// Rewrites any nodes that appear more than once + /// in the root into CROSS APPLY table sources. + /// Single-use nodes are lowered to their inner expression. + /// + /// + /// This is called from both and + /// from the postprocessor () so that + /// the transformation runs before SqlNullabilityProcessor sees the expression tree. + /// + internal static SelectExpression TransformVariableWrapsOnSelectExpression(SelectExpression rootSelect) + { + // Scan the entire expression for VariableWrap occurrences, grouped by variable name. + var scanner = new VariableWrapScanner(); + scanner.Visit(rootSelect); + + // Only process names that appear more than once (single-use wraps are emitted inline). + var multiUse = scanner.Groups + .Where(kv => kv.Value.Count > 1) + .ToList(); + + if (multiUse.Count == 0) + return rootSelect; + + // Build the column mapping: variableName → ColumnExpression referencing the CROSS APPLY. + var columnMapping = new Dictionary(StringComparer.Ordinal); + var crossApplyTables = new List(); + var counter = 0; + + foreach (var (name, wraps) in multiUse) + { + var tableAlias = $"w{counter++}"; + var first = wraps[0]; + var body = new VariableWrapCrossApplyExpression(tableAlias, first.Inner, name); + crossApplyTables.Add(new CrossApplyExpression(body)); + columnMapping[name] = new ColumnExpression(name, tableAlias, first.Type, first.TypeMapping!, false); + } + + // Rewrite: replace VariableWrapSqlExpression with the column references. + var replacer = new VariableWrapReplacer(columnMapping); + var rewritten = (SelectExpression)replacer.Visit(rootSelect); + + // Append the new CROSS APPLY table sources. + rewritten.SetTables([.. rewritten.Tables, .. crossApplyTables]); + + return rewritten; + } + + // ── helpers ───────────────────────────────────────────────────────────────────────── + + /// + /// Scans a SQL expression tree for nodes, + /// collecting them grouped by . + /// + private sealed class VariableWrapScanner : ExpressionVisitor + { + public Dictionary> Groups { get; } = new(StringComparer.Ordinal); + + protected override Expression VisitExtension(Expression node) + { + if (node is VariableWrapSqlExpression wrap) + { + if (!Groups.TryGetValue(wrap.VariableName, out var list)) + Groups[wrap.VariableName] = list = []; + list.Add(wrap); + // Don't recurse into the inner — we only care about top-level occurrences. + return node; + } + + return base.VisitExtension(node); + } + } + + /// + /// Replaces nodes with + /// references from the provided mapping. + /// Nodes whose name is not in the mapping (single-use) are lowered to the inner expression. + /// Does not recurse into nested nodes that are table sources, + /// since the CROSS APPLY is only added at the root level. + /// + private sealed class VariableWrapReplacer(IReadOnlyDictionary mapping) : ExpressionVisitor + { + // 0 = not yet inside any SelectExpression; 1 = in the root SELECT (replace here); + // 2+ = in a nested SelectExpression (leave Variable.Wrap untouched — it belongs to a + // different scope and would have its own CROSS APPLY if it were at the root). + private int _depth; + + protected override Expression VisitExtension(Expression node) + { + if (node is VariableWrapSqlExpression wrap && _depth == 1) + { + if (mapping.TryGetValue(wrap.VariableName, out var col)) + return col; + // Single-use: strip to inner expression. + return Visit(wrap.Inner); + } + + if (node is SelectExpression) + { + if (_depth >= 1) + return node; // Nested SelectExpression — do not recurse. + + _depth++; + try { return base.VisitExtension(node); } + finally { _depth--; } + } + + return base.VisitExtension(node); + } + } +#endif } diff --git a/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs b/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs index 4f952fc..3c1a879 100644 --- a/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs +++ b/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs @@ -1,3 +1,4 @@ +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; @@ -5,19 +6,15 @@ namespace EntityFrameworkCore.Projectables.Query; /// /// An for that compares -/// expressions using EF Core's structural . +/// expressions structurally after normalising table-alias names. /// -/// This comparer is used by to detect when the same -/// subtree (including its alias assignments) appears in more -/// than one table-source position within a query. Two nodes -/// are considered equal only if they are fully structurally identical — including the aliases -/// EF Core assigned to their tables. -/// -/// -/// Note: In practice EF Core assigns fresh aliases (e.g. [e], [e0]) to -/// every translation of the same LINQ sub-expression, so most logically duplicate sub-queries -/// will still compare as unequal here. A future improvement could normalise alias names before -/// comparison to detect these alias-differing duplicates. +/// EF Core assigns fresh aliases (e.g. e, e0) to every logical copy of the same +/// LINQ sub-expression, so two occurrences of the same query — such as the two halves of a +/// UNION ALL — are structurally identical apart from their alias assignments. On EF Core +/// 10 this comparer first rewrites both expressions to use canonical alias names (a0, +/// a1, …) in depth-first traversal order, then delegates to +/// for a full structural comparison. On earlier +/// versions the comparison falls back to a plain structural comparison (no alias normalisation). /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] @@ -29,9 +26,93 @@ private NormalizedSelectExpressionComparer() { } /// public bool Equals(SelectExpression? x, SelectExpression? y) - => ExpressionEqualityComparer.Instance.Equals(x, y); + { + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null) return false; +#if !NET8_0 && !NET9_0 + return ExpressionEqualityComparer.Instance.Equals(Normalize(x), Normalize(y)); +#else + return ExpressionEqualityComparer.Instance.Equals(x, y); +#endif + } /// public int GetHashCode(SelectExpression obj) - => ExpressionEqualityComparer.Instance.GetHashCode(obj); + { +#if !NET8_0 && !NET9_0 + return ExpressionEqualityComparer.Instance.GetHashCode(Normalize(obj)); +#else + return ExpressionEqualityComparer.Instance.GetHashCode(obj); +#endif + } + +#if !NET8_0 && !NET9_0 + /// + /// Returns a copy of where every table alias has been replaced + /// by a canonical name (a0, a1, …) assigned in depth-first traversal order. + /// + private static SelectExpression Normalize(SelectExpression select) + { + var collector = new AliasCollector(); + collector.Visit(select); + + var map = new Dictionary(StringComparer.Ordinal); + var counter = 0; + foreach (var alias in collector.AliasesInOrder) + map.TryAdd(alias, $"a{counter++}"); + + return (SelectExpression)new AliasNormalizer(map).Visit(select); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────── + + /// + /// Collects every table-alias string encountered while traversing a + /// tree, preserving depth-first insertion order. + /// + private sealed class AliasCollector : ExpressionVisitor + { + private readonly LinkedList _aliases = new(); + private readonly HashSet _seen = new(StringComparer.Ordinal); + + public IEnumerable AliasesInOrder => _aliases; + + protected override Expression VisitExtension(Expression node) + { + if (node is TableExpressionBase table && table.Alias is { } alias) + AddAlias(alias); + + return base.VisitExtension(node); + } + + private void AddAlias(string alias) + { + if (_seen.Add(alias)) + _aliases.AddLast(alias); + } + } + + /// + /// Rewrites a tree, replacing every table alias with the + /// canonical name found in . + /// + private sealed class AliasNormalizer(IReadOnlyDictionary aliasMap) : ExpressionVisitor + { + protected override Expression VisitExtension(Expression node) + { + switch (node) + { + case ColumnExpression col when aliasMap.TryGetValue(col.TableAlias, out var newTableAlias): + return new ColumnExpression(col.Name, newTableAlias, col.Type, col.TypeMapping!, col.IsNullable); + + case TableExpressionBase table when table.Alias is { } alias && aliasMap.TryGetValue(alias, out var newAlias): + var visited = (TableExpressionBase)base.VisitExtension(table); + return visited.WithAlias(newAlias); + + default: + return base.VisitExtension(node); + } + } + } +#endif } diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs new file mode 100644 index 0000000..3d9d6f9 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs @@ -0,0 +1,95 @@ +#if !NET8_0 && !NET9_0 +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; + +/// +/// A that represents the inner subquery of a +/// CROSS APPLY (SELECT AS []) AS [alias] +/// expression added by to materialise a +/// exactly once per row. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class VariableWrapCrossApplyExpression : TableExpressionBase +{ + /// Creates a new instance. + /// The SQL table alias (e.g. _cv0) for the CROSS APPLY table. + /// The SQL expression to project as a single column. + /// The name to give the projected column (the variable name). + public VariableWrapCrossApplyExpression(string alias, SqlExpression inner, string columnName) + : base(alias) + { + Inner = inner; + ColumnName = columnName; + } + + private VariableWrapCrossApplyExpression( + string alias, + SqlExpression inner, + string columnName, + IReadOnlyDictionary annotations) + : base(alias, annotations) + { + Inner = inner; + ColumnName = columnName; + } + + /// The expression computed inside the CROSS APPLY subquery. + public SqlExpression Inner { get; } + + /// The projected column name inside the CROSS APPLY subquery. + public string ColumnName { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newInner = (SqlExpression)visitor.Visit(Inner); + return ReferenceEquals(newInner, Inner) + ? this + : new VariableWrapCrossApplyExpression(Alias!, newInner, ColumnName, + GetAnnotations().ToDictionary(a => a.Name, a => a)); + } + + /// + public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor) + => new VariableWrapCrossApplyExpression( + alias ?? Alias!, + (SqlExpression)cloningExpressionVisitor.Visit(Inner), + ColumnName, + GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + public override TableExpressionBase WithAlias(string newAlias) + => new VariableWrapCrossApplyExpression(newAlias, Inner, ColumnName, + GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) + => new VariableWrapCrossApplyExpression(Alias!, Inner, ColumnName, annotations); + + /// + public override Expression Quote() + => throw new NotSupportedException($"{nameof(VariableWrapCrossApplyExpression)} does not support pre-compiled queries."); + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append($"(SELECT "); + expressionPrinter.Visit(Inner); + expressionPrinter.Append($" AS [{ColumnName}]) AS [{Alias}]"); + } + + /// + public override bool Equals(object? obj) + => obj is VariableWrapCrossApplyExpression other + && Alias == other.Alias + && ColumnName == other.ColumnName + && Inner.Equals(other.Inner); + + /// + public override int GetHashCode() => HashCode.Combine(Alias, ColumnName, Inner); +} +#endif diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs new file mode 100644 index 0000000..0c58ff8 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; + +/// +/// A that marks a named reused local variable produced by a +/// block-bodied [Projectable] method. +/// +/// The source generator wraps each occurrence of a reused local variable in a call to +/// . EF Core's method-call translator converts those +/// calls to nodes. The +/// then replaces multi-occurrence groups with a +/// CROSS APPLY (SELECT … AS [name]) AS [alias] table source so that the expression is +/// computed exactly once per row. +/// +/// +/// Single-occurrence nodes (where the variable is only used once) are lowered to the plain +/// expression by the SQL generator, preserving the original SQL shape. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class VariableWrapSqlExpression : SqlExpression +{ + /// Initialises a new instance. + /// The name of the local variable as it appears in source. + /// The SQL expression that computes the variable's value. + public VariableWrapSqlExpression(string variableName, SqlExpression inner) + : base(inner.Type, inner.TypeMapping) + { + VariableName = variableName; + Inner = inner; + } + + /// The local-variable name the generator used when emitting this marker. + public string VariableName { get; } + + /// The SQL expression that produces the variable's value. + public SqlExpression Inner { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newInner = (SqlExpression)visitor.Visit(Inner); + return ReferenceEquals(newInner, Inner) + ? this + : new VariableWrapSqlExpression(VariableName, newInner); + } + +#if !NET8_0 + /// + public override Expression Quote() + => throw new NotSupportedException($"{nameof(VariableWrapSqlExpression)} does not support pre-compiled queries."); +#endif + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append($"Wrap({VariableName}: "); + expressionPrinter.Visit(Inner); + expressionPrinter.Append(")"); + } + + /// + public override bool Equals(object? obj) + => obj is VariableWrapSqlExpression other + && VariableName == other.VariableName + && Inner.Equals(other.Inner); + + /// + public override int GetHashCode() => HashCode.Combine(VariableName, Inner); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs new file mode 100644 index 0000000..71e406d --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs @@ -0,0 +1,89 @@ +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// A decorator that wraps the provider's own +/// factory and inserts a step. +/// +/// This postprocessor runs before the relational nullability processor so that +/// nodes — which EF Core's nullability processor does +/// not understand — are either replaced by CROSS APPLY table sources (on .NET 10 / EF +/// Core 10) or lowered to their inner expressions (on earlier versions) before they can cause +/// an exception. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class VariableWrapQueryTranslationPostprocessorFactory( + IQueryTranslationPostprocessorFactory inner, + QueryTranslationPostprocessorDependencies dependencies) + : IQueryTranslationPostprocessorFactory +{ + /// + public QueryTranslationPostprocessor Create(QueryCompilationContext queryCompilationContext) + { + var innerPostprocessor = inner.Create(queryCompilationContext); + return new VariableWrapQueryTranslationPostprocessor(dependencies, queryCompilationContext, innerPostprocessor); + } +} + +/// +/// Processes nodes in the SQL expression tree before +/// delegating to the inner postprocessor. +/// +/// On .NET 10 / EF Core 10: Replaces multi-use +/// groups with CROSS APPLY (SELECT … AS [name]) AS [alias] table sources +/// and references. +/// On earlier versions: Lowers every to its +/// plain inner expression (identity semantics). +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class VariableWrapQueryTranslationPostprocessor( + QueryTranslationPostprocessorDependencies dependencies, + QueryCompilationContext queryCompilationContext, + QueryTranslationPostprocessor inner) + : QueryTranslationPostprocessor(dependencies, queryCompilationContext) +{ + /// + public override Expression Process(Expression query) + { + // Apply Variable.Wrap transformation before the provider's postprocessor (which + // eventually runs SqlNullabilityProcessor — an EF Core internal processor that throws + // on unknown custom SQL expressions). + query = TransformVariableWraps(query); + return inner.Process(query); + } + + private static Expression TransformVariableWraps(Expression query) + { + if (query is not ShapedQueryExpression shaped + || shaped.QueryExpression is not SelectExpression selectExpression) + return query; + +#if !NET8_0 && !NET9_0 + var transformed = CteAwareQuerySqlGenerator.TransformVariableWrapsOnSelectExpression(selectExpression); + return ReferenceEquals(transformed, selectExpression) + ? query + : shaped.UpdateQueryExpression(transformed); +#else + // Lower Variable.Wrap → inner expression so the nullability processor is unaffected. + var stripped = (SelectExpression)new VariableWrapStripper().Visit(selectExpression); + return ReferenceEquals(stripped, selectExpression) ? query : shaped.UpdateQueryExpression(stripped); +#endif + } + +#if NET8_0 || NET9_0 + /// Removes by replacing each with its inner expression. + private sealed class VariableWrapStripper : ExpressionVisitor + { + protected override Expression VisitExtension(Expression node) + => node is VariableWrapSqlExpression wrap + ? Visit(wrap.Inner) + : base.VisitExtension(node); + } +#endif +} diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs new file mode 100644 index 0000000..a5f843c --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Reflection; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// Translates calls to — the reuse-marker inserted by +/// the source generator for block-bodied projectable methods — into +/// nodes so that the SQL generator can later decide +/// whether to inline them or factor them out via a CROSS APPLY. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class VariableWrapTranslatorPlugin : IMethodCallTranslatorPlugin +{ + public IEnumerable Translators { get; } = [new VariableWrapTranslator()]; + + private sealed class VariableWrapTranslator : IMethodCallTranslator + { + private static readonly MethodInfo _variableWrapMethod = + ((MethodCallExpression)((Expression>)(v => Variable.Wrap("x", v))).Body) + .Method.GetGenericMethodDefinition(); + + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (!method.IsGenericMethod || method.GetGenericMethodDefinition() != _variableWrapMethod) + return null; + + // arguments[0] is the constant name string, arguments[1] is the inner SQL expression. + var variableName = (string)((SqlConstantExpression)arguments[0]).Value!; + var inner = arguments[1]; + return new VariableWrapSqlExpression(variableName, inner); + } + } +} diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 53252e5..dd28265 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -30,14 +30,6 @@ public sealed class ProjectableExpressionReplacer : ExpressionVisitor ((MethodCallExpression)((Expression, IQueryable>>) (q => q.Where(x => true))).Body).Method.GetGenericMethodDefinition(); - // Variable.Wrap(string, T) generic method definition — used to strip the marker - // injected by the source generator for reused local variables in block-bodied projectable - // methods. At this stage in the pipeline the marker is peeled away (identity behaviour); - // future implementations may use it to emit SQL CTEs. - private readonly static MethodInfo _variableWrapMethod = - ((MethodCallExpression)((Expression>)(v => Variable.Wrap("x", v))).Body) - .Method.GetGenericMethodDefinition(); - // Static caches — keyed by CLR type, shared across all instances for the AppDomain lifetime. // ConditionalWeakTable uses "ephemeron" semantics: the Type key is not kept alive by the // cache entry, so types from collectible AssemblyLoadContexts can still be unloaded. @@ -153,17 +145,10 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La protected override Expression VisitMethodCall(MethodCallExpression node) { - // Strip Variable.Wrap("name", value) → value. - // The generator emits Variable.Wrap for reused local variables as a CTE marker. - // We peel it away here so EF Core never sees the call; the SQL shape is unchanged - // for now (both occurrences are inlined). Future work: use the marker to create CTEs. - if (node.Method.IsGenericMethod && - node.Method.GetGenericMethodDefinition() == _variableWrapMethod) - { - return Visit(node.Arguments[1]); - } - - // Replace MethodGroup arguments with their reflected expressions. + // Variable.Wrap("name", value) is now handled by VariableWrapTranslatorPlugin during + // EF Core's SQL translation phase, which converts it to a VariableWrapSqlExpression. + // We no longer strip it here so EF Core sees the call and can translate it properly. + // (Single-use wraps are inlined; multi-use wraps get a CROSS APPLY on net10.0+.) // No-alloc fast-path: scan args without allocating; only copy the array and call // Update() when a replacement is actually found (method-group arguments are rare). Expression[]? updatedArgs = null; From a4b57ef7b6f0bd3a357f84b9e7dde490db32d2fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:32:48 +0000 Subject: [PATCH 08/11] Update and add DotNet10_0 snapshot verified files; CROSS APPLY now working for reused locals Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- ...leReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt | 7 +++++-- ...dTests.LocalVariable_IsInlined.DotNet10_0.verified.txt | 2 +- ...lVariables_AreInlinedCorrectly.DotNet10_0.verified.txt | 2 +- ...gProjectableMethod_InCondition.DotNet10_0.verified.txt | 2 +- ...ableMethod_InLogicalExpression.DotNet10_0.verified.txt | 2 +- ...lingProjectableMethod_Multiple.DotNet10_0.verified.txt | 2 +- ...allingProjectableMethod_Nested.DotNet10_0.verified.txt | 2 +- ...ctableMethod_WithLocalVariable.DotNet10_0.verified.txt | 2 +- ...eField_SubqueryInlinedViaCount.DotNet10_0.verified.txt | 2 +- ....ProjectOverNavigationProperty.DotNet10_0.verified.txt | 5 +++-- ...ModelTests.ProjectQueryFilters.DotNet10_0.verified.txt | 5 +++-- ...ery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt | 7 +++++++ ...y_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt | 8 ++++++++ ...uery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt | 7 +++++++ ...ensionMethodAcceptingDbContext.DotNet10_0.verified.txt | 3 ++- ...ectProjectableExtensionMethod2.DotNet10_0.verified.txt | 2 +- ...ncingBasePropertyInDerivedBody.DotNet10_0.verified.txt | 2 +- ...cingPreviouslyAssignedProperty.DotNet10_0.verified.txt | 2 +- ...orReferencingStaticConstMember.DotNet10_0.verified.txt | 2 +- ...orWithBaseInitializerAndIfElse.DotNet10_0.verified.txt | 4 ++-- ...t_ConstructorWithLocalVariable.DotNet10_0.verified.txt | 2 +- ..._DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt | 2 +- ...sts.Select_EntityInstanceToDto.DotNet10_0.verified.txt | 2 +- ...oadedConstructor_WithThreeArgs.DotNet10_0.verified.txt | 2 +- ...rloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt | 2 +- ...Tests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt | 2 +- ...hisOverload_ChainedThisAndBase.DotNet10_0.verified.txt | 2 +- ...erload_WithBodyAfterDelegation.DotNet10_0.verified.txt | 2 +- ...t_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt | 2 +- ...s.EntityRootSubqueryExpression.DotNet10_0.verified.txt | 4 ++-- ...ts.FilterOnProjectableProperty.DotNet10_0.verified.txt | 2 +- ...ineSelectProjectableProperties.DotNet10_0.verified.txt | 2 +- ...erOnComplexProjectableProperty.DotNet10_0.verified.txt | 2 +- ...ineSelectProjectableProperties.DotNet10_0.verified.txt | 2 +- ...erOnComplexProjectableProperty.DotNet10_0.verified.txt | 2 +- 35 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt index eec38d9..b29d69c 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt @@ -1,2 +1,5 @@ -SELECT [e].[Value] * 2 + [e].[Value] * 2 -FROM [Entity] AS [e] \ No newline at end of file +SELECT [w].[doubled] + [w].[doubled] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 2 AS [doubled] +) AS [w] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt index 9689484..e61805e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + 5 +SELECT ([e].[Value] * 2) + 5 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt index 4a903b0..98c6174 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + [e].[Value] * 3 + 10 +SELECT (([e].[Value] * 2) + ([e].[Value] * 3)) + 10 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt index 478d0ba..d1c34c5 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT CASE - WHEN [e].[Value] * 2 > 200 THEN N'Very High' + WHEN ([e].[Value] * 2) > 200 THEN N'Very High' ELSE N'Normal' END FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt index de3373a..3e1de12 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT CASE - WHEN ([e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 100) OR [e].[Value] * 2 > 150 THEN CAST(1 AS bit) + WHEN ([e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 100) OR ([e].[Value] * 2) > 150 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt index 69eb4b8..0a254ff 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + 42 +SELECT ([e].[Value] * 2) + 42 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt index 72fc7ea..cc31d51 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] + 42 + 10 +SELECT ([e].[Value] + 42) + 10 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt index ae5ad93..708800b 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT ([e].[Value] * 2 + 42) * 2 +SELECT (([e].[Value] * 2) + 42) * 2 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt index c925721..9e37fa7 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT [e].[Id], ( SELECT COUNT(*) FROM [Entity] AS [e0] - WHERE [e0].[Id] * 2 > 4) AS [SubsetCount] + WHERE ([e0].[Id] * 2) > 4) AS [SubsetCount] FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt index 0178f4c..739c39b 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt @@ -1,6 +1,7 @@ SELECT ( - SELECT TOP(1) [o].[RecordDate] + SELECT [o].[RecordDate] FROM [Order] AS [o] WHERE [u].[Id] = [o].[UserId] - ORDER BY [o].[RecordDate] DESC) + ORDER BY [o].[RecordDate] DESC + FETCH FIRST 1 ROWS ONLY) FROM [User] AS [u] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt index 43f5941..600b505 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt @@ -9,7 +9,8 @@ INNER JOIN ( WHERE [o1].[row] <= 2 ) AS [o2] ON [u].[Id] = [o2].[UserId] WHERE ( - SELECT TOP(1) [o].[Id] + SELECT [o].[Id] FROM [Order] AS [o] WHERE [u].[Id] = [o].[UserId] - ORDER BY [o].[RecordDate] DESC) > 100 \ No newline at end of file + ORDER BY [o].[RecordDate] DESC + FETCH FIRST 1 ROWS ONLY) > 100 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt new file mode 100644 index 0000000..6f59285 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= 1 AND [e].[Id] <= 5 +UNION ALL +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt new file mode 100644 index 0000000..615eff9 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT [e].[Id] AS [OuterId], [e1].[Name] AS [InnerName] +FROM [Entity] AS [e] +INNER JOIN ( + SELECT [e0].[Id], [e0].[Name] + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 10 +) AS [e1] ON [e].[Id] = ([e1].[Id] + 1) +WHERE [e].[Id] >= 1 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt new file mode 100644 index 0000000..7f2b439 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] % 2) = 0 +UNION +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE ([e0].[Id] % 2) = 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt index f49ccac..f4b46ea 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt @@ -1,7 +1,8 @@ SELECT [e1].[Id], [e1].[Name] FROM [Entity] AS [e] OUTER APPLY ( - SELECT TOP(1) [e0].[Id], [e0].[Name] + SELECT [e0].[Id], [e0].[Name] FROM [Entity] AS [e0] WHERE [e0].[Id] > [e].[Id] + FETCH FIRST 1 ROWS ONLY ) AS [e1] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt index 43ca93d..57f2233 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + 1 + 1 +SELECT ([e].[Id] + 1) + 1 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt index 6a9d698..992871d 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +SELECT [p].[FirstName] AS [Code], (N'[' + COALESCE([p].[FirstName], N'')) + N']' AS [Label] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt index cccd5bb..5c8a30f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[FirstName], [p].[LastName], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt index 2752cb7..c3922a7 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' - ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt index 2f9967b..10f4b26 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ -SELECT CASE +SELECT CASE WHEN [p].[Id] < 0 THEN 0 ELSE [p].[Id] -END AS [Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +END AS [Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt index 8fc80f2..5dafde0 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt index dee2833..5dafde0 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt index 6004f0a..bd53399 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + (N'-' + COALESCE([p].[LastName], N'')) AS [Name] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt index cccd5bb..5c8a30f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[FirstName], [p].[LastName], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt index 75b71ad..bf59ef8 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt @@ -1,6 +1,6 @@ SELECT [e].[Id], ( SELECT COUNT(*) FROM [Entity] AS [e0] - WHERE [e0].[Id] * 5 = 5) AS [TotalCount] + WHERE ([e0].[Id] * 5) = 5) AS [TotalCount] FROM [Entity] AS [e] -WHERE [e].[Id] * 5 = 5 \ No newline at end of file +WHERE ([e].[Id] * 5) = 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt index d4ddd00..b5c9406 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] + 1 = 2 \ No newline at end of file +WHERE ([e].[Id] + 1) = 2 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt index c89d0e6..e3dfe9e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + [e].[Id] * 2 +SELECT [e].[Id] + ([e].[Id] * 2) FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt index 6c95f6a..666a621 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] * 2 = 2 \ No newline at end of file +WHERE ([e].[Id] * 2) = 2 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt index c89d0e6..e3dfe9e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + [e].[Id] * 2 +SELECT [e].[Id] + ([e].[Id] * 2) FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt index 6c95f6a..666a621 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] * 2 = 2 \ No newline at end of file +WHERE ([e].[Id] * 2) = 2 \ No newline at end of file From 5d00700b71c3febf9017b2e8864730e8e162aebe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:42:13 +0000 Subject: [PATCH 09/11] =?UTF-8?q?Rename=20CTE=E2=86=92CrossApply,=20add=20?= =?UTF-8?q?PostgreSQL=20LATERAL,=20new=20tests=20with=20snapshots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Internal/ProjectionOptionsExtension.cs | 7 +- .../Query/CteAwareQuerySqlGeneratorFactory.cs | 21 -- .../Query/CteDeduplicatingRewriter.cs | 148 ---------- .../NormalizedSelectExpressionComparer.cs | 118 -------- ...or.cs => ProjectablesQuerySqlGenerator.cs} | 161 +++++------ .../ProjectablesQuerySqlGeneratorFactory.cs | 18 ++ .../SqlExpressions/CteTableExpression.cs | 93 ------- ...ression.cs => InlineSubqueryExpression.cs} | 39 +-- .../VariableWrapSqlExpression.cs | 6 +- ...riableWrapQueryTranslationPostprocessor.cs | 17 +- ...linedMultipleTimes.DotNet10_0.verified.txt | 4 +- .../Helpers/SampleNpgsqlDbContext.cs | 71 +++++ ...d_GeneratesLateral.DotNet10_0.verified.txt | 8 + ...ed_GeneratesLateral.DotNet9_0.verified.txt | 8 + ...iable_Reused_GeneratesLateral.verified.txt | 8 + ...d_GeneratesLateral.DotNet10_0.verified.txt | 5 + ...ed_GeneratesLateral.DotNet9_0.verified.txt | 2 + ...ResultReused_GeneratesLateral.verified.txt | 2 + ...UsedOnce_IsInlined.DotNet10_0.verified.txt | 2 + ..._UsedOnce_IsInlined.DotNet9_0.verified.txt | 2 + ...leVariable_UsedOnce_IsInlined.verified.txt | 2 + ...e_GeneratesLateral.DotNet10_0.verified.txt | 5 + ...ce_GeneratesLateral.DotNet9_0.verified.txt | 2 + ...le_UsedTwice_GeneratesLateral.verified.txt | 2 + ...neratesTwoLaterals.DotNet10_0.verified.txt | 8 + ...eneratesTwoLaterals.DotNet9_0.verified.txt | 2 + ...sedTwice_GeneratesTwoLaterals.verified.txt | 2 + ...e_GeneratesLateral.DotNet10_0.verified.txt | 6 + ...re_GeneratesLateral.DotNet9_0.verified.txt | 3 + ...ReuseInWhere_GeneratesLateral.verified.txt | 3 + .../LocalVariableReuseLateralTests.cs | 146 ++++++++++ ...eneratesCrossApply.DotNet10_0.verified.txt | 8 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 8 + ...le_Reused_GeneratesCrossApply.verified.txt | 8 + ...eneratesCrossApply.DotNet10_0.verified.txt | 5 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 2 + ...WithReuse_GeneratesCrossApply.verified.txt | 2 + ...ableGetsCrossApply.DotNet10_0.verified.txt | 5 + ...iableGetsCrossApply.DotNet9_0.verified.txt | 2 + ...yReusedVariableGetsCrossApply.verified.txt | 2 + ...eneratesCrossApply.DotNet10_0.verified.txt | 5 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 2 + ...ultReused_GeneratesCrossApply.verified.txt | 2 + ...UsedOnce_IsInlined.DotNet10_0.verified.txt | 2 + ..._UsedOnce_IsInlined.DotNet9_0.verified.txt | 2 + ...leVariable_UsedOnce_IsInlined.verified.txt | 2 + ...ratesOneCrossApply.DotNet10_0.verified.txt | 5 + ...eratesOneCrossApply.DotNet9_0.verified.txt | 2 + ...eTimes_GeneratesOneCrossApply.verified.txt | 2 + ...eneratesCrossApply.DotNet10_0.verified.txt | 5 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 2 + ...UsedTwice_GeneratesCrossApply.verified.txt | 2 + ...tesTwoCrossApplies.DotNet10_0.verified.txt | 8 + ...atesTwoCrossApplies.DotNet9_0.verified.txt | 2 + ...wice_GeneratesTwoCrossApplies.verified.txt | 2 + ...eneratesCrossApply.DotNet10_0.verified.txt | 6 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 3 + ...tAndWhere_GeneratesCrossApply.verified.txt | 3 + ...eneratesCrossApply.DotNet10_0.verified.txt | 6 + ...GeneratesCrossApply.DotNet9_0.verified.txt | 3 + ...seInWhere_GeneratesCrossApply.verified.txt | 3 + .../LocalVariableReuseTests.cs | 253 ++++++++++++++++++ ...desUseProjectable.DotNet10_0.verified.txt} | 0 ...SidesUseProjectable.DotNet9_0.verified.txt | 7 + ...oncat_BothSidesUseProjectable.verified.txt | 7 + ...desUseProjectable.DotNet10_0.verified.txt} | 0 ...SidesUseProjectable.DotNet9_0.verified.txt | 8 + ...fJoin_BothSidesUseProjectable.verified.txt | 8 + ...desUseProjectable.DotNet10_0.verified.txt} | 0 ...SidesUseProjectable.DotNet9_0.verified.txt | 7 + ...Union_BothSidesUseProjectable.verified.txt | 7 + ...cs => SetOperationWithProjectableTests.cs} | 32 +-- 72 files changed, 823 insertions(+), 538 deletions(-) delete mode 100644 src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs delete mode 100644 src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs delete mode 100644 src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs rename src/EntityFrameworkCore.Projectables/Query/{CteAwareQuerySqlGenerator.cs => ProjectablesQuerySqlGenerator.cs} (52%) create mode 100644 src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs delete mode 100644 src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs rename src/EntityFrameworkCore.Projectables/Query/SqlExpressions/{VariableWrapCrossApplyExpression.cs => InlineSubqueryExpression.cs} (62%) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleNpgsqlDbContext.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.cs create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet10_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.cs rename tests/EntityFrameworkCore.Projectables.FunctionalTests/{CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt => SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt} (100%) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.verified.txt rename tests/EntityFrameworkCore.Projectables.FunctionalTests/{CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt => SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt} (100%) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.verified.txt rename tests/EntityFrameworkCore.Projectables.FunctionalTests/{CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt => SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt} (100%) create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet9_0.verified.txt create mode 100644 tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.verified.txt rename tests/EntityFrameworkCore.Projectables.FunctionalTests/{CteTests.cs => SetOperationWithProjectableTests.cs} (51%) diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index aad57e9..5add3a4 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -62,10 +62,9 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor // Custom convention to handle global query filters, etc services.AddScoped(); - // Register the CTE-aware SQL generator factory for deduplicating duplicate - // SelectExpression subtrees produced when local variables in block-bodied - // projectable methods are referenced more than once. - services.Replace(ServiceDescriptor.Scoped()); + // Register the SQL generator factory that emits CROSS APPLY / CROSS JOIN LATERAL + // subqueries for reused local variables in block-bodied projectable methods. + services.Replace(ServiceDescriptor.Scoped()); // Wrap the query translation postprocessor to handle VariableWrapSqlExpression before // EF Core's SqlNullabilityProcessor encounters it. diff --git a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs b/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs deleted file mode 100644 index a0a21d5..0000000 --- a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGeneratorFactory.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.EntityFrameworkCore.Query; - -namespace EntityFrameworkCore.Projectables.Query; - -/// -/// An implementation that creates -/// instances, enabling CTE-based SQL deduplication -/// for local variables that are referenced more than once in projectable block-bodied methods. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -public sealed class CteAwareQuerySqlGeneratorFactory : IQuerySqlGeneratorFactory -{ - private readonly QuerySqlGeneratorDependencies _dependencies; - - /// - public CteAwareQuerySqlGeneratorFactory(QuerySqlGeneratorDependencies dependencies) - => _dependencies = dependencies; - - /// - public QuerySqlGenerator Create() => new CteAwareQuerySqlGenerator(_dependencies); -} diff --git a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs b/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs deleted file mode 100644 index 724d74e..0000000 --- a/src/EntityFrameworkCore.Projectables/Query/CteDeduplicatingRewriter.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore.Query; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; -using EntityFrameworkCore.Projectables.Query.SqlExpressions; - -namespace EntityFrameworkCore.Projectables.Query; - -/// -/// An that rewrites duplicate -/// subtrees (used as table sources in FROM clauses) into -/// references, so that the SQL generator can emit a single WITH … AS (…) clause instead -/// of repeating the body. -/// -/// Scope: Only nodes that appear as table sources are -/// considered for CTE factoring. Scalar subqueries (those wrapped in a -/// ) are intentionally left untouched because they have a -/// different SQL shape and cannot be substituted with a plain table reference. -/// -/// -/// Alias-neutral equality: EF Core assigns fresh aliases (e.g. e, e0) to -/// each occurrence of a sub-expression, so two logically identical -/// trees typically differ only in their alias names. This rewriter therefore uses -/// instead of -/// to detect structural equivalence regardless of alias -/// names. -/// -/// -/// Two passes are performed: -/// -/// Count occurrences of every table-source subtree using -/// alias-neutral structural equality. -/// Replace all occurrences of any subtree that appears more than once with a -/// and add the defining expression to -/// in depth-first order so that dependent CTEs are emitted -/// before their consumers. -/// -/// -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -public sealed class CteDeduplicatingRewriter : ExpressionVisitor -{ - private readonly Dictionary _occurrenceCount - = new(NormalizedSelectExpressionComparer.Instance); - - private readonly Dictionary _cteMap - = new(NormalizedSelectExpressionComparer.Instance); - - private int _cteCounter; - private bool _isCounting = true; - - // When > 0 we are inside a ScalarSubqueryExpression and must not replace the inner - // SelectExpression with a CteTableExpression (the types are incompatible). - private int _scalarSubqueryDepth; - - /// - /// CTEs collected during the rewrite pass, in depth-first order so nested - /// CTEs appear before their consumers. - /// - public IReadOnlyList CollectedCtes => [.. _cteMap.Values]; - - /// - /// Rewrites the given , replacing duplicate table-source - /// subtrees with references. - /// - public Expression Rewrite(Expression expression) - { - // Pass 1: count occurrences - _isCounting = true; - _scalarSubqueryDepth = 0; - Visit(expression); - - // Pass 2: replace duplicates with CTE references - _isCounting = false; - _scalarSubqueryDepth = 0; - return Visit(expression); - } - - /// - protected override Expression VisitExtension(Expression node) - { - if (node is ScalarSubqueryExpression scalarSubquery) - { - return VisitScalarSubqueryExpression(scalarSubquery); - } - - if (node is SelectExpression select && _scalarSubqueryDepth == 0) - { - return VisitTableSourceSelectExpression(select); - } - - return base.VisitExtension(node); - } - - /// - /// Visits a , tracking depth so that the inner - /// is not replaced with a . - /// - private Expression VisitScalarSubqueryExpression(ScalarSubqueryExpression scalarSubquery) - { - _scalarSubqueryDepth++; - try - { - return base.VisitExtension(scalarSubquery); - } - finally - { - _scalarSubqueryDepth--; - } - } - - /// - /// Handles a that appears as a table source (not inside a - /// scalar subquery). - /// - private Expression VisitTableSourceSelectExpression(SelectExpression select) - { - if (_isCounting) - { - // Count this subtree before visiting children so we count the original form. - _occurrenceCount[select] = _occurrenceCount.TryGetValue(select, out var existing) - ? existing + 1 - : 1; - - // Visit children to count nested SelectExpressions. - base.VisitExtension(select); - return select; - } - - // Rewrite pass: check whether this subtree appears more than once. - if (_occurrenceCount.TryGetValue(select, out var count) && count > 1) - { - if (!_cteMap.TryGetValue(select, out var cte)) - { - // First time we see this duplicate — visit children first (depth-first) so that - // nested CTEs appear before their consumers in CollectedCtes. - var rewrittenInner = (SelectExpression)base.VisitExtension(select); - - var cteName = $"cte_{_cteCounter++}"; - cte = new CteTableExpression(cteName, rewrittenInner); - _cteMap[select] = cte; - } - - return cte; - } - - return base.VisitExtension(select); - } -} diff --git a/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs b/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs deleted file mode 100644 index 3c1a879..0000000 --- a/src/EntityFrameworkCore.Projectables/Query/NormalizedSelectExpressionComparer.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore.Query; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; - -namespace EntityFrameworkCore.Projectables.Query; - -/// -/// An for that compares -/// expressions structurally after normalising table-alias names. -/// -/// EF Core assigns fresh aliases (e.g. e, e0) to every logical copy of the same -/// LINQ sub-expression, so two occurrences of the same query — such as the two halves of a -/// UNION ALL — are structurally identical apart from their alias assignments. On EF Core -/// 10 this comparer first rewrites both expressions to use canonical alias names (a0, -/// a1, …) in depth-first traversal order, then delegates to -/// for a full structural comparison. On earlier -/// versions the comparison falls back to a plain structural comparison (no alias normalisation). -/// -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -internal sealed class NormalizedSelectExpressionComparer : IEqualityComparer -{ - public static readonly NormalizedSelectExpressionComparer Instance = new(); - - private NormalizedSelectExpressionComparer() { } - - /// - public bool Equals(SelectExpression? x, SelectExpression? y) - { - if (ReferenceEquals(x, y)) return true; - if (x is null || y is null) return false; -#if !NET8_0 && !NET9_0 - return ExpressionEqualityComparer.Instance.Equals(Normalize(x), Normalize(y)); -#else - return ExpressionEqualityComparer.Instance.Equals(x, y); -#endif - } - - /// - public int GetHashCode(SelectExpression obj) - { -#if !NET8_0 && !NET9_0 - return ExpressionEqualityComparer.Instance.GetHashCode(Normalize(obj)); -#else - return ExpressionEqualityComparer.Instance.GetHashCode(obj); -#endif - } - -#if !NET8_0 && !NET9_0 - /// - /// Returns a copy of where every table alias has been replaced - /// by a canonical name (a0, a1, …) assigned in depth-first traversal order. - /// - private static SelectExpression Normalize(SelectExpression select) - { - var collector = new AliasCollector(); - collector.Visit(select); - - var map = new Dictionary(StringComparer.Ordinal); - var counter = 0; - foreach (var alias in collector.AliasesInOrder) - map.TryAdd(alias, $"a{counter++}"); - - return (SelectExpression)new AliasNormalizer(map).Visit(select); - } - - // ── helpers ───────────────────────────────────────────────────────────────────────────── - - /// - /// Collects every table-alias string encountered while traversing a - /// tree, preserving depth-first insertion order. - /// - private sealed class AliasCollector : ExpressionVisitor - { - private readonly LinkedList _aliases = new(); - private readonly HashSet _seen = new(StringComparer.Ordinal); - - public IEnumerable AliasesInOrder => _aliases; - - protected override Expression VisitExtension(Expression node) - { - if (node is TableExpressionBase table && table.Alias is { } alias) - AddAlias(alias); - - return base.VisitExtension(node); - } - - private void AddAlias(string alias) - { - if (_seen.Add(alias)) - _aliases.AddLast(alias); - } - } - - /// - /// Rewrites a tree, replacing every table alias with the - /// canonical name found in . - /// - private sealed class AliasNormalizer(IReadOnlyDictionary aliasMap) : ExpressionVisitor - { - protected override Expression VisitExtension(Expression node) - { - switch (node) - { - case ColumnExpression col when aliasMap.TryGetValue(col.TableAlias, out var newTableAlias): - return new ColumnExpression(col.Name, newTableAlias, col.Type, col.TypeMapping!, col.IsNullable); - - case TableExpressionBase table when table.Alias is { } alias && aliasMap.TryGetValue(alias, out var newAlias): - var visited = (TableExpressionBase)base.VisitExtension(table); - return visited.WithAlias(newAlias); - - default: - return base.VisitExtension(node); - } - } - } -#endif -} diff --git a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs similarity index 52% rename from src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs rename to src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs index 9d564c2..15c3375 100644 --- a/src/EntityFrameworkCore.Projectables/Query/CteAwareQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs @@ -7,91 +7,38 @@ namespace EntityFrameworkCore.Projectables.Query; /// -/// A subclass that supports nodes -/// and generates CROSS APPLY clauses for reused local variables. +/// A subclass that emits CROSS APPLY (SQL Server) or +/// CROSS JOIN LATERAL (PostgreSQL) subquery clauses for local variables declared inside +/// block-bodied [Projectable] methods that are referenced more than once. /// -/// Before generating the main SELECT, it: -/// -/// On .NET 10 (EF Core 10): Rewrites multi-use -/// nodes into a CROSS APPLY (SELECT … AS [name]) AS [alias] table source, so -/// that the expression is computed exactly once per row. -/// Runs to detect duplicate -/// subtrees and replaces them with references. -/// Emits a WITH cteName AS (…) preamble for each collected CTE, in depth-first order. -/// +/// When the source generator detects that a local variable is used more than once, it wraps +/// every occurrence in a call to . EF Core's method +/// translator converts those calls to nodes. This +/// generator then hoists each multi-use variable into an inline subquery so that its expression +/// is evaluated exactly once per row. /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -public class CteAwareQuerySqlGenerator : QuerySqlGenerator +public class ProjectablesQuerySqlGenerator : QuerySqlGenerator { /// - public CteAwareQuerySqlGenerator(QuerySqlGeneratorDependencies dependencies) + public ProjectablesQuerySqlGenerator(QuerySqlGeneratorDependencies dependencies) : base(dependencies) { } - /// - protected override void GenerateRootCommand(Expression queryExpression) - { -#if !NET8_0 && !NET9_0 - if (queryExpression is SelectExpression selectExpr) - queryExpression = TransformVariableWrapsOnSelectExpression(selectExpr); -#endif - - var rewriter = new CteDeduplicatingRewriter(); - var rewritten = rewriter.Rewrite(queryExpression); - var ctes = rewriter.CollectedCtes; - - if (ctes.Count == 0) - { - // No CTEs found — fall through to base behaviour using the original expression - // (avoids any observable difference in SQL formatting caused by a no-op tree visit). - base.GenerateRootCommand(queryExpression); - return; - } - - Sql.Append("WITH "); - - for (var i = 0; i < ctes.Count; i++) - { - if (i > 0) - { - Sql.AppendLine(","); - } - - var cte = ctes[i]; - Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cte.CteName)); - Sql.AppendLine(" AS ("); - using (Sql.Indent()) - { - Visit(cte.Inner); - } - - Sql.AppendLine(); - Sql.Append(")"); - } - - Sql.AppendLine(); - base.GenerateRootCommand(rewritten); - } - /// protected override Expression VisitExtension(Expression expression) { switch (expression) { - case CteTableExpression cteTable: - return VisitCteTable(cteTable); - case VariableWrapSqlExpression wrap: - // Fall-through: emit the inner expression directly. - // This handles single-use wraps (not transformed to CROSS APPLY) and any - // platform where the CROSS APPLY transformation is not applied. + // Single-use wrap or unrecognised platform: emit the inner expression directly. return Visit(wrap.Inner); #if !NET8_0 && !NET9_0 - case VariableWrapCrossApplyExpression crossApply: - return VisitVariableWrapCrossApply(crossApply); + case InlineSubqueryExpression inlineSub: + return VisitInlineSubquery(inlineSub); #endif default: @@ -99,51 +46,60 @@ protected override Expression VisitExtension(Expression expression) } } +#if !NET8_0 && !NET9_0 /// - /// Emits the CTE reference as a bare name with alias separator (e.g., [cte_0] AS [cte_0]). - /// The CTE body is already emitted in the WITH preamble. + /// Returns if this generator is targeting PostgreSQL, which uses + /// CROSS JOIN LATERAL instead of SQL Server's CROSS APPLY. + /// Detection is based on the SQL identifier delimiter: SQL Server uses […], + /// PostgreSQL uses "…". /// - private Expression VisitCteTable(CteTableExpression cteTable) - { - Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cteTable.CteName)); - Sql.Append(AliasSeparator); - Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(cteTable.Alias!)); - return cteTable; - } + protected virtual bool IsPostgres + => Dependencies.SqlGenerationHelper.DelimitIdentifier("x").StartsWith('"'); -#if !NET8_0 && !NET9_0 /// - /// Emits the CROSS APPLY subquery body, e.g. - /// (SELECT [e].[Bar] * 2 AS [temp]) AS [_cv0]. + /// Emits the inline subquery that materialises a reused local variable exactly once per row. + /// The JOIN keyword itself (CROSS APPLY vs CROSS JOIN LATERAL) is controlled + /// by . /// - private Expression VisitVariableWrapCrossApply(VariableWrapCrossApplyExpression crossApply) + private Expression VisitInlineSubquery(InlineSubqueryExpression inlineSub) { Sql.AppendLine("("); using (Sql.Indent()) { Sql.Append("SELECT "); - Visit(crossApply.Inner); + Visit(inlineSub.Inner); Sql.Append(AliasSeparator); - Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(crossApply.ColumnName)); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(inlineSub.ColumnName)); } Sql.AppendLine(); Sql.Append(")"); Sql.Append(AliasSeparator); - Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(crossApply.Alias!)); - return crossApply; + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(inlineSub.Alias!)); + return inlineSub; + } + + /// + /// Overrides cross-apply emission to use CROSS JOIN LATERAL when targeting + /// PostgreSQL (or any provider that uses double-quote identifiers) for + /// tables; delegates to the base implementation for all other table types. + /// + protected override Expression VisitCrossApply(CrossApplyExpression crossApplyExpression) + { + if (IsPostgres && crossApplyExpression.Table is InlineSubqueryExpression inlineSub) + { + Sql.Append("CROSS JOIN LATERAL "); + return VisitInlineSubquery(inlineSub); + } + + return base.VisitCrossApply(crossApplyExpression); } /// /// Rewrites any nodes that appear more than once - /// in the root into CROSS APPLY table sources. + /// in the root into inline subquery table sources. /// Single-use nodes are lowered to their inner expression. /// - /// - /// This is called from both and - /// from the postprocessor () so that - /// the transformation runs before SqlNullabilityProcessor sees the expression tree. - /// internal static SelectExpression TransformVariableWrapsOnSelectExpression(SelectExpression rootSelect) { // Scan the entire expression for VariableWrap occurrences, grouped by variable name. @@ -158,26 +114,31 @@ internal static SelectExpression TransformVariableWrapsOnSelectExpression(Select if (multiUse.Count == 0) return rootSelect; - // Build the column mapping: variableName → ColumnExpression referencing the CROSS APPLY. + // Build column mapping: variableName → ColumnExpression referencing the subquery table. var columnMapping = new Dictionary(StringComparer.Ordinal); - var crossApplyTables = new List(); + var inlineSubqueryTables = new List(); var counter = 0; - foreach (var (name, wraps) in multiUse) + foreach (var (variableName, wraps) in multiUse) { - var tableAlias = $"w{counter++}"; + // Use "v" (for "variable") as the table-alias prefix to satisfy EF Core's + // alias normalization (letter + optional counter: "v", "v0", "v1", …). + // The original variable name is used as the column name inside the subquery so + // the SQL still shows the meaningful name (e.g. SELECT … AS [doubled]). + var tableAlias = counter == 0 ? "v" : $"v{counter}"; + counter++; var first = wraps[0]; - var body = new VariableWrapCrossApplyExpression(tableAlias, first.Inner, name); - crossApplyTables.Add(new CrossApplyExpression(body)); - columnMapping[name] = new ColumnExpression(name, tableAlias, first.Type, first.TypeMapping!, false); + var subquery = new InlineSubqueryExpression(tableAlias, first.Inner, variableName); + inlineSubqueryTables.Add(new CrossApplyExpression(subquery)); + columnMapping[variableName] = new ColumnExpression(variableName, tableAlias, first.Type, first.TypeMapping!, false); } // Rewrite: replace VariableWrapSqlExpression with the column references. var replacer = new VariableWrapReplacer(columnMapping); var rewritten = (SelectExpression)replacer.Visit(rootSelect); - // Append the new CROSS APPLY table sources. - rewritten.SetTables([.. rewritten.Tables, .. crossApplyTables]); + // Append the new subquery table sources. + rewritten.SetTables([.. rewritten.Tables, .. inlineSubqueryTables]); return rewritten; } @@ -212,13 +173,13 @@ protected override Expression VisitExtension(Expression node) /// references from the provided mapping. /// Nodes whose name is not in the mapping (single-use) are lowered to the inner expression. /// Does not recurse into nested nodes that are table sources, - /// since the CROSS APPLY is only added at the root level. + /// since the inline subquery is only added at the root level. /// private sealed class VariableWrapReplacer(IReadOnlyDictionary mapping) : ExpressionVisitor { // 0 = not yet inside any SelectExpression; 1 = in the root SELECT (replace here); // 2+ = in a nested SelectExpression (leave Variable.Wrap untouched — it belongs to a - // different scope and would have its own CROSS APPLY if it were at the root). + // different scope and would have its own subquery if it were at the root). private int _depth; protected override Expression VisitExtension(Expression node) diff --git a/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs new file mode 100644 index 0000000..75ee69f --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Query; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// An that produces +/// instances, which add CROSS APPLY / +/// CROSS JOIN LATERAL subquery support for reused local variables in block-bodied +/// [Projectable] methods. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public class ProjectablesQuerySqlGeneratorFactory(QuerySqlGeneratorDependencies dependencies) + : IQuerySqlGeneratorFactory +{ + /// + public QuerySqlGenerator Create() + => new ProjectablesQuerySqlGenerator(dependencies); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs deleted file mode 100644 index 5cf9693..0000000 --- a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/CteTableExpression.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Query; -using Microsoft.EntityFrameworkCore.Query.SqlExpressions; - -namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; - -/// -/// Represents a SQL Common Table Expression (CTE) definition: -/// WITH AS (). -/// Acts as a table source; references to it are ColumnExpressions -/// whose TableAlias matches this expression's Alias. -/// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -public sealed class CteTableExpression : TableExpressionBase -#if NET8_0 - , IClonableTableExpressionBase -#endif -{ - /// Creates a new with the given name and body. - public CteTableExpression(string alias, SelectExpression inner) - : base(alias) - { - Inner = inner; - } - - private CteTableExpression(string alias, SelectExpression inner, IReadOnlyDictionary annotations) -#if NET8_0 - : base(alias, annotations.Values) -#else - : base(alias, annotations) -#endif - { - Inner = inner; - } - - /// The alias used as the CTE name in the WITH clause. - public string CteName => Alias!; - - /// The that defines the CTE body. - public SelectExpression Inner { get; } - - protected override Expression VisitChildren(ExpressionVisitor visitor) - { - var newInner = (SelectExpression)visitor.Visit(Inner); - return newInner != Inner - ? new CteTableExpression(CteName, newInner, GetAnnotations().ToDictionary(a => a.Name, a => a)) - : this; - } - -#if NET8_0 - /// Creates a clone of this expression. - public TableExpressionBase Clone() - => new CteTableExpression(CteName, Inner, GetAnnotations().ToDictionary(a => a.Name, a => a)); - - /// - protected override TableExpressionBase CreateWithAnnotations(IEnumerable annotations) - => new CteTableExpression(CteName, Inner, annotations.ToDictionary(a => a.Name, a => a)); -#else - /// - public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor) - => new CteTableExpression(alias ?? CteName, (SelectExpression)cloningExpressionVisitor.Visit(Inner), GetAnnotations().ToDictionary(a => a.Name, a => a)); - - /// - public override TableExpressionBase WithAlias(string newAlias) - => new CteTableExpression(newAlias, Inner, GetAnnotations().ToDictionary(a => a.Name, a => a)); - - /// - protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) - => new CteTableExpression(CteName, Inner, annotations); - - /// - public override Expression Quote() - => throw new NotSupportedException($"{nameof(CteTableExpression)} does not support pre-compiled queries."); -#endif - - /// - protected override void Print(ExpressionPrinter expressionPrinter) - { - expressionPrinter.Append($"CTE:{CteName}("); - expressionPrinter.Visit(Inner); - expressionPrinter.Append(")"); - } - - /// - public override bool Equals(object? obj) - => obj is CteTableExpression other - && CteName == other.CteName - && Inner.Equals(other.Inner); - - /// - public override int GetHashCode() => HashCode.Combine(CteName, Inner); -} diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs similarity index 62% rename from src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs rename to src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs index 3d9d6f9..8e78f72 100644 --- a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapCrossApplyExpression.cs +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs @@ -7,26 +7,29 @@ namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; /// -/// A that represents the inner subquery of a -/// CROSS APPLY (SELECT AS []) AS [alias] -/// expression added by to materialise a -/// exactly once per row. +/// A that represents a single-column inline subquery used +/// to materialise a reused local variable exactly once per row: +/// +/// (SELECT <Inner> AS [<ColumnName>]) AS [<Alias>] +/// +/// This is emitted by as the body of a +/// CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) clause. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] -public sealed class VariableWrapCrossApplyExpression : TableExpressionBase +public sealed class InlineSubqueryExpression : TableExpressionBase { /// Creates a new instance. - /// The SQL table alias (e.g. _cv0) for the CROSS APPLY table. - /// The SQL expression to project as a single column. - /// The name to give the projected column (the variable name). - public VariableWrapCrossApplyExpression(string alias, SqlExpression inner, string columnName) + /// The SQL table alias for the inline subquery. + /// The SQL expression projected as a single column. + /// The name of the projected column (the original variable name). + public InlineSubqueryExpression(string alias, SqlExpression inner, string columnName) : base(alias) { Inner = inner; ColumnName = columnName; } - private VariableWrapCrossApplyExpression( + private InlineSubqueryExpression( string alias, SqlExpression inner, string columnName, @@ -37,10 +40,10 @@ private VariableWrapCrossApplyExpression( ColumnName = columnName; } - /// The expression computed inside the CROSS APPLY subquery. + /// The expression computed inside the inline subquery. public SqlExpression Inner { get; } - /// The projected column name inside the CROSS APPLY subquery. + /// The projected column name (the original local-variable name). public string ColumnName { get; } /// @@ -49,13 +52,13 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) var newInner = (SqlExpression)visitor.Visit(Inner); return ReferenceEquals(newInner, Inner) ? this - : new VariableWrapCrossApplyExpression(Alias!, newInner, ColumnName, + : new InlineSubqueryExpression(Alias!, newInner, ColumnName, GetAnnotations().ToDictionary(a => a.Name, a => a)); } /// public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor) - => new VariableWrapCrossApplyExpression( + => new InlineSubqueryExpression( alias ?? Alias!, (SqlExpression)cloningExpressionVisitor.Visit(Inner), ColumnName, @@ -63,16 +66,16 @@ public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloni /// public override TableExpressionBase WithAlias(string newAlias) - => new VariableWrapCrossApplyExpression(newAlias, Inner, ColumnName, + => new InlineSubqueryExpression(newAlias, Inner, ColumnName, GetAnnotations().ToDictionary(a => a.Name, a => a)); /// protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) - => new VariableWrapCrossApplyExpression(Alias!, Inner, ColumnName, annotations); + => new InlineSubqueryExpression(Alias!, Inner, ColumnName, annotations); /// public override Expression Quote() - => throw new NotSupportedException($"{nameof(VariableWrapCrossApplyExpression)} does not support pre-compiled queries."); + => throw new NotSupportedException($"{nameof(InlineSubqueryExpression)} does not support pre-compiled queries."); /// protected override void Print(ExpressionPrinter expressionPrinter) @@ -84,7 +87,7 @@ protected override void Print(ExpressionPrinter expressionPrinter) /// public override bool Equals(object? obj) - => obj is VariableWrapCrossApplyExpression other + => obj is InlineSubqueryExpression other && Alias == other.Alias && ColumnName == other.ColumnName && Inner.Equals(other.Inner); diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs index 0c58ff8..2c32ac0 100644 --- a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs @@ -12,9 +12,9 @@ namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; /// The source generator wraps each occurrence of a reused local variable in a call to /// . EF Core's method-call translator converts those /// calls to nodes. The -/// then replaces multi-occurrence groups with a -/// CROSS APPLY (SELECT … AS [name]) AS [alias] table source so that the expression is -/// computed exactly once per row. +/// then replaces multi-occurrence groups with a +/// CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) inline subquery so +/// that the expression is computed exactly once per row. /// /// /// Single-occurrence nodes (where the variable is only used once) are lowered to the plain diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs index 71e406d..0958ab8 100644 --- a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs @@ -11,9 +11,9 @@ namespace EntityFrameworkCore.Projectables.Query; /// /// This postprocessor runs before the relational nullability processor so that /// nodes — which EF Core's nullability processor does -/// not understand — are either replaced by CROSS APPLY table sources (on .NET 10 / EF -/// Core 10) or lowered to their inner expressions (on earlier versions) before they can cause -/// an exception. +/// not understand — are either hoisted into CROSS APPLY / CROSS JOIN LATERAL +/// subqueries (on .NET 10 / EF Core 10+) or lowered to their inner expressions (on earlier +/// versions) before the nullability processor encounters them. /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] @@ -34,11 +34,12 @@ public QueryTranslationPostprocessor Create(QueryCompilationContext queryCompila /// Processes nodes in the SQL expression tree before /// delegating to the inner postprocessor. /// -/// On .NET 10 / EF Core 10: Replaces multi-use -/// groups with CROSS APPLY (SELECT … AS [name]) AS [alias] table sources -/// and references. +/// On .NET 10 / EF Core 10+: Replaces multi-use +/// groups with CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) +/// inline subquery table sources so each local variable expression is computed exactly +/// once per row. /// On earlier versions: Lowers every to its -/// plain inner expression (identity semantics). +/// plain inner expression (identity semantics, no deduplication). /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] @@ -65,7 +66,7 @@ private static Expression TransformVariableWraps(Expression query) return query; #if !NET8_0 && !NET9_0 - var transformed = CteAwareQuerySqlGenerator.TransformVariableWrapsOnSelectExpression(selectExpression); + var transformed = ProjectablesQuerySqlGenerator.TransformVariableWrapsOnSelectExpression(selectExpression); return ReferenceEquals(transformed, selectExpression) ? query : shaped.UpdateQueryExpression(transformed); diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt index b29d69c..aa1ba90 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ -SELECT [w].[doubled] + [w].[doubled] +SELECT [v].[doubled] + [v].[doubled] FROM [Entity] AS [e] CROSS APPLY ( SELECT [e].[Value] * 2 AS [doubled] -) AS [w] \ No newline at end of file +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleNpgsqlDbContext.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleNpgsqlDbContext.cs new file mode 100644 index 0000000..45ca514 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/Helpers/SampleNpgsqlDbContext.cs @@ -0,0 +1,71 @@ +using System.Text; +using EntityFrameworkCore.Projectables.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.Projectables.FunctionalTests.Helpers; + +/// +/// A test that simulates a PostgreSQL / double-quote-delimiter +/// provider by replacing EF Core's with one that uses +/// "identifier" quoting. +/// +/// detects the provider by checking whether +/// starts with a double quote. +/// This makes the generator emit CROSS JOIN LATERAL instead of SQL Server's +/// CROSS APPLY. No real database connection is required — tests only call +/// . +/// +/// +public sealed class SampleNpgsqlDbContext : DbContext + where TEntity : class +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + // Fake connection string — we never actually connect. + optionsBuilder.UseSqlServer("Server=(localdb)\\v11.0;Integrated Security=true"); + // Replace the SQL generation helper so DelimitIdentifier uses "…" quoting, which + // causes ProjectablesQuerySqlGenerator.IsPostgres to return true. + optionsBuilder.ReplaceService(); + optionsBuilder.UseProjectables(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + } +} + +/// +/// An that uses double-quote (") identifier +/// delimiters to simulate PostgreSQL quoting style for test purposes. +/// +internal sealed class PostgresStyleSqlGenerationHelper(RelationalSqlGenerationHelperDependencies dependencies) + : RelationalSqlGenerationHelper(dependencies) +{ + public override string DelimitIdentifier(string identifier) + => '"' + EscapeIdentifier(identifier) + '"'; + + public override void DelimitIdentifier(StringBuilder builder, string identifier) + { + builder.Append('"'); + EscapeIdentifier(builder, identifier); + builder.Append('"'); + } + + public override string DelimitIdentifier(string name, string? schema) + => schema is null + ? DelimitIdentifier(name) + : DelimitIdentifier(schema) + "." + DelimitIdentifier(name); + + public override void DelimitIdentifier(StringBuilder builder, string name, string? schema) + { + if (schema is not null) + { + DelimitIdentifier(builder, schema); + builder.Append('.'); + } + + DelimitIdentifier(builder, name); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet10_0.verified.txt new file mode 100644 index 0000000..4facc77 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT "v"."capped" + "v"."capped" +FROM "Entity" AS "e" +CROSS JOIN LATERAL ( + SELECT CASE + WHEN "e"."Value" > 100 THEN 100 + ELSE "e"."Value" + END AS "capped" +) AS "v" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet9_0.verified.txt new file mode 100644 index 0000000..ddb9bb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.DotNet9_0.verified.txt @@ -0,0 +1,8 @@ +SELECT CASE + WHEN "e"."Value" > 100 THEN 100 + ELSE "e"."Value" +END + CASE + WHEN "e"."Value" > 100 THEN 100 + ELSE "e"."Value" +END +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.verified.txt new file mode 100644 index 0000000..ddb9bb7 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.ConditionalExpressionInVariable_Reused_GeneratesLateral.verified.txt @@ -0,0 +1,8 @@ +SELECT CASE + WHEN "e"."Value" > 100 THEN 100 + ELSE "e"."Value" +END + CASE + WHEN "e"."Value" > 100 THEN 100 + ELSE "e"."Value" +END +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet10_0.verified.txt new file mode 100644 index 0000000..bccc476 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT "v"."inner" + "v"."inner" +FROM "Entity" AS "e" +CROSS JOIN LATERAL ( + SELECT "e"."Value" * 2 AS "inner" +) AS "v" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet9_0.verified.txt new file mode 100644 index 0000000..f49cb97 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + ("e"."Value" * 2) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.verified.txt new file mode 100644 index 0000000..f49cb97 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.NestedProjectableCall_ResultReused_GeneratesLateral.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + ("e"."Value" * 2) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt new file mode 100644 index 0000000..74c4021 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + 1 +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt new file mode 100644 index 0000000..74c4021 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + 1 +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.verified.txt new file mode 100644 index 0000000..74c4021 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedOnce_IsInlined.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + 1 +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet10_0.verified.txt new file mode 100644 index 0000000..fb1b679 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT "v"."doubled" + "v"."doubled" +FROM "Entity" AS "e" +CROSS JOIN LATERAL ( + SELECT "e"."Value" * 2 AS "doubled" +) AS "v" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet9_0.verified.txt new file mode 100644 index 0000000..f49cb97 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + ("e"."Value" * 2) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.verified.txt new file mode 100644 index 0000000..f49cb97 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.SingleVariable_UsedTwice_GeneratesLateral.verified.txt @@ -0,0 +1,2 @@ +SELECT ("e"."Value" * 2) + ("e"."Value" * 2) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet10_0.verified.txt new file mode 100644 index 0000000..c162e75 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT ("v"."doubled" * "v"."doubled") + ("v0"."tripled" * "v0"."tripled") +FROM "Entity" AS "e" +CROSS JOIN LATERAL ( + SELECT "e"."Value" * 2 AS "doubled" +) AS "v" +CROSS JOIN LATERAL ( + SELECT "e"."Value" * 3 AS "tripled" +) AS "v0" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet9_0.verified.txt new file mode 100644 index 0000000..3f9c88c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT (("e"."Value" * 2) * ("e"."Value" * 2)) + (("e"."Value" * 3) * ("e"."Value" * 3)) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.verified.txt new file mode 100644 index 0000000..3f9c88c --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.TwoVariables_EachUsedTwice_GeneratesTwoLaterals.verified.txt @@ -0,0 +1,2 @@ +SELECT (("e"."Value" * 2) * ("e"."Value" * 2)) + (("e"."Value" * 3) * ("e"."Value" * 3)) +FROM "Entity" AS "e" \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet10_0.verified.txt new file mode 100644 index 0000000..c20de40 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +SELECT "e"."Id", "e"."IsActive", "e"."Name", "e"."Score", "e"."Value" +FROM "Entity" AS "e" +CROSS JOIN LATERAL ( + SELECT "e"."Score" * 2 AS "adjusted" +) AS "v" +WHERE "v"."adjusted" > 50 AND "v"."adjusted" < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet9_0.verified.txt new file mode 100644 index 0000000..5fd4afe --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.DotNet9_0.verified.txt @@ -0,0 +1,3 @@ +SELECT "e"."Id", "e"."IsActive", "e"."Name", "e"."Score", "e"."Value" +FROM "Entity" AS "e" +WHERE ("e"."Score" * 2) > 50 AND ("e"."Score" * 2) < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.verified.txt new file mode 100644 index 0000000..5fd4afe --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.VariableReuseInWhere_GeneratesLateral.verified.txt @@ -0,0 +1,3 @@ +SELECT "e"."Id", "e"."IsActive", "e"."Name", "e"."Score", "e"."Value" +FROM "Entity" AS "e" +WHERE ("e"."Score" * 2) > 50 AND ("e"."Score" * 2) < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.cs new file mode 100644 index 0000000..3c6ede5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseLateralTests.cs @@ -0,0 +1,146 @@ +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +/// +/// Verifies that local variables declared in block-bodied [Projectable] methods are +/// hoisted into CROSS JOIN LATERAL (SELECT … AS "variableName") AS "variableName" +/// subqueries when using a PostgreSQL provider (Npgsql) and the variable is referenced more than +/// once. +/// +[UsesVerify] +public class LocalVariableReuseLateralTests +{ + public record Entity + { + public int Id { get; set; } + public int Value { get; set; } + public bool IsActive { get; set; } + public string? Name { get; set; } + public int Score { get; set; } + } + + // ── single reused variable ───────────────────────────────────────────────────────── + + /// + /// A single local variable used twice generates one CROSS JOIN LATERAL. + /// + [Fact] + public Task SingleVariable_UsedTwice_GeneratesLateral() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Select(x => x.Lateral_DoubledTwice()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A single local variable used only once is inlined — no CROSS JOIN LATERAL. + /// + [Fact] + public Task SingleVariable_UsedOnce_IsInlined() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Select(x => x.Lateral_DoubledOnce()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// Two local variables both used twice generate two CROSS JOIN LATERAL clauses. + /// + [Fact] + public Task TwoVariables_EachUsedTwice_GeneratesTwoLaterals() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Select(x => x.Lateral_TwoReuseVariables()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A reused variable in a WHERE clause generates a CROSS JOIN LATERAL. + /// + [Fact] + public Task VariableReuseInWhere_GeneratesLateral() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Where(x => x.Lateral_IsHighScorer()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A block-bodied method that calls another projectable and stores the result in a local + /// variable used twice generates a CROSS JOIN LATERAL for the composed expression. + /// + [Fact] + public Task NestedProjectableCall_ResultReused_GeneratesLateral() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Select(x => x.Lateral_ReuseNestedProjectable()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A conditional expression stored in a reused local variable. + /// + [Fact] + public Task ConditionalExpressionInVariable_Reused_GeneratesLateral() + { + using var dbContext = new SampleNpgsqlDbContext(); + var query = dbContext.Set().Select(x => x.Lateral_ReuseConditional()); + return Verifier.Verify(query.ToQueryString()); + } +} + +// ── projectable method definitions ──────────────────────────────────────────────────────── + +public static class LateralVariableReuseExtensions +{ + [Projectable(AllowBlockBody = true)] + public static int Lateral_DoubledTwice(this LocalVariableReuseLateralTests.Entity e) + { + var doubled = e.Value * 2; + return doubled + doubled; + } + + [Projectable(AllowBlockBody = true)] + public static int Lateral_DoubledOnce(this LocalVariableReuseLateralTests.Entity e) + { + var doubled = e.Value * 2; + return doubled + 1; + } + + [Projectable(AllowBlockBody = true)] + public static int Lateral_TwoReuseVariables(this LocalVariableReuseLateralTests.Entity e) + { + var doubled = e.Value * 2; + var tripled = e.Value * 3; + return doubled * doubled + tripled * tripled; + } + + [Projectable(AllowBlockBody = true)] + public static bool Lateral_IsHighScorer(this LocalVariableReuseLateralTests.Entity e) + { + var adjusted = e.Score * 2; + return adjusted > 50 && adjusted < 200; + } + + [Projectable(AllowBlockBody = true)] + public static int Lateral_GetDoubledValue(this LocalVariableReuseLateralTests.Entity e) + => e.Value * 2; + + [Projectable(AllowBlockBody = true)] + public static int Lateral_ReuseNestedProjectable(this LocalVariableReuseLateralTests.Entity e) + { + var inner = e.Lateral_GetDoubledValue(); + return inner + inner; + } + + [Projectable(AllowBlockBody = true)] + public static int Lateral_ReuseConditional(this LocalVariableReuseLateralTests.Entity e) + { + var capped = e.Value > 100 ? 100 : e.Value; + return capped + capped; + } +} diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..8e2d351 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT [v].[capped] + [v].[capped] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT CASE + WHEN [e].[Value] > 100 THEN 100 + ELSE [e].[Value] + END AS [capped] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..38d4739 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,8 @@ +SELECT CASE + WHEN [e].[Value] > 100 THEN 100 + ELSE [e].[Value] +END + CASE + WHEN [e].[Value] > 100 THEN 100 + ELSE [e].[Value] +END +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..38d4739 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.ConditionalExpressionInVariable_Reused_GeneratesCrossApply.verified.txt @@ -0,0 +1,8 @@ +SELECT CASE + WHEN [e].[Value] > 100 THEN 100 + ELSE [e].[Value] +END + CASE + WHEN [e].[Value] > 100 THEN 100 + ELSE [e].[Value] +END +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..e794a31 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT ([v].[doubled] + [v].[doubled]) + ([e].[Score] + 100) +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT ([e].[Score] + 100) * 2 AS [doubled] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..9f67a69 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ((([e].[Score] + 100) * 2) + (([e].[Score] + 100) * 2)) + ([e].[Score] + 100) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..9f67a69 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.DeeplyNestedChain_WithReuse_GeneratesCrossApply.verified.txt @@ -0,0 +1,2 @@ +SELECT ((([e].[Score] + 100) * 2) + (([e].[Score] + 100) * 2)) + ([e].[Score] + 100) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..8988c84 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT ([v].[multiplied] + [v].[multiplied]) + ([e].[Score] + 10) +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 4 AS [multiplied] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..2db1af5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 4) + ([e].[Value] * 4)) + ([e].[Score] + 10) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.verified.txt new file mode 100644 index 0000000..2db1af5 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.MixedReuse_OnlyReusedVariableGetsCrossApply.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 4) + ([e].[Value] * 4)) + ([e].[Score] + 10) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..7f0f729 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [v].[inner] + [v].[inner] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 2 AS [inner] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..ac1ac94 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + ([e].[Value] * 2) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..ac1ac94 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.NestedProjectableCall_ResultReused_GeneratesCrossApply.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + ([e].[Value] * 2) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt new file mode 100644 index 0000000..3d9f228 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet10_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + 1 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt new file mode 100644 index 0000000..3d9f228 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + 1 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.verified.txt new file mode 100644 index 0000000..3d9f228 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedOnce_IsInlined.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + 1 +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..bc1e389 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT ([v].[score] + [v].[score]) + [v].[score] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 10 AS [score] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..1843d0d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 10) + ([e].[Value] * 10)) + ([e].[Value] * 10) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.verified.txt new file mode 100644 index 0000000..1843d0d --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedThreeTimes_GeneratesOneCrossApply.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 10) + ([e].[Value] * 10)) + ([e].[Value] * 10) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..aa1ba90 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,5 @@ +SELECT [v].[doubled] + [v].[doubled] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 2 AS [doubled] +) AS [v] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..ac1ac94 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + ([e].[Value] * 2) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..ac1ac94 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.SingleVariable_UsedTwice_GeneratesCrossApply.verified.txt @@ -0,0 +1,2 @@ +SELECT ([e].[Value] * 2) + ([e].[Value] * 2) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet10_0.verified.txt new file mode 100644 index 0000000..a7cb32f --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT ([v].[doubled] * [v].[doubled]) + ([v0].[tripled] * [v0].[tripled]) +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] * 2 AS [doubled] +) AS [v] +CROSS APPLY ( + SELECT [e].[Value] * 3 AS [tripled] +) AS [v0] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet9_0.verified.txt new file mode 100644 index 0000000..03c2431 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.DotNet9_0.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 2) * ([e].[Value] * 2)) + (([e].[Value] * 3) * ([e].[Value] * 3)) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.verified.txt new file mode 100644 index 0000000..03c2431 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies.verified.txt @@ -0,0 +1,2 @@ +SELECT (([e].[Value] * 2) * ([e].[Value] * 2)) + (([e].[Value] * 3) * ([e].[Value] * 3)) +FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..d589418 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +SELECT ([e].[Value] - 5) * 2 +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Value] - 5 AS [adjusted] +) AS [v] +WHERE [v].[adjusted] > 0 AND [v].[adjusted] < 100 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..4e64278 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,3 @@ +SELECT ([e].[Value] - 5) * 2 +FROM [Entity] AS [e] +WHERE ([e].[Value] - 5) > 0 AND ([e].[Value] - 5) < 100 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..4e64278 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseAcrossSelectAndWhere_GeneratesCrossApply.verified.txt @@ -0,0 +1,3 @@ +SELECT ([e].[Value] - 5) * 2 +FROM [Entity] AS [e] +WHERE ([e].[Value] - 5) > 0 AND ([e].[Value] - 5) < 100 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet10_0.verified.txt new file mode 100644 index 0000000..f467d54 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet10_0.verified.txt @@ -0,0 +1,6 @@ +SELECT [e].[Id], [e].[IsActive], [e].[Name], [e].[Score], [e].[Value] +FROM [Entity] AS [e] +CROSS APPLY ( + SELECT [e].[Score] * 2 AS [adjusted] +) AS [v] +WHERE [v].[adjusted] > 50 AND [v].[adjusted] < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet9_0.verified.txt new file mode 100644 index 0000000..2be2269 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.DotNet9_0.verified.txt @@ -0,0 +1,3 @@ +SELECT [e].[Id], [e].[IsActive], [e].[Name], [e].[Score], [e].[Value] +FROM [Entity] AS [e] +WHERE ([e].[Score] * 2) > 50 AND ([e].[Score] * 2) < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.verified.txt new file mode 100644 index 0000000..2be2269 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.VariableReuseInWhere_GeneratesCrossApply.verified.txt @@ -0,0 +1,3 @@ +SELECT [e].[Id], [e].[IsActive], [e].[Name], [e].[Score], [e].[Value] +FROM [Entity] AS [e] +WHERE ([e].[Score] * 2) > 50 AND ([e].[Score] * 2) < 200 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.cs new file mode 100644 index 0000000..0448c96 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/LocalVariableReuseTests.cs @@ -0,0 +1,253 @@ +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +/// +/// Verifies that local variables declared in block-bodied [Projectable] methods are +/// hoisted into CROSS APPLY (SELECT … AS [variableName]) AS [variableName] inline +/// subqueries when the same variable is referenced more than once. This ensures that complex +/// expressions are computed exactly once per row instead of being inlined multiple times. +/// +[UsesVerify] +public class LocalVariableReuseTests +{ + public record Entity + { + public int Id { get; set; } + public int Value { get; set; } + public bool IsActive { get; set; } + public string? Name { get; set; } + public int Score { get; set; } + } + + // ── single reused variable ───────────────────────────────────────────────────────── + + /// + /// A single local variable used twice generates one CROSS APPLY. + /// + [Fact] + public Task SingleVariable_UsedTwice_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.DoubledTwice()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A single local variable used three times still generates one CROSS APPLY. + /// + [Fact] + public Task SingleVariable_UsedThreeTimes_GeneratesOneCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.TripleReuse()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A local variable used only once is inlined — no CROSS APPLY. + /// + [Fact] + public Task SingleVariable_UsedOnce_IsInlined() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.DoubledOnce()); + return Verifier.Verify(query.ToQueryString()); + } + + // ── multiple reused variables ────────────────────────────────────────────────────── + + /// + /// Two local variables both used twice generate two CROSS APPLY clauses. + /// + [Fact] + public Task TwoVariables_EachUsedTwice_GeneratesTwoCrossApplies() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.TwoReuseVariables()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// First variable used twice and second variable used once — only the reused one gets a + /// CROSS APPLY. + /// + [Fact] + public Task MixedReuse_OnlyReusedVariableGetsCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.MixedReuse()); + return Verifier.Verify(query.ToQueryString()); + } + + // ── reuse in WHERE clause ────────────────────────────────────────────────────────── + + /// + /// A reused variable in a WHERE clause generates a CROSS APPLY. + /// + [Fact] + public Task VariableReuseInWhere_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Where(x => x.IsHighScorer()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A reused variable shared between SELECT projection and WHERE filter. + /// + [Fact] + public Task VariableReuseAcrossSelectAndWhere_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set() + .Where(x => x.IsPositiveAdjusted()) + .Select(x => x.GetAdjustedValue()); + return Verifier.Verify(query.ToQueryString()); + } + + // ── nested / chained projectable calls ──────────────────────────────────────────── + + /// + /// A block-bodied method that calls another projectable and stores the result in a local + /// variable used twice generates a CROSS APPLY for the composed expression. + /// + [Fact] + public Task NestedProjectableCall_ResultReused_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.ReuseNestedProjectable()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A deeply nested chain: outer calls middle which calls inner; the middle result is + /// reused in the outer body. + /// + [Fact] + public Task DeeplyNestedChain_WithReuse_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.DeepChainWithReuse()); + return Verifier.Verify(query.ToQueryString()); + } + + /// + /// A block-bodied method stores a conditional expression in a reused local variable. + /// + [Fact] + public Task ConditionalExpressionInVariable_Reused_GeneratesCrossApply() + { + using var dbContext = new SampleDbContext(); + var query = dbContext.Set().Select(x => x.ReuseConditional()); + return Verifier.Verify(query.ToQueryString()); + } + + /// +} + +// ── projectable method definitions ──────────────────────────────────────────────────────── + +public static class LocalVariableReuseExtensions +{ + // ── basic numeric reuse ──────────────────────────────────────────────────────────── + + [Projectable(AllowBlockBody = true)] + public static int DoubledTwice(this LocalVariableReuseTests.Entity e) + { + var doubled = e.Value * 2; + return doubled + doubled; + } + + [Projectable(AllowBlockBody = true)] + public static int TripleReuse(this LocalVariableReuseTests.Entity e) + { + var score = e.Value * 10; + return score + score + score; + } + + [Projectable(AllowBlockBody = true)] + public static int DoubledOnce(this LocalVariableReuseTests.Entity e) + { + var doubled = e.Value * 2; + return doubled + 1; + } + + // ── two reused variables ─────────────────────────────────────────────────────────── + + [Projectable(AllowBlockBody = true)] + public static int TwoReuseVariables(this LocalVariableReuseTests.Entity e) + { + var doubled = e.Value * 2; + var tripled = e.Value * 3; + return doubled * doubled + tripled * tripled; + } + + [Projectable(AllowBlockBody = true)] + public static int MixedReuse(this LocalVariableReuseTests.Entity e) + { + var multiplied = e.Value * 4; // reused twice + var offset = e.Score + 10; // used once + return multiplied + multiplied + offset; + } + + // ── WHERE clause ────────────────────────────────────────────────────────────────── + + [Projectable(AllowBlockBody = true)] + public static bool IsHighScorer(this LocalVariableReuseTests.Entity e) + { + var adjusted = e.Score * 2; + return adjusted > 50 && adjusted < 200; + } + + [Projectable(AllowBlockBody = true)] + public static bool IsPositiveAdjusted(this LocalVariableReuseTests.Entity e) + { + var adjusted = e.Value - 5; + return adjusted > 0 && adjusted < 100; + } + + [Projectable(AllowBlockBody = true)] + public static int GetAdjustedValue(this LocalVariableReuseTests.Entity e) + { + var adjusted = e.Value - 5; + return adjusted * 2; + } + + // ── nested projectable ──────────────────────────────────────────────────────────── + + [Projectable(AllowBlockBody = true)] + public static int GetDoubledValue(this LocalVariableReuseTests.Entity e) + => e.Value * 2; + + [Projectable(AllowBlockBody = true)] + public static int ReuseNestedProjectable(this LocalVariableReuseTests.Entity e) + { + var inner = e.GetDoubledValue(); // calls another projectable + return inner + inner; // reuse → CROSS APPLY + } + + [Projectable(AllowBlockBody = true)] + public static int GetAdjustedScore(this LocalVariableReuseTests.Entity e) + => e.Score + 100; + + [Projectable(AllowBlockBody = true)] + public static int DeepChainWithReuse(this LocalVariableReuseTests.Entity e) + { + var mid = e.GetAdjustedScore(); // calls GetAdjustedScore + var doubled = mid * 2; // further computation + return doubled + doubled + mid; // doubled reused, mid once + } + + // ── conditional / string ────────────────────────────────────────────────────────── + + [Projectable(AllowBlockBody = true)] + public static int ReuseConditional(this LocalVariableReuseTests.Entity e) + { + var capped = e.Value > 100 ? 100 : e.Value; + return capped + capped; + } +} diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt similarity index 100% rename from tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaConcat_IsExtractedToCte.DotNet10_0.verified.txt rename to tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet9_0.verified.txt new file mode 100644 index 0000000..6f59285 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet9_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= 1 AND [e].[Id] <= 5 +UNION ALL +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.verified.txt new file mode 100644 index 0000000..6f59285 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= 1 AND [e].[Id] <= 5 +UNION ALL +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt similarity index 100% rename from tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaSelfJoin_IsExtractedToCte.DotNet10_0.verified.txt rename to tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet9_0.verified.txt new file mode 100644 index 0000000..615eff9 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet9_0.verified.txt @@ -0,0 +1,8 @@ +SELECT [e].[Id] AS [OuterId], [e1].[Name] AS [InnerName] +FROM [Entity] AS [e] +INNER JOIN ( + SELECT [e0].[Id], [e0].[Name] + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 10 +) AS [e1] ON [e].[Id] = ([e1].[Id] + 1) +WHERE [e].[Id] >= 1 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.verified.txt new file mode 100644 index 0000000..96a2439 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.verified.txt @@ -0,0 +1,8 @@ +SELECT [e].[Id] AS [OuterId], [t].[Name] AS [InnerName] +FROM [Entity] AS [e] +INNER JOIN ( + SELECT [e0].[Id], [e0].[Name] + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 10 +) AS [t] ON [e].[Id] = ([t].[Id] + 1) +WHERE [e].[Id] >= 1 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt similarity index 100% rename from tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.DuplicateSubquery_ViaUnion_IsExtractedToCte.DotNet10_0.verified.txt rename to tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet9_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet9_0.verified.txt new file mode 100644 index 0000000..7f2b439 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet9_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] % 2) = 0 +UNION +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE ([e0].[Id] % 2) = 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.verified.txt new file mode 100644 index 0000000..7f2b439 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] % 2) = 0 +UNION +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE ([e0].[Id] % 2) = 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.cs similarity index 51% rename from tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs rename to tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.cs index e8d1b3d..9c77d96 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/CteTests.cs +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.cs @@ -6,21 +6,11 @@ namespace EntityFrameworkCore.Projectables.FunctionalTests; /// -/// Verifies that the CteAwareQuerySqlGenerator emits a proper WITH … AS (…) -/// preamble when the same filtered base query is used as a table source more than once in a -/// single SQL statement. -/// -/// -/// EF Core assigns fresh table aliases (e.g. [e], [e0]) to every logical copy of -/// a sub-expression, so two occurrences of the same LINQ query compile to structurally equivalent -/// but alias-different -/// nodes. The CteDeduplicatingRewriter uses -/// NormalizedSelectExpressionComparer — which ignores alias names — to detect these -/// duplicates and hoists them into a single WITH clause. -/// +/// Verifies that projectable properties and methods work correctly when the same queryable is +/// used as a table source more than once in a single SQL statement (union, concat, self-join). /// [UsesVerify] -public class CteTests +public class SetOperationWithProjectableTests { public record Entity { @@ -36,11 +26,10 @@ public record Entity /// /// subset.Concat(subset) translates to UNION ALL. - /// Both halves share the same filtered , - /// so the CteDeduplicatingRewriter should extract it into a single WITH clause. + /// Both halves use the same filtered projectable condition. /// [Fact] - public Task DuplicateSubquery_ViaConcat_IsExtractedToCte() + public Task ProjectableInConcat_BothSidesUseProjectable() { using var dbContext = new SampleDbContext(); @@ -54,11 +43,10 @@ public Task DuplicateSubquery_ViaConcat_IsExtractedToCte() /// /// subset.Union(subset) translates to UNION (distinct). - /// Both halves share the same filtered , - /// so the CteDeduplicatingRewriter should extract it into a single WITH clause. + /// Both halves use the same filtered projectable property. /// [Fact] - public Task DuplicateSubquery_ViaUnion_IsExtractedToCte() + public Task ProjectableInUnion_BothSidesUseProjectable() { using var dbContext = new SampleDbContext(); @@ -71,12 +59,10 @@ public Task DuplicateSubquery_ViaUnion_IsExtractedToCte() } /// - /// Self-join with the same filtered query on both sides. - /// The two filtered table sources for the join are structurally identical, so the - /// CteDeduplicatingRewriter should detect the duplicate and emit a WITH clause. + /// Self-join with the same filtered query on both sides where both sides use a projectable. /// [Fact] - public Task DuplicateSubquery_ViaSelfJoin_IsExtractedToCte() + public Task ProjectableInSelfJoin_BothSidesUseProjectable() { using var dbContext = new SampleDbContext(); From bba2c76514e4dadf357aec572801977087b6d91d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:48:46 +0000 Subject: [PATCH 10/11] =?UTF-8?q?Fix=20remaining=20CTE=E2=86=92CROSS=20APP?= =?UTF-8?q?LY=20references=20in=20comments=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Variable.cs | 14 +++++++++----- .../SyntaxRewriters/BlockStatementConverter.cs | 14 +++++++++----- .../Internal/ProjectionOptionsExtension.cs | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs index cfcc255..b3d2d2a 100644 --- a/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs +++ b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs @@ -2,7 +2,8 @@ namespace EntityFrameworkCore.Projectables; /// /// Utility class for marking reused local variables in projectable expression trees, -/// enabling CTE-based SQL deduplication at the SQL generation layer. +/// enabling the SQL generator to hoist shared computations into CROSS APPLY (SQL Server) +/// or CROSS JOIN LATERAL (PostgreSQL) inline subqueries. /// public static class Variable { @@ -11,11 +12,14 @@ public static class Variable /// /// When the same appears more than once in a generated /// expression tree (because the corresponding local variable was referenced multiple times - /// in a [Projectable(AllowBlockBody = true)] method body), the SQL generator can - /// extract the shared computation into a SQL CTE: + /// in a [Projectable(AllowBlockBody = true)] method body), the SQL generator hoists + /// the shared computation into a single inline subquery evaluated exactly once per row: /// - /// WITH [name] AS (<inner expression>) - /// SELECT … FROM … JOIN [name] ON … + /// -- SQL Server + /// CROSS APPLY (SELECT <inner expression> AS [name]) AS [v] + /// + /// -- PostgreSQL + /// CROSS JOIN LATERAL (SELECT <inner expression> AS "name") AS "v" /// /// /// diff --git a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs index 60fee29..42e2fff 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs @@ -18,7 +18,8 @@ internal class BlockStatementConverter // Pre-computed reference counts for each local variable across the code statements // (statements that are not local declarations). Variables with count > 1 are wrapped - // in Variable.Wrap so the SQL generator can hoist them into a CTE. + // in Variable.Wrap so the SQL generator can hoist them into a CROSS APPLY / LATERAL + // inline subquery, computing the expression exactly once per row. private IReadOnlyDictionary _preComputedRefCounts = new Dictionary(); public BlockStatementConverter(SourceProductionContext context, ExpressionSyntaxRewriter expressionRewriter) @@ -99,7 +100,8 @@ public BlockStatementConverter(SourceProductionContext context, ExpressionSyntax // Pre-compute how many times each local variable is referenced in the code // statements (non-declaration statements). Variables with count > 1 will be // wrapped in Variable.Wrap in the generated expression tree so the SQL generator - // can identify shared computations and hoist them into a CTE. + // can identify shared computations and hoist them into a CROSS APPLY / LATERAL + // inline subquery. _preComputedRefCounts = ComputeCodeStatementRefCounts(codeStatements); // next as its "fallthrough" branch. This naturally handles chains like: // if (a) return 1; if (b) return 2; return 3; @@ -358,7 +360,8 @@ private bool TryProcessLocalDeclaration(LocalDeclarationStatementSyntax localDec /// Replaces references to local variables in the given expression with their initializer expressions. /// Also tracks how many times each variable is referenced via . /// Variables referenced more than once in the final expression are wrapped in - /// Variable.Wrap("name", expr) so the SQL generator can hoist them into a CTE. + /// Variable.Wrap("name", expr) so the SQL generator can hoist them into a + /// CROSS APPLY / CROSS JOIN LATERAL inline subquery. /// private ExpressionSyntax ReplaceLocalVariables(ExpressionSyntax expression) => (ExpressionSyntax)new LocalVariableReplacer(_localVariables, _localVariableReferenceCount, _preComputedRefCounts).Visit(expression); @@ -512,8 +515,9 @@ public LocalVariableReplacer( // When the variable is referenced more than once in the code statements, // wrap the substituted expression in Variable.Wrap("name", expr). - // This embeds a CTE marker directly into the generated expression tree - // so the runtime SQL generator can identify shared sub-computations. + // This embeds a reuse marker into the generated expression tree so the + // SQL generator can hoist shared sub-computations into a CROSS APPLY / + // CROSS JOIN LATERAL inline subquery, evaluated exactly once per row. if (_preComputedRefCounts.TryGetValue(varName, out var preCount) && preCount > 1) { return BuildVariableWrapCall(varName, inner).WithTriviaFrom(node); diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index 5add3a4..7410a02 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -43,7 +43,7 @@ public void ApplyServices(IServiceCollection services) services.AddScoped(); // Translate Variable.Wrap(name, expr) calls to VariableWrapSqlExpression so the - // CteAwareQuerySqlGenerator can decide whether to inline or CROSS-APPLY them. + // ProjectablesQuerySqlGenerator can decide whether to inline or CROSS-APPLY them. services.AddSingleton(); static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor descriptor) From 1273ec2a14b8fe18a82e795479fdd8f1e7e1fde7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:41:48 +0000 Subject: [PATCH 11/11] EF8/9 CROSS APPLY support, stronger PostgreSQL detection, new processor factory Co-authored-by: PhenX <42170+PhenX@users.noreply.github.com> --- .../Internal/ProjectionOptionsExtension.cs | 21 +++ ...tablesParameterBasedSqlProcessorFactory.cs | 125 ++++++++++++++++ .../Query/ProjectablesQuerySqlGenerator.cs | 133 +++++++++++++++--- .../InlineSubqueryExpression.cs | 39 +++-- ...riableWrapQueryTranslationPostprocessor.cs | 49 +++---- 5 files changed, 307 insertions(+), 60 deletions(-) create mode 100644 src/EntityFrameworkCore.Projectables/Query/ProjectablesParameterBasedSqlProcessorFactory.cs diff --git a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs index 7410a02..ce5a967 100644 --- a/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs +++ b/src/EntityFrameworkCore.Projectables/Infrastructure/Internal/ProjectionOptionsExtension.cs @@ -82,6 +82,27 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor )); } +#if NET8_0 || NET9_0 + // In EF Core 8/9 the execution-time SqlNullabilityProcessor (run inside + // RelationalParameterBasedSqlProcessor.Optimize) throws on unknown + // TableExpressionBase subtypes — including our InlineSubqueryExpression. + // ProjectablesParameterBasedSqlProcessorFactory decorates the provider's factory + // and temporarily hides InlineSubqueryExpression tables around the nullability pass. + var paramSqlDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory)); + if (paramSqlDescriptor is not null) + { + var paramFactory = ActivatorUtilities.CreateFactory( + typeof(ProjectablesParameterBasedSqlProcessorFactory), + new[] { paramSqlDescriptor.ServiceType }); + + services.Replace(ServiceDescriptor.Describe( + paramSqlDescriptor.ServiceType, + sp => paramFactory(sp, new[] { CreateTargetInstance(sp, paramSqlDescriptor) }), + paramSqlDescriptor.Lifetime + )); + } +#endif + if (_compatibilityMode is CompatibilityMode.Full) { var targetDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IQueryCompiler)); diff --git a/src/EntityFrameworkCore.Projectables/Query/ProjectablesParameterBasedSqlProcessorFactory.cs b/src/EntityFrameworkCore.Projectables/Query/ProjectablesParameterBasedSqlProcessorFactory.cs new file mode 100644 index 0000000..7976bf9 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesParameterBasedSqlProcessorFactory.cs @@ -0,0 +1,125 @@ +#if NET8_0 || NET9_0 +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// A decorator for EF Core 8/9 that +/// ensures table sources are temporarily hidden from the +/// execution-time SqlNullabilityProcessor. +/// +/// In EF Core 8 and 9 the SQL nullability processor visits every +/// in a and throws on unknown subtypes. Our +/// is added to the table list during translation, so without +/// this decorator the second nullability pass (run by +/// at query execution time) would +/// throw. EF Core 10 changed the nullability processor to be lenient about unknown table types, +/// so this decorator is not needed there. +/// +/// +/// The strategy: +/// +/// Clone the root so the compiled-query cache is not +/// mutated. +/// Remove every wrapping an +/// from the clone's table list. +/// Run the inner provider's Optimize on the clean clone — no custom table +/// types remain, so the nullability pass succeeds. +/// Re-append the removed entries to the result's +/// table list so the SQL generator can still emit the CROSS APPLY clauses. +/// +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class ProjectablesParameterBasedSqlProcessorFactory( + IRelationalParameterBasedSqlProcessorFactory inner, + RelationalParameterBasedSqlProcessorDependencies dependencies) + : IRelationalParameterBasedSqlProcessorFactory +{ +#if NET8_0 + /// + public RelationalParameterBasedSqlProcessor Create(bool useRelationalNulls) + => new ProjectablesParameterBasedSqlProcessor(dependencies, useRelationalNulls, inner.Create(useRelationalNulls)); +#else + /// + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + => new ProjectablesParameterBasedSqlProcessor(dependencies, parameters, inner.Create(parameters)); +#endif +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class ProjectablesParameterBasedSqlProcessor( + RelationalParameterBasedSqlProcessorDependencies dependencies, +#if NET8_0 + bool useRelationalNulls, +#else + RelationalParameterBasedSqlProcessorParameters parameters, +#endif + RelationalParameterBasedSqlProcessor inner) +#if NET8_0 + : RelationalParameterBasedSqlProcessor(dependencies, useRelationalNulls) +#else + : RelationalParameterBasedSqlProcessor(dependencies, parameters) +#endif +{ + // Reflection accessor for SelectExpression._tables (private List). + private static readonly FieldInfo TablesField = + typeof(SelectExpression).GetField("_tables", BindingFlags.NonPublic | BindingFlags.Instance)!; + + /// + public override Expression Optimize( + Expression queryExpression, + IReadOnlyDictionary parametersValues, + out bool canCache) + { + if (queryExpression is not ShapedQueryExpression { QueryExpression: SelectExpression selectExpr }) + return inner.Optimize(queryExpression, parametersValues, out canCache); + + var selectTables = (List)TablesField.GetValue(selectExpr)!; + + // Extract CrossApplyExpression wrappers whose inner table is an InlineSubqueryExpression. + var inlineSubqueries = selectTables + .OfType() + .Where(ca => ca.Table is InlineSubqueryExpression) + .ToList(); + + if (inlineSubqueries.Count == 0) + return inner.Optimize(queryExpression, parametersValues, out canCache); + + // Temporarily remove them so the inner processor's nullability pass does not + // encounter our custom TableExpressionBase subtype (EF 8/9 throw on unknown types). + // We restore them afterwards so the compiled-query cache remains valid. + foreach (var ca in inlineSubqueries) + selectTables.Remove(ca); + + Expression result; + try + { + result = inner.Optimize(queryExpression, parametersValues, out canCache); + } + finally + { + // Always restore the original SelectExpression's table list. + foreach (var ca in inlineSubqueries) + selectTables.Add(ca); + } + + // If Optimize returned a different SelectExpression (e.g. due to nullability rewrites), + // append our CROSS APPLY tables to that result as well. + if (result is ShapedQueryExpression { QueryExpression: SelectExpression resultSelectExpr } + && !ReferenceEquals(resultSelectExpr, selectExpr)) + { + var resultTables = (List)TablesField.GetValue(resultSelectExpr)!; + foreach (var ca in inlineSubqueries) + resultTables.Add(ca); + } + + return result; + } +} +#endif diff --git a/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs index 15c3375..941287f 100644 --- a/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs @@ -1,4 +1,5 @@ using System.Linq.Expressions; +using System.Reflection; using EntityFrameworkCore.Projectables.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; @@ -33,28 +34,44 @@ protected override Expression VisitExtension(Expression expression) switch (expression) { case VariableWrapSqlExpression wrap: - // Single-use wrap or unrecognised platform: emit the inner expression directly. + // Single-use wrap: emit the inner expression directly. return Visit(wrap.Inner); -#if !NET8_0 && !NET9_0 case InlineSubqueryExpression inlineSub: return VisitInlineSubquery(inlineSub); -#endif default: return base.VisitExtension(expression); } } -#if !NET8_0 && !NET9_0 /// - /// Returns if this generator is targeting PostgreSQL, which uses - /// CROSS JOIN LATERAL instead of SQL Server's CROSS APPLY. - /// Detection is based on the SQL identifier delimiter: SQL Server uses […], - /// PostgreSQL uses "…". + /// Returns if this generator is targeting a database that uses + /// CROSS JOIN LATERAL syntax (e.g. PostgreSQL) rather than SQL Server's + /// CROSS APPLY. + /// + /// Detection uses two signals in combination: + /// + /// The is NOT from the SQL Server provider + /// assembly (Microsoft.EntityFrameworkCore.SqlServer). + /// The helper uses double-quote identifier delimiters ("x"), which is the + /// ISO-SQL standard followed by PostgreSQL, SQLite, and others. + /// + /// /// - protected virtual bool IsPostgres - => Dependencies.SqlGenerationHelper.DelimitIdentifier("x").StartsWith('"'); + protected virtual bool UsesLateralJoin + { + get + { + var assemblyName = Dependencies.SqlGenerationHelper.GetType().Assembly.GetName().Name + ?? string.Empty; + // SQL Server uses CROSS APPLY; exclude it explicitly. + if (assemblyName.Contains("SqlServer", StringComparison.OrdinalIgnoreCase)) + return false; + // Secondary heuristic: ISO-SQL providers use double-quote identifiers. + return Dependencies.SqlGenerationHelper.DelimitIdentifier("x").StartsWith('"'); + } + } /// /// Emits the inline subquery that materialises a reused local variable exactly once per row. @@ -81,12 +98,12 @@ private Expression VisitInlineSubquery(InlineSubqueryExpression inlineSub) /// /// Overrides cross-apply emission to use CROSS JOIN LATERAL when targeting - /// PostgreSQL (or any provider that uses double-quote identifiers) for + /// PostgreSQL (or any ISO-SQL provider) for /// tables; delegates to the base implementation for all other table types. /// protected override Expression VisitCrossApply(CrossApplyExpression crossApplyExpression) { - if (IsPostgres && crossApplyExpression.Table is InlineSubqueryExpression inlineSub) + if (UsesLateralJoin && crossApplyExpression.Table is InlineSubqueryExpression inlineSub) { Sql.Append("CROSS JOIN LATERAL "); return VisitInlineSubquery(inlineSub); @@ -116,7 +133,7 @@ internal static SelectExpression TransformVariableWrapsOnSelectExpression(Select // Build column mapping: variableName → ColumnExpression referencing the subquery table. var columnMapping = new Dictionary(StringComparer.Ordinal); - var inlineSubqueryTables = new List(); + var inlineSubqueries = new List(); var counter = 0; foreach (var (variableName, wraps) in multiUse) @@ -129,20 +146,101 @@ internal static SelectExpression TransformVariableWrapsOnSelectExpression(Select counter++; var first = wraps[0]; var subquery = new InlineSubqueryExpression(tableAlias, first.Inner, variableName); - inlineSubqueryTables.Add(new CrossApplyExpression(subquery)); - columnMapping[variableName] = new ColumnExpression(variableName, tableAlias, first.Type, first.TypeMapping!, false); + inlineSubqueries.Add(subquery); + columnMapping[variableName] = CreateColumnExpr(rootSelect, subquery, variableName, first.Type, first.TypeMapping!); } // Rewrite: replace VariableWrapSqlExpression with the column references. var replacer = new VariableWrapReplacer(columnMapping); var rewritten = (SelectExpression)replacer.Visit(rootSelect); - // Append the new subquery table sources. - rewritten.SetTables([.. rewritten.Tables, .. inlineSubqueryTables]); + // Append the new subquery table sources (wrapped in CrossApplyExpression). + AddInlineSubqueries(rewritten, inlineSubqueries); return rewritten; } + // ── version-specific helpers ──────────────────────────────────────────────────────────── + + // Reflection fields cached per AppDomain – access pattern depends on EF Core version. + +#if NET8_0 + private static readonly FieldInfo TablesField8 = + typeof(SelectExpression).GetField("_tables", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly FieldInfo TableReferencesField8 = + typeof(SelectExpression).GetField("_tableReferences", BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly Type TableReferenceExpressionType8 = + typeof(SelectExpression).Assembly.GetType("Microsoft.EntityFrameworkCore.Query.Internal.TableReferenceExpression")!; + + private static readonly ConstructorInfo TableReferenceExpressionCtor8 = + TableReferenceExpressionType8.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)[0]; + + private static readonly MethodInfo AddTableMethod8 = + typeof(SelectExpression).GetMethod("AddTable", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; + + private static readonly MethodInfo CreateColumnExpressionMethod8 = + typeof(SelectExpression).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .First(m => m.Name == "CreateColumnExpression" && !m.IsStatic); +#elif NET9_0 + private static readonly FieldInfo TablesField9 = + typeof(SelectExpression).GetField("_tables", BindingFlags.NonPublic | BindingFlags.Instance)!; +#endif + + /// + /// Creates a referencing a column inside + /// in a way that is compatible with all supported EF Core versions. + /// + private static ColumnExpression CreateColumnExpr( + SelectExpression selectExpr, + InlineSubqueryExpression subquery, + string columnName, + Type type, + RelationalTypeMapping typeMapping) + { +#if NET8_0 + // EF Core 8: ColumnExpression has no public ctor (string name, string alias, …). + // We must use SelectExpression.CreateColumnExpression (public method) which + // looks up the table by reference in _tables, then maps to _tableReferences. + // The CrossApplyExpression wrapper is what gets added to _tables, so we pass + // the wrapper (not the inner InlineSubqueryExpression) to CreateColumnExpression. + var crossApply = new CrossApplyExpression(subquery); + var tableRef = TableReferenceExpressionCtor8.Invoke([selectExpr, crossApply.Alias ?? subquery.Alias!]); + AddTableMethod8.Invoke(selectExpr, [crossApply, tableRef]); + return (ColumnExpression)CreateColumnExpressionMethod8.Invoke(selectExpr, + [crossApply, columnName, type, typeMapping, (bool?)false])!; +#else + // EF Core 9+: public ColumnExpression(name, tableAlias, type, typeMapping, nullable) + return new ColumnExpression(columnName, subquery.Alias!, type, typeMapping, false); +#endif + } + + /// + /// Appends wrappers for each + /// to the table list of , using the API available in the + /// current EF Core version. + /// + private static void AddInlineSubqueries( + SelectExpression selectExpr, + IReadOnlyList subqueries) + { +#if NET8_0 + // Tables were already registered inside CreateColumnExpr (EF8 path uses AddTableMethod8). + // Nothing additional to do here for EF8. +#elif NET9_0 + // EF Core 9 has no public SetTables; mutate _tables directly via the cached field. + if (TablesField9.GetValue(selectExpr) is List tables9) + { + foreach (var sub in subqueries) + tables9.Add(new CrossApplyExpression(sub)); + } +#else + // EF Core 10+: SetTables is available. + selectExpr.SetTables([.. selectExpr.Tables, .. subqueries.Select(s => new CrossApplyExpression(s))]); +#endif + } + // ── helpers ───────────────────────────────────────────────────────────────────────── /// @@ -205,5 +303,4 @@ protected override Expression VisitExtension(Expression node) return base.VisitExtension(node); } } -#endif } diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs index 8e78f72..ad212bb 100644 --- a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs @@ -1,4 +1,3 @@ -#if !NET8_0 && !NET9_0 using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Query; @@ -29,6 +28,20 @@ public InlineSubqueryExpression(string alias, SqlExpression inner, string column ColumnName = columnName; } + // Secondary constructor used when annotations are propagated (e.g. during cloning). + // The annotation parameter type differs between EF Core 8 and 9+. +#if NET8_0 + private InlineSubqueryExpression( + string alias, + SqlExpression inner, + string columnName, + IEnumerable annotations) + : base(alias, annotations) + { + Inner = inner; + ColumnName = columnName; + } +#else private InlineSubqueryExpression( string alias, SqlExpression inner, @@ -39,6 +52,7 @@ private InlineSubqueryExpression( Inner = inner; ColumnName = columnName; } +#endif /// The expression computed inside the inline subquery. public SqlExpression Inner { get; } @@ -52,10 +66,17 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) var newInner = (SqlExpression)visitor.Visit(Inner); return ReferenceEquals(newInner, Inner) ? this - : new InlineSubqueryExpression(Alias!, newInner, ColumnName, - GetAnnotations().ToDictionary(a => a.Name, a => a)); + : new InlineSubqueryExpression(Alias!, newInner, ColumnName); } + // EF Core 8 uses CreateWithAnnotations (IEnumerable) as the abstract method + // for copying an expression with a new annotation set. EF Core 9+ uses an immutable Clone / + // WithAlias / WithAnnotations API instead. +#if NET8_0 + /// + protected override TableExpressionBase CreateWithAnnotations(IEnumerable annotations) + => new InlineSubqueryExpression(Alias!, Inner, ColumnName, annotations); +#else /// public override TableExpressionBase Clone(string? alias, ExpressionVisitor cloningExpressionVisitor) => new InlineSubqueryExpression( @@ -69,18 +90,19 @@ public override TableExpressionBase WithAlias(string newAlias) => new InlineSubqueryExpression(newAlias, Inner, ColumnName, GetAnnotations().ToDictionary(a => a.Name, a => a)); - /// - protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) - => new InlineSubqueryExpression(Alias!, Inner, ColumnName, annotations); - /// public override Expression Quote() => throw new NotSupportedException($"{nameof(InlineSubqueryExpression)} does not support pre-compiled queries."); + /// + protected override TableExpressionBase WithAnnotations(IReadOnlyDictionary annotations) + => new InlineSubqueryExpression(Alias!, Inner, ColumnName, annotations); +#endif + /// protected override void Print(ExpressionPrinter expressionPrinter) { - expressionPrinter.Append($"(SELECT "); + expressionPrinter.Append("(SELECT "); expressionPrinter.Visit(Inner); expressionPrinter.Append($" AS [{ColumnName}]) AS [{Alias}]"); } @@ -95,4 +117,3 @@ public override bool Equals(object? obj) /// public override int GetHashCode() => HashCode.Combine(Alias, ColumnName, Inner); } -#endif diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs index 0958ab8..46f8136 100644 --- a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs @@ -10,10 +10,10 @@ namespace EntityFrameworkCore.Projectables.Query; /// factory and inserts a step. /// /// This postprocessor runs before the relational nullability processor so that -/// nodes — which EF Core's nullability processor does -/// not understand — are either hoisted into CROSS APPLY / CROSS JOIN LATERAL -/// subqueries (on .NET 10 / EF Core 10+) or lowered to their inner expressions (on earlier -/// versions) before the nullability processor encounters them. +/// nodes are hoisted into CROSS APPLY / +/// CROSS JOIN LATERAL subqueries before the translation-time nullability processor +/// encounters them. Multi-use wraps are replaced with +/// references; single-use wraps are lowered to their inner expression. /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] @@ -32,15 +32,8 @@ public QueryTranslationPostprocessor Create(QueryCompilationContext queryCompila /// /// Processes nodes in the SQL expression tree before -/// delegating to the inner postprocessor. -/// -/// On .NET 10 / EF Core 10+: Replaces multi-use -/// groups with CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) -/// inline subquery table sources so each local variable expression is computed exactly -/// once per row. -/// On earlier versions: Lowers every to its -/// plain inner expression (identity semantics, no deduplication). -/// +/// delegating to the inner postprocessor. Multi-use wraps become CROSS APPLY / +/// CROSS JOIN LATERAL inline subquery table sources. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] internal sealed class VariableWrapQueryTranslationPostprocessor( @@ -52,9 +45,16 @@ internal sealed class VariableWrapQueryTranslationPostprocessor( /// public override Expression Process(Expression query) { - // Apply Variable.Wrap transformation before the provider's postprocessor (which - // eventually runs SqlNullabilityProcessor — an EF Core internal processor that throws - // on unknown custom SQL expressions). + // Transform Variable.Wrap nodes BEFORE the provider's postprocessor runs the + // translation-time SqlNullabilityProcessor. The SQL Server provider overrides + // VisitCustomSqlExpression to throw on unknown nodes, so both + // VariableWrapSqlExpression (SqlExpression) and InlineSubqueryExpression + // (TableExpressionBase) must be gone before that processor runs. + // + // For EF Core 8/9, a second nullability pass runs at execution time inside + // RelationalParameterBasedSqlProcessor.Optimize. ProjectablesParameterBasedSqlProcessorFactory + // (registered in the DI container for EF 8/9) intercepts that pass and temporarily + // hides InlineSubqueryExpression tables so they never reach the processor. query = TransformVariableWraps(query); return inner.Process(query); } @@ -65,26 +65,9 @@ private static Expression TransformVariableWraps(Expression query) || shaped.QueryExpression is not SelectExpression selectExpression) return query; -#if !NET8_0 && !NET9_0 var transformed = ProjectablesQuerySqlGenerator.TransformVariableWrapsOnSelectExpression(selectExpression); return ReferenceEquals(transformed, selectExpression) ? query : shaped.UpdateQueryExpression(transformed); -#else - // Lower Variable.Wrap → inner expression so the nullability processor is unaffected. - var stripped = (SelectExpression)new VariableWrapStripper().Visit(selectExpression); - return ReferenceEquals(stripped, selectExpression) ? query : shaped.UpdateQueryExpression(stripped); -#endif } - -#if NET8_0 || NET9_0 - /// Removes by replacing each with its inner expression. - private sealed class VariableWrapStripper : ExpressionVisitor - { - protected override Expression VisitExtension(Expression node) - => node is VariableWrapSqlExpression wrap - ? Visit(wrap.Inner) - : base.VisitExtension(node); - } -#endif }