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.0true12.014.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
///
///
[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
}