diff --git a/Directory.Build.props b/Directory.Build.props index 876b646..c3d47d7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - net8.0;net10.0 + net8.0;net9.0;net10.0 true 12.0 14.0 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.Abstractions/Variable.cs b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs new file mode 100644 index 0000000..b3d2d2a --- /dev/null +++ b/src/EntityFrameworkCore.Projectables.Abstractions/Variable.cs @@ -0,0 +1,38 @@ +namespace EntityFrameworkCore.Projectables; + +/// +/// Utility class for marking reused local variables in projectable expression trees, +/// enabling the SQL generator to hoist shared computations into CROSS APPLY (SQL Server) +/// or CROSS JOIN LATERAL (PostgreSQL) inline subqueries. +/// +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 hoists + /// the shared computation into a single inline subquery evaluated exactly once per row: + /// + /// -- SQL Server + /// CROSS APPLY (SELECT <inner expression> AS [name]) AS [v] + /// + /// -- PostgreSQL + /// CROSS JOIN LATERAL (SELECT <inner expression> AS "name") AS "v" + /// + /// + /// + /// 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 95409dd..42e2fff 100644 --- a/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs +++ b/src/EntityFrameworkCore.Projectables.Generator/SyntaxRewriters/BlockStatementConverter.cs @@ -14,6 +14,13 @@ internal class BlockStatementConverter private readonly SourceProductionContext _context; private readonly ExpressionSyntaxRewriter _expressionRewriter; 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 CROSS APPLY / LATERAL + // inline subquery, computing the expression exactly once per row. + private IReadOnlyDictionary _preComputedRefCounts = new Dictionary(); public BlockStatementConverter(SourceProductionContext context, ExpressionSyntaxRewriter expressionRewriter) { @@ -90,7 +97,12 @@ 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 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; // => a ? 1 : (b ? 2 : 3) @@ -346,9 +358,36 @@ 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 + /// CROSS APPLY / CROSS JOIN LATERAL inline subquery. /// private ExpressionSyntax ReplaceLocalVariables(ExpressionSyntax expression) - => (ExpressionSyntax)new LocalVariableReplacer(_localVariables).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( @@ -450,21 +489,62 @@ private void ReportUnsupportedStatement(StatementSyntax statement, string member private class LocalVariableReplacer : CSharpSyntaxRewriter { private readonly Dictionary _localVariables; + private readonly Dictionary _referenceCount; + private readonly IReadOnlyDictionary _preComputedRefCounts; - public LocalVariableReplacer(Dictionary localVariables) + public LocalVariableReplacer( + Dictionary localVariables, + 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)) { - return SyntaxFactory.ParenthesizedExpression(replacement.WithoutTrivia()) - .WithTriviaFrom(node); + var varName = node.Identifier.Text; + _referenceCount[varName] = _referenceCount.TryGetValue(varName, out var count) + ? count + 1 + : 1; + + 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 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); + } + + 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/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..ce5a967 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; @@ -41,6 +42,10 @@ public void ApplyServices(IServiceCollection services) // Register a convention that will ignore properties marked with the ProjectableAttribute services.AddScoped(); + // Translate Variable.Wrap(name, expr) calls to VariableWrapSqlExpression so the + // ProjectablesQuerySqlGenerator can decide whether to inline or CROSS-APPLY them. + services.AddSingleton(); + static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor descriptor) { if (descriptor.ImplementationInstance is not null) @@ -57,6 +62,47 @@ static object CreateTargetInstance(IServiceProvider services, ServiceDescriptor // Custom convention to handle global query filters, etc services.AddScoped(); + // Register the SQL generator factory that emits CROSS APPLY / CROSS JOIN LATERAL + // subqueries for reused local variables in block-bodied projectable methods. + services.Replace(ServiceDescriptor.Scoped()); + + // Wrap the query translation postprocessor to handle VariableWrapSqlExpression before + // EF Core's SqlNullabilityProcessor encounters it. + var postprocessorDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(IQueryTranslationPostprocessorFactory)); + if (postprocessorDescriptor is not null) + { + var decoratorObjectFactory = ActivatorUtilities.CreateFactory( + typeof(VariableWrapQueryTranslationPostprocessorFactory), + new[] { postprocessorDescriptor.ServiceType }); + + services.Replace(ServiceDescriptor.Describe( + postprocessorDescriptor.ServiceType, + serviceProvider => decoratorObjectFactory(serviceProvider, new[] { CreateTargetInstance(serviceProvider, postprocessorDescriptor) }), + postprocessorDescriptor.Lifetime + )); + } + +#if 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 new file mode 100644 index 0000000..941287f --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGenerator.cs @@ -0,0 +1,306 @@ +using System.Linq.Expressions; +using System.Reflection; +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 emits CROSS APPLY (SQL Server) or +/// CROSS JOIN LATERAL (PostgreSQL) subquery clauses for local variables declared inside +/// block-bodied [Projectable] methods that are referenced more than once. +/// +/// When the source generator detects that a local variable is used more than once, it wraps +/// every occurrence in a call to . EF Core's method +/// translator converts those calls to nodes. This +/// generator then hoists each multi-use variable into an inline subquery so that its expression +/// is evaluated exactly once per row. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public class ProjectablesQuerySqlGenerator : QuerySqlGenerator +{ + /// + public ProjectablesQuerySqlGenerator(QuerySqlGeneratorDependencies dependencies) + : base(dependencies) + { + } + + /// + protected override Expression VisitExtension(Expression expression) + { + switch (expression) + { + case VariableWrapSqlExpression wrap: + // Single-use wrap: emit the inner expression directly. + return Visit(wrap.Inner); + + case InlineSubqueryExpression inlineSub: + return VisitInlineSubquery(inlineSub); + + default: + return base.VisitExtension(expression); + } + } + + /// + /// 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 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. + /// The JOIN keyword itself (CROSS APPLY vs CROSS JOIN LATERAL) is controlled + /// by . + /// + private Expression VisitInlineSubquery(InlineSubqueryExpression inlineSub) + { + Sql.AppendLine("("); + using (Sql.Indent()) + { + Sql.Append("SELECT "); + Visit(inlineSub.Inner); + Sql.Append(AliasSeparator); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(inlineSub.ColumnName)); + } + + Sql.AppendLine(); + Sql.Append(")"); + Sql.Append(AliasSeparator); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(inlineSub.Alias!)); + return inlineSub; + } + + /// + /// Overrides cross-apply emission to use CROSS JOIN LATERAL when targeting + /// 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 (UsesLateralJoin && crossApplyExpression.Table is InlineSubqueryExpression inlineSub) + { + Sql.Append("CROSS JOIN LATERAL "); + return VisitInlineSubquery(inlineSub); + } + + return base.VisitCrossApply(crossApplyExpression); + } + + /// + /// Rewrites any nodes that appear more than once + /// in the root into inline subquery table sources. + /// Single-use nodes are lowered to their inner expression. + /// + internal static SelectExpression TransformVariableWrapsOnSelectExpression(SelectExpression rootSelect) + { + // Scan the entire expression for VariableWrap occurrences, grouped by variable name. + var scanner = new VariableWrapScanner(); + scanner.Visit(rootSelect); + + // Only process names that appear more than once (single-use wraps are emitted inline). + var multiUse = scanner.Groups + .Where(kv => kv.Value.Count > 1) + .ToList(); + + if (multiUse.Count == 0) + return rootSelect; + + // Build column mapping: variableName → ColumnExpression referencing the subquery table. + var columnMapping = new Dictionary(StringComparer.Ordinal); + var inlineSubqueries = new List(); + var counter = 0; + + foreach (var (variableName, wraps) in multiUse) + { + // Use "v" (for "variable") as the table-alias prefix to satisfy EF Core's + // alias normalization (letter + optional counter: "v", "v0", "v1", …). + // The original variable name is used as the column name inside the subquery so + // the SQL still shows the meaningful name (e.g. SELECT … AS [doubled]). + var tableAlias = counter == 0 ? "v" : $"v{counter}"; + counter++; + var first = wraps[0]; + var subquery = new InlineSubqueryExpression(tableAlias, first.Inner, variableName); + 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 (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 ───────────────────────────────────────────────────────────────────────── + + /// + /// Scans a SQL expression tree for nodes, + /// collecting them grouped by . + /// + private sealed class VariableWrapScanner : ExpressionVisitor + { + public Dictionary> Groups { get; } = new(StringComparer.Ordinal); + + protected override Expression VisitExtension(Expression node) + { + if (node is VariableWrapSqlExpression wrap) + { + if (!Groups.TryGetValue(wrap.VariableName, out var list)) + Groups[wrap.VariableName] = list = []; + list.Add(wrap); + // Don't recurse into the inner — we only care about top-level occurrences. + return node; + } + + return base.VisitExtension(node); + } + } + + /// + /// Replaces nodes with + /// references from the provided mapping. + /// Nodes whose name is not in the mapping (single-use) are lowered to the inner expression. + /// Does not recurse into nested nodes that are table sources, + /// since the inline subquery is only added at the root level. + /// + private sealed class VariableWrapReplacer(IReadOnlyDictionary mapping) : ExpressionVisitor + { + // 0 = not yet inside any SelectExpression; 1 = in the root SELECT (replace here); + // 2+ = in a nested SelectExpression (leave Variable.Wrap untouched — it belongs to a + // different scope and would have its own subquery if it were at the root). + private int _depth; + + protected override Expression VisitExtension(Expression node) + { + if (node is VariableWrapSqlExpression wrap && _depth == 1) + { + if (mapping.TryGetValue(wrap.VariableName, out var col)) + return col; + // Single-use: strip to inner expression. + return Visit(wrap.Inner); + } + + if (node is SelectExpression) + { + if (_depth >= 1) + return node; // Nested SelectExpression — do not recurse. + + _depth++; + try { return base.VisitExtension(node); } + finally { _depth--; } + } + + return base.VisitExtension(node); + } + } +} diff --git a/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs new file mode 100644 index 0000000..75ee69f --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/ProjectablesQuerySqlGeneratorFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Query; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// An that produces +/// instances, which add CROSS APPLY / +/// CROSS JOIN LATERAL subquery support for reused local variables in block-bodied +/// [Projectable] methods. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public class ProjectablesQuerySqlGeneratorFactory(QuerySqlGeneratorDependencies dependencies) + : IQuerySqlGeneratorFactory +{ + /// + public QuerySqlGenerator Create() + => new ProjectablesQuerySqlGenerator(dependencies); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs new file mode 100644 index 0000000..ad212bb --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/InlineSubqueryExpression.cs @@ -0,0 +1,119 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; + +/// +/// A that represents a single-column inline subquery used +/// to materialise a reused local variable exactly once per row: +/// +/// (SELECT <Inner> AS [<ColumnName>]) AS [<Alias>] +/// +/// This is emitted by as the body of a +/// CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) clause. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class InlineSubqueryExpression : TableExpressionBase +{ + /// Creates a new instance. + /// The SQL table alias for the inline subquery. + /// The SQL expression projected as a single column. + /// The name of the projected column (the original variable name). + public InlineSubqueryExpression(string alias, SqlExpression inner, string columnName) + : base(alias) + { + Inner = inner; + ColumnName = columnName; + } + + // 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, + string columnName, + IReadOnlyDictionary annotations) + : base(alias, annotations) + { + Inner = inner; + ColumnName = columnName; + } +#endif + + /// The expression computed inside the inline subquery. + public SqlExpression Inner { get; } + + /// The projected column name (the original local-variable name). + public string ColumnName { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newInner = (SqlExpression)visitor.Visit(Inner); + return ReferenceEquals(newInner, Inner) + ? this + : 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( + alias ?? Alias!, + (SqlExpression)cloningExpressionVisitor.Visit(Inner), + ColumnName, + GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + public override TableExpressionBase WithAlias(string newAlias) + => new InlineSubqueryExpression(newAlias, Inner, ColumnName, + GetAnnotations().ToDictionary(a => a.Name, a => a)); + + /// + 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.Visit(Inner); + expressionPrinter.Append($" AS [{ColumnName}]) AS [{Alias}]"); + } + + /// + public override bool Equals(object? obj) + => obj is InlineSubqueryExpression other + && Alias == other.Alias + && ColumnName == other.ColumnName + && Inner.Equals(other.Inner); + + /// + public override int GetHashCode() => HashCode.Combine(Alias, ColumnName, Inner); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs new file mode 100644 index 0000000..2c32ac0 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/SqlExpressions/VariableWrapSqlExpression.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query.SqlExpressions; + +/// +/// A that marks a named reused local variable produced by a +/// block-bodied [Projectable] method. +/// +/// The source generator wraps each occurrence of a reused local variable in a call to +/// . EF Core's method-call translator converts those +/// calls to nodes. The +/// then replaces multi-occurrence groups with a +/// CROSS APPLY (SQL Server) or CROSS JOIN LATERAL (PostgreSQL) inline subquery so +/// that the expression is computed exactly once per row. +/// +/// +/// Single-occurrence nodes (where the variable is only used once) are lowered to the plain +/// expression by the SQL generator, preserving the original SQL shape. +/// +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +public sealed class VariableWrapSqlExpression : SqlExpression +{ + /// Initialises a new instance. + /// The name of the local variable as it appears in source. + /// The SQL expression that computes the variable's value. + public VariableWrapSqlExpression(string variableName, SqlExpression inner) + : base(inner.Type, inner.TypeMapping) + { + VariableName = variableName; + Inner = inner; + } + + /// The local-variable name the generator used when emitting this marker. + public string VariableName { get; } + + /// The SQL expression that produces the variable's value. + public SqlExpression Inner { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var newInner = (SqlExpression)visitor.Visit(Inner); + return ReferenceEquals(newInner, Inner) + ? this + : new VariableWrapSqlExpression(VariableName, newInner); + } + +#if !NET8_0 + /// + public override Expression Quote() + => throw new NotSupportedException($"{nameof(VariableWrapSqlExpression)} does not support pre-compiled queries."); +#endif + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append($"Wrap({VariableName}: "); + expressionPrinter.Visit(Inner); + expressionPrinter.Append(")"); + } + + /// + public override bool Equals(object? obj) + => obj is VariableWrapSqlExpression other + && VariableName == other.VariableName + && Inner.Equals(other.Inner); + + /// + public override int GetHashCode() => HashCode.Combine(VariableName, Inner); +} diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs new file mode 100644 index 0000000..46f8136 --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapQueryTranslationPostprocessor.cs @@ -0,0 +1,73 @@ +using System.Linq.Expressions; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// A decorator that wraps the provider's own +/// factory and inserts a step. +/// +/// This postprocessor runs before the relational nullability processor so that +/// nodes 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")] +internal sealed class VariableWrapQueryTranslationPostprocessorFactory( + IQueryTranslationPostprocessorFactory inner, + QueryTranslationPostprocessorDependencies dependencies) + : IQueryTranslationPostprocessorFactory +{ + /// + public QueryTranslationPostprocessor Create(QueryCompilationContext queryCompilationContext) + { + var innerPostprocessor = inner.Create(queryCompilationContext); + return new VariableWrapQueryTranslationPostprocessor(dependencies, queryCompilationContext, innerPostprocessor); + } +} + +/// +/// Processes nodes in the SQL expression tree before +/// delegating to the inner postprocessor. 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( + QueryTranslationPostprocessorDependencies dependencies, + QueryCompilationContext queryCompilationContext, + QueryTranslationPostprocessor inner) + : QueryTranslationPostprocessor(dependencies, queryCompilationContext) +{ + /// + public override Expression Process(Expression query) + { + // 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); + } + + private static Expression TransformVariableWraps(Expression query) + { + if (query is not ShapedQueryExpression shaped + || shaped.QueryExpression is not SelectExpression selectExpression) + return query; + + var transformed = ProjectablesQuerySqlGenerator.TransformVariableWrapsOnSelectExpression(selectExpression); + return ReferenceEquals(transformed, selectExpression) + ? query + : shaped.UpdateQueryExpression(transformed); + } +} diff --git a/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs b/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs new file mode 100644 index 0000000..a5f843c --- /dev/null +++ b/src/EntityFrameworkCore.Projectables/Query/VariableWrapTranslatorPlugin.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Reflection; +using EntityFrameworkCore.Projectables.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Projectables.Query; + +/// +/// Translates calls to — the reuse-marker inserted by +/// the source generator for block-bodied projectable methods — into +/// nodes so that the SQL generator can later decide +/// whether to inline them or factor them out via a CROSS APPLY. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Needed")] +internal sealed class VariableWrapTranslatorPlugin : IMethodCallTranslatorPlugin +{ + public IEnumerable Translators { get; } = [new VariableWrapTranslator()]; + + private sealed class VariableWrapTranslator : IMethodCallTranslator + { + private static readonly MethodInfo _variableWrapMethod = + ((MethodCallExpression)((Expression>)(v => Variable.Wrap("x", v))).Body) + .Method.GetGenericMethodDefinition(); + + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (!method.IsGenericMethod || method.GetGenericMethodDefinition() != _variableWrapMethod) + return null; + + // arguments[0] is the constant name string, arguments[1] is the inner SQL expression. + var variableName = (string)((SqlConstantExpression)arguments[0]).Value!; + var inner = arguments[1]; + return new VariableWrapSqlExpression(variableName, inner); + } + } +} diff --git a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs index 72bc0f8..dd28265 100644 --- a/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs +++ b/src/EntityFrameworkCore.Projectables/Services/ProjectableExpressionReplacer.cs @@ -145,7 +145,10 @@ bool TryGetReflectedExpression(MemberInfo memberInfo, [NotNullWhen(true)] out La protected override Expression VisitMethodCall(MethodCallExpression node) { - // Replace MethodGroup arguments with their reflected expressions. + // Variable.Wrap("name", value) is now handled by VariableWrapTranslatorPlugin during + // EF Core's SQL translation phase, which converts it to a VariableWrapSqlExpression. + // We no longer strip it here so EF Core sees the call and can translate it properly. + // (Single-use wraps are inlined; multi-use wraps get a CROSS APPLY on net10.0+.) // No-alloc fast-path: scan args without allocating; only copy the array and call // Update() when a replacement is actually found (method-group arguments are rare). Expression[]? updatedArgs = null; diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariableReuse_IsInlinedMultipleTimes.DotNet10_0.verified.txt index eec38d9..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,2 +1,5 @@ -SELECT [e].[Value] * 2 + [e].[Value] * 2 -FROM [Entity] AS [e] \ No newline at end of file +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/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt index 9689484..e61805e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.LocalVariable_IsInlined.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + 5 +SELECT ([e].[Value] * 2) + 5 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt index 4a903b0..98c6174 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodiedMethodTests.MultipleLocalVariables_AreInlinedCorrectly.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + [e].[Value] * 3 + 10 +SELECT (([e].[Value] * 2) + ([e].[Value] * 3)) + 10 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt index 478d0ba..d1c34c5 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InCondition.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT CASE - WHEN [e].[Value] * 2 > 200 THEN N'Very High' + WHEN ([e].[Value] * 2) > 200 THEN N'Very High' ELSE N'Normal' END FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt index de3373a..3e1de12 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_InLogicalExpression.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT CASE - WHEN ([e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 100) OR [e].[Value] * 2 > 150 THEN CAST(1 AS bit) + WHEN ([e].[IsActive] = CAST(1 AS bit) AND [e].[Value] > 100) OR ([e].[Value] * 2) > 150 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt index 69eb4b8..0a254ff 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Multiple.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] * 2 + 42 +SELECT ([e].[Value] * 2) + 42 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt index 72fc7ea..cc31d51 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_Nested.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Value] + 42 + 10 +SELECT ([e].[Value] + 42) + 10 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt index ae5ad93..708800b 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/BlockBodiedMethods/BlockBodyProjectableCallTests.BlockBodyCallingProjectableMethod_WithLocalVariable.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT ([e].[Value] * 2 + 42) * 2 +SELECT (([e].[Value] * 2) + 42) * 2 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt index c925721..9e37fa7 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ClosureMemberAccessTests.CapturedIQueryableField_SubqueryInlinedViaCount.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ SELECT [e].[Id], ( SELECT COUNT(*) FROM [Entity] AS [e0] - WHERE [e0].[Id] * 2 > 4) AS [SubsetCount] + WHERE ([e0].[Id] * 2) > 4) AS [SubsetCount] FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt index 0178f4c..739c39b 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectOverNavigationProperty.DotNet10_0.verified.txt @@ -1,6 +1,7 @@ SELECT ( - SELECT TOP(1) [o].[RecordDate] + SELECT [o].[RecordDate] FROM [Order] AS [o] WHERE [u].[Id] = [o].[UserId] - ORDER BY [o].[RecordDate] DESC) + ORDER BY [o].[RecordDate] DESC + FETCH FIRST 1 ROWS ONLY) FROM [User] AS [u] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt index 43f5941..600b505 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ComplexModelTests.ProjectQueryFilters.DotNet10_0.verified.txt @@ -9,7 +9,8 @@ INNER JOIN ( WHERE [o1].[row] <= 2 ) AS [o2] ON [u].[Id] = [o2].[UserId] WHERE ( - SELECT TOP(1) [o].[Id] + SELECT [o].[Id] FROM [Order] AS [o] WHERE [u].[Id] = [o].[UserId] - ORDER BY [o].[RecordDate] DESC) > 100 \ No newline at end of file + ORDER BY [o].[RecordDate] DESC + FETCH FIRST 1 ROWS ONLY) > 100 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt index f49ccac..f4b46ea 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.ExtensionMethodAcceptingDbContext.DotNet10_0.verified.txt @@ -1,7 +1,8 @@ SELECT [e1].[Id], [e1].[Name] FROM [Entity] AS [e] OUTER APPLY ( - SELECT TOP(1) [e0].[Id], [e0].[Name] + SELECT [e0].[Id], [e0].[Name] FROM [Entity] AS [e0] WHERE [e0].[Id] > [e].[Id] + FETCH FIRST 1 ROWS ONLY ) AS [e1] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt index 43ca93d..57f2233 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ExtensionsMethods/ExtensionMethodTests.SelectProjectableExtensionMethod2.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + 1 + 1 +SELECT ([e].[Id] + 1) + 1 FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/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/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt index 6a9d698..992871d 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingBasePropertyInDerivedBody.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName] AS [Code], N'[' + COALESCE([p].[FirstName], N'') + N']' AS [Label] +SELECT [p].[FirstName] AS [Code], (N'[' + COALESCE([p].[FirstName], N'')) + N']' AS [Label] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt index cccd5bb..5c8a30f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingPreviouslyAssignedProperty.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[FirstName], [p].[LastName], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt index 2752cb7..c3922a7 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorReferencingStaticConstMember.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' - ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' - ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt index 2f9967b..10f4b26 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithBaseInitializerAndIfElse.DotNet10_0.verified.txt @@ -1,5 +1,5 @@ -SELECT CASE +SELECT CASE WHEN [p].[Id] < 0 THEN 0 ELSE [p].[Id] -END AS [Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +END AS [Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt index 8fc80f2..5dafde0 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ConstructorWithLocalVariable.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_DerivedDtoWithBaseConstructor.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_EntityInstanceToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithThreeArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt index dee2833..5dafde0 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_OverloadedConstructor_WithTwoArgs.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ScalarFieldsToDto.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt index 6004f0a..bd53399 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_ChainedThisAndBase.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N'-' + COALESCE([p].[LastName], N'') AS [Name] +SELECT [p].[Id], COALESCE([p].[FirstName], N'') + (N'-' + COALESCE([p].[LastName], N'')) AS [Name] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt index cccd5bb..5c8a30f 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_ThisOverload_WithBodyAfterDelegation.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[FirstName], [p].[LastName], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[FirstName], [p].[LastName], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt index a8f3f2f..fdf3a04 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/ProjectableConstructorTests.Select_UnassignedPropertyNotInQuery.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [p].[Id], COALESCE([p].[FirstName], N'') + N' ' + COALESCE([p].[LastName], N'') AS [FullName] +SELECT [p].[Id], (COALESCE([p].[FirstName], N'') + N' ') + COALESCE([p].[LastName], N'') AS [FullName] FROM [PersonEntity] AS [p] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt index 75b71ad..bf59ef8 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/QueryRootTests.EntityRootSubqueryExpression.DotNet10_0.verified.txt @@ -1,6 +1,6 @@ SELECT [e].[Id], ( SELECT COUNT(*) FROM [Entity] AS [e0] - WHERE [e0].[Id] * 5 = 5) AS [TotalCount] + WHERE ([e0].[Id] * 5) = 5) AS [TotalCount] FROM [Entity] AS [e] -WHERE [e].[Id] * 5 = 5 \ No newline at end of file +WHERE ([e].[Id] * 5) = 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt new file mode 100644 index 0000000..6f59285 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInConcat_BothSidesUseProjectable.DotNet10_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE [e].[Id] >= 1 AND [e].[Id] <= 5 +UNION ALL +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 5 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/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/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt new file mode 100644 index 0000000..615eff9 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInSelfJoin_BothSidesUseProjectable.DotNet10_0.verified.txt @@ -0,0 +1,8 @@ +SELECT [e].[Id] AS [OuterId], [e1].[Name] AS [InnerName] +FROM [Entity] AS [e] +INNER JOIN ( + SELECT [e0].[Id], [e0].[Name] + FROM [Entity] AS [e0] + WHERE [e0].[Id] >= 1 AND [e0].[Id] <= 10 +) AS [e1] ON [e].[Id] = ([e1].[Id] + 1) +WHERE [e].[Id] >= 1 AND [e].[Id] <= 10 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/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/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt new file mode 100644 index 0000000..7f2b439 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.ProjectableInUnion_BothSidesUseProjectable.DotNet10_0.verified.txt @@ -0,0 +1,7 @@ +SELECT [e].[Id], [e].[Name] +FROM [Entity] AS [e] +WHERE ([e].[Id] % 2) = 0 +UNION +SELECT [e0].[Id], [e0].[Name] +FROM [Entity] AS [e0] +WHERE ([e0].[Id] % 2) = 0 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/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/SetOperationWithProjectableTests.cs b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.cs new file mode 100644 index 0000000..9c77d96 --- /dev/null +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/SetOperationWithProjectableTests.cs @@ -0,0 +1,80 @@ +using EntityFrameworkCore.Projectables.FunctionalTests.Helpers; +using Microsoft.EntityFrameworkCore; +using VerifyXunit; +using Xunit; + +namespace EntityFrameworkCore.Projectables.FunctionalTests; + +/// +/// 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 SetOperationWithProjectableTests +{ + 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 use the same filtered projectable condition. + /// + [Fact] + public Task ProjectableInConcat_BothSidesUseProjectable() + { + 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 use the same filtered projectable property. + /// + [Fact] + public Task ProjectableInUnion_BothSidesUseProjectable() + { + 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 where both sides use a projectable. + /// + [Fact] + public Task ProjectableInSelfJoin_BothSidesUseProjectable() + { + 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()); + } +} diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt index d4ddd00..b5c9406 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullComplexFunctionTests.FilterOnProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] + 1 = 2 \ No newline at end of file +WHERE ([e].[Id] + 1) = 2 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt index c89d0e6..e3dfe9e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + [e].[Id] * 2 +SELECT [e].[Id] + ([e].[Id] * 2) FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt index 6c95f6a..666a621 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullPropertyTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] * 2 = 2 \ No newline at end of file +WHERE ([e].[Id] * 2) = 2 \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt index c89d0e6..e3dfe9e 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.CombineSelectProjectableProperties.DotNet10_0.verified.txt @@ -1,2 +1,2 @@ -SELECT [e].[Id] + [e].[Id] * 2 +SELECT [e].[Id] + ([e].[Id] * 2) FROM [Entity] AS [e] \ No newline at end of file diff --git a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt index 6c95f6a..666a621 100644 --- a/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt +++ b/tests/EntityFrameworkCore.Projectables.FunctionalTests/StatefullSimpleFunctionTests.FilterOnComplexProjectableProperty.DotNet10_0.verified.txt @@ -1,3 +1,3 @@ SELECT [e].[Id] FROM [Entity] AS [e] -WHERE [e].[Id] * 2 = 2 \ No newline at end of file +WHERE ([e].[Id] * 2) = 2 \ No newline at end of file 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..80f25c0 --- /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) => global::EntityFrameworkCore.Projectables.Variable.Wrap("doubled", (@this.Bar * 2)) + global::EntityFrameworkCore.Projectables.Variable.Wrap("doubled", (@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()); + } +}