From 0c49f787a305d517f2641d895fd33f051b39ac43 Mon Sep 17 00:00:00 2001 From: Koen Date: Sun, 26 Jul 2026 01:08:08 +0000 Subject: [PATCH] preserve visitor rewrites in window function SQL expressions --- .../Internal/WindowFrameBoundSqlExpression.cs | 4 +- .../Internal/WindowFunctionSqlExpression.cs | 52 +++++++++++-- .../WindowFunctionSqlExpressionWrapper.cs | 2 +- .../Internal/WindowSpecSqlExpression.cs | 2 + .../WindowFunctionSqlExpressionTests.cs | 75 +++++++++++++++++++ 5 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 tests/ExpressiveSharp.Tests/RelationalExtensions/WindowFunctionSqlExpressionTests.cs diff --git a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFrameBoundSqlExpression.cs b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFrameBoundSqlExpression.cs index 9ffc9f27..ad1d714f 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFrameBoundSqlExpression.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFrameBoundSqlExpression.cs @@ -30,7 +30,7 @@ public override Expression Quote() => #endif public override bool Equals(object? obj) => - obj is WindowFrameBoundSqlExpression other && BoundInfo == other.BoundInfo; + obj is WindowFrameBoundSqlExpression other && base.Equals(other) && BoundInfo == other.BoundInfo; - public override int GetHashCode() => BoundInfo.GetHashCode(); + public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), BoundInfo); } diff --git a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpression.cs b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpression.cs index e1c13007..a939b37d 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpression.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpression.cs @@ -10,9 +10,11 @@ namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Infrastructur /// RANK, DENSE_RANK, NTILE, aggregates, and navigation functions (ROW_NUMBER uses the built-in /// ). /// -/// Self-rendering: interleaves -/// nodes with column/ordering expressions, producing correct SQL through any provider's -/// — no custom generator replacement needed. +/// Self-rendering: for visitors, +/// interleaves nodes with column/ordering expressions, +/// producing correct SQL through any provider's generator — no custom generator replacement +/// needed. All other visitors rebuild children normally so rewrites (alias remapping, pushdown) +/// are preserved. /// /// /// Function names and clause syntax are hardcoded as literal SQL fragments, relying on SQL:2003 @@ -53,10 +55,44 @@ public WindowFunctionSqlExpression( protected override Expression VisitChildren(ExpressionVisitor visitor) { - EmitWindowFunction( - text => visitor.Visit(new SqlFragmentExpression(text)), - expr => visitor.Visit(expr)); - return this; + if (visitor is QuerySqlGenerator) + { + EmitWindowFunction( + text => visitor.Visit(new SqlFragmentExpression(text)), + expr => visitor.Visit(expr)); + return this; + } + + // Every other visitor (projection pushdown, alias uniquification, ...) rewrites children; + // discarding its results would leave stale references in the generated SQL. + var changed = false; + + var newArguments = new SqlExpression[Arguments.Count]; + for (var i = 0; i < Arguments.Count; i++) + { + newArguments[i] = (SqlExpression)visitor.Visit(Arguments[i]); + changed |= newArguments[i] != Arguments[i]; + } + + var newPartitions = new SqlExpression[Partitions.Count]; + for (var i = 0; i < Partitions.Count; i++) + { + newPartitions[i] = (SqlExpression)visitor.Visit(Partitions[i]); + changed |= newPartitions[i] != Partitions[i]; + } + + var newOrderings = new OrderingExpression[Orderings.Count]; + for (var i = 0; i < Orderings.Count; i++) + { + newOrderings[i] = (OrderingExpression)visitor.Visit(Orderings[i]); + changed |= newOrderings[i] != Orderings[i]; + } + + return changed + ? new WindowFunctionSqlExpression( + FunctionName, newArguments, newPartitions, newOrderings, Type, TypeMapping, + FrameType, FrameStart, FrameEnd) + : this; } protected override void Print(ExpressionPrinter expressionPrinter) => @@ -119,6 +155,7 @@ public override Expression Quote() => public override bool Equals(object? obj) => obj is WindowFunctionSqlExpression other + && base.Equals(other) && FunctionName == other.FunctionName && Arguments.SequenceEqual(other.Arguments) && Partitions.SequenceEqual(other.Partitions) @@ -130,6 +167,7 @@ obj is WindowFunctionSqlExpression other public override int GetHashCode() { var hash = new HashCode(); + hash.Add(base.GetHashCode()); hash.Add(FunctionName); foreach (var a in Arguments) hash.Add(a); foreach (var p in Partitions) hash.Add(p); diff --git a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs index 5caf39e6..9e746c12 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs @@ -54,7 +54,7 @@ private sealed class UnwrapVisitor : ExpressionVisitor { if (node is SqlFragmentExpression fragment && fragment.Sql.StartsWith(PlaceholderPrefix, StringComparison.Ordinal) - && _stash.Remove(fragment.Sql, out var original)) + && _stash.TryGetValue(fragment.Sql, out var original)) { return original; } diff --git a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowSpecSqlExpression.cs b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowSpecSqlExpression.cs index 049e1da3..6977561c 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowSpecSqlExpression.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/Infrastructure/Internal/WindowSpecSqlExpression.cs @@ -119,6 +119,7 @@ public override Expression Quote() => public override bool Equals(object? obj) => obj is WindowSpecSqlExpression other + && base.Equals(other) && Partitions.SequenceEqual(other.Partitions) && Orderings.SequenceEqual(other.Orderings) && FrameType == other.FrameType @@ -128,6 +129,7 @@ obj is WindowSpecSqlExpression other public override int GetHashCode() { var hash = new HashCode(); + hash.Add(base.GetHashCode()); foreach (var p in Partitions) hash.Add(p); foreach (var o in Orderings) hash.Add(o); hash.Add(FrameType); diff --git a/tests/ExpressiveSharp.Tests/RelationalExtensions/WindowFunctionSqlExpressionTests.cs b/tests/ExpressiveSharp.Tests/RelationalExtensions/WindowFunctionSqlExpressionTests.cs new file mode 100644 index 00000000..b6c8c77d --- /dev/null +++ b/tests/ExpressiveSharp.Tests/RelationalExtensions/WindowFunctionSqlExpressionTests.cs @@ -0,0 +1,75 @@ +#pragma warning disable EF1001 + +using System.Linq.Expressions; +using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ExpressiveSharp.Tests.RelationalExtensions; + +[TestClass] +public class WindowFunctionSqlExpressionTests +{ + private static readonly SqlFragmentExpression ColPrice = new("[Price]"); + private static readonly SqlFragmentExpression ColTotal = new("[Total]"); + private static readonly SqlFragmentExpression ColCustomerId = new("[CustomerId]"); + + private static string Print(Expression expr) + { + var printer = new ExpressionPrinter(); + printer.Visit(expr); + return printer.ToString(); + } + + private static WindowFunctionSqlExpression MakeSum(Type type) => + new( + "SUM", + arguments: [ColPrice], + partitions: [ColCustomerId], + orderings: [new OrderingExpression(ColPrice, ascending: true)], + type: type, + typeMapping: null); + + private sealed class ReplacingVisitor(Expression from, Expression to) : ExpressionVisitor + { + public override Expression? Visit(Expression? node) + => ReferenceEquals(node, from) ? to : base.Visit(node); + } + + [TestMethod] + public void VisitChildren_ReplacedArgument_IsReflectedInTheResult() + { + var expr = MakeSum(typeof(double)); + + var visited = new ReplacingVisitor(ColPrice, ColTotal).Visit(expr)!; + + var printed = Print(visited); + StringAssert.Contains(printed, "[Total]"); + } + + [TestMethod] + public void Equals_DifferingClrType_IsNotEqual() + { + var asDouble = MakeSum(typeof(double)); + var asDecimal = MakeSum(typeof(decimal)); + + Assert.AreNotEqual(asDouble, asDecimal); + } + + [TestMethod] + public void UnwrapAll_DuplicatedPlaceholder_RestoresBothOccurrences() + { + var windowFunction = MakeSum(typeof(double)); + + var wrapped = WindowFunctionSqlExpressionWrapper.WrapAll(windowFunction, out var stash); + Assert.AreEqual(1, stash.Count); + + var duplicated = Expression.Block(wrapped, wrapped); + + var unwrapped = (BlockExpression)WindowFunctionSqlExpressionWrapper.UnwrapAll(duplicated, stash); + + Assert.IsInstanceOfType(unwrapped.Expressions[0]); + Assert.IsInstanceOfType(unwrapped.Expressions[1]); + } +}