diff --git a/CLAUDE.md b/CLAUDE.md index 767a2c2..728ecc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,12 @@ ExpressiveSharp.EntityFrameworkCore (net8.0;net10.0) └── Provides: ExpressiveDbSet, IIncludableRewritableQueryable, chain-continuity stubs, async lambda stubs with [PolyfillTarget] +ExpressiveSharp.EntityFrameworkCore.Relational (net8.0;net10.0) + ├── ExpressiveSharp.EntityFrameworkCore + ├── EF Core Relational 8.0.25 / 10.0.0 + └── Provides: Window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE), + auto-discovered via IExpressivePlugin assembly attribute + ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0) └── Microsoft.CodeAnalysis.CSharp.Workspaces 4.12.0 (packed into ExpressiveSharp.EntityFrameworkCore NuGet package) @@ -95,6 +101,7 @@ ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0) | `ExpressiveSharp.IntegrationTests.ExpressionCompile` | Compiles and invokes generated expression trees directly | | `ExpressiveSharp.IntegrationTests.EntityFrameworkCore` | EF Core query translation validation | | `ExpressiveSharp.EntityFrameworkCore.Tests` | EF Core integration-specific tests | +| `ExpressiveSharp.EntityFrameworkCore.Relational.Tests` | Window function SQL shape + integration tests (SQLite) | | `ExpressiveSharp.Benchmarks` | BenchmarkDotNet performance benchmarks (generator, resolver, replacer, transformers, EF Core) | ### Three Verification Levels (see `docs/testing-strategy.md`) diff --git a/Directory.Packages.props b/Directory.Packages.props index d2196b4..f37ea3a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,7 +11,10 @@ + + + diff --git a/ExpressiveSharp.slnx b/ExpressiveSharp.slnx index 677ed84..0aeda9b 100644 --- a/ExpressiveSharp.slnx +++ b/ExpressiveSharp.slnx @@ -10,6 +10,7 @@ + @@ -19,6 +20,7 @@ + diff --git a/README.md b/README.md index d4bac85..7228a01 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Mark computed properties and methods with [`[Expressive]`](#expressive-attribute |---|---| | **EF Core** — modern syntax + `[Expressive]` expansion on `DbSet` | [`ExpressiveDbSet`](#ef-core-integration) (or [`UseExpressives()`](#ef-core-integration) for global `[Expressive]` expansion) | | **Any `IQueryable`** — modern syntax + `[Expressive]` expansion | [`.WithExpressionRewrite()`](#irewritablequeryt) | +| **EF Core** — SQL window functions (ROW_NUMBER, RANK, etc.) | [`WindowFunction.*`](#window-functions-sql) (install `ExpressiveSharp.EntityFrameworkCore.Relational`) | | **Advanced** — build an `Expression` inline, no attribute needed | [`ExpressionPolyfill.Create`](#expressionpolyfillcreate) | | **Advanced** — expand `[Expressive]` members in an existing expression tree | [`.ExpandExpressives()`](#expressive-attribute) | | **Advanced** — make third-party/BCL members expressable | [`[ExpressiveFor]`](#expressivefor--external-member-mapping) | @@ -224,6 +225,61 @@ var result = await ctx.Orders .FirstOrDefaultAsync(o => o.Total > 100); ``` +### Window Functions (SQL) + +Install `ExpressiveSharp.EntityFrameworkCore.Relational` for SQL window function support (ROW_NUMBER, RANK, DENSE_RANK, NTILE): + +```bash +dotnet add package ExpressiveSharp.EntityFrameworkCore.Relational +``` + +Enable it in your `DbContextOptionsBuilder`: + +```csharp +var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .UseExpressives(o => o.UseRelationalExtensions()) + .Options; +``` + +Then use window functions in your queries: + +```csharp +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; + +var ranked = db.Orders.Select(o => new +{ + o.Id, + o.Price, + RowNum = WindowFunction.RowNumber( + Window.OrderBy(o.Price)), + PriceRank = WindowFunction.Rank( + Window.PartitionBy(o.CustomerId) + .OrderByDescending(o.Price)), + Quartile = WindowFunction.Ntile(4, + Window.OrderBy(o.Id)) +}); +``` + +Generated SQL: + +```sql +SELECT "o"."Id", "o"."Price", + ROW_NUMBER() OVER(ORDER BY "o"."Price"), + RANK() OVER(PARTITION BY "o"."CustomerId" ORDER BY "o"."Price" DESC), + NTILE(4) OVER(ORDER BY "o"."Id") +FROM "Orders" AS "o" +``` + +Build window specifications with the fluent API: + +| Method | SQL | +|---|---| +| `Window.OrderBy(expr)` | `ORDER BY expr ASC` | +| `Window.OrderByDescending(expr)` | `ORDER BY expr DESC` | +| `Window.PartitionBy(expr)` | `PARTITION BY expr` | +| `.ThenBy(expr)` / `.ThenByDescending(expr)` | Additional ordering columns | + ## Supported C# Features ### Expression-Level @@ -435,6 +491,7 @@ Key improvements: broader C# syntax support (switch expressions, pattern matchin |---|---|---| | **ExpressiveSharp** | C# 12 | C# 14 | | **ExpressiveSharp.EntityFrameworkCore** | EF Core 8.x | EF Core 10.x | +| **ExpressiveSharp.EntityFrameworkCore.Relational** | EF Core 8.x | EF Core 10.x | ## Contributing diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveOptionsBuilderExtensions.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveOptionsBuilderExtensions.cs new file mode 100644 index 0000000..f1ddb8c --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveOptionsBuilderExtensions.cs @@ -0,0 +1,14 @@ +using ExpressiveSharp.EntityFrameworkCore.Relational; + +// ReSharper disable once CheckNamespace — intentionally in parent namespace for discoverability +namespace ExpressiveSharp.EntityFrameworkCore; + +public static class ExpressiveOptionsBuilderExtensions +{ + /// + /// Enables relational database extensions: SQL window functions + /// (ROW_NUMBER, RANK, DENSE_RANK, NTILE) and indexed Select support. + /// + public static ExpressiveOptionsBuilder UseRelationalExtensions(this ExpressiveOptionsBuilder builder) + => builder.AddPlugin(new RelationalExpressivePlugin()); +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveSharp.EntityFrameworkCore.Relational.csproj b/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveSharp.EntityFrameworkCore.Relational.csproj new file mode 100644 index 0000000..7f13e72 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveSharp.EntityFrameworkCore.Relational.csproj @@ -0,0 +1,23 @@ + + + + Relational database extensions for ExpressiveSharp EF Core — window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) and indexed Select + + + + + + + + + + + + + + diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionEvaluatableExpressionFilter.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionEvaluatableExpressionFilter.cs new file mode 100644 index 0000000..661b3bf --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionEvaluatableExpressionFilter.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; +using Microsoft.EntityFrameworkCore.Query; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Prevents , , and +/// method calls from being client-evaluated. These must remain as expression tree nodes +/// for the method call translators to handle. +/// +internal sealed class WindowFunctionEvaluatableExpressionFilter : IEvaluatableExpressionFilterPlugin +{ + public bool IsEvaluatableExpression(Expression expression) + { + if (expression is MethodCallExpression methodCall) + { + var declaringType = methodCall.Method.DeclaringType; + if (declaringType == typeof(Window) + || declaringType == typeof(WindowDefinition) + || declaringType == typeof(WindowFunction)) + { + return false; + } + } + + return true; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionMethodCallTranslator.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionMethodCallTranslator.cs new file mode 100644 index 0000000..b1a1b37 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionMethodCallTranslator.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Translates static methods into SQL window function expressions. +/// ROW_NUMBER uses the built-in ; +/// RANK, DENSE_RANK, and NTILE use . +/// +internal sealed class WindowFunctionMethodCallTranslator : IMethodCallTranslator +{ + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + public WindowFunctionMethodCallTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + _sqlExpressionFactory = sqlExpressionFactory; + _typeMappingSource = typeMappingSource; + } + + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (method.DeclaringType != typeof(WindowFunction)) + return null; + + var longTypeMapping = _typeMappingSource.FindMapping(typeof(long))!; + + return method.Name switch + { + nameof(WindowFunction.RowNumber) when arguments.Count == 1 && arguments[0] is WindowSpecSqlExpression spec + => new RowNumberExpression(spec.Partitions, spec.Orderings, longTypeMapping), + + nameof(WindowFunction.RowNumber) when arguments.Count == 0 + => new RowNumberExpression( + [], + [new OrderingExpression( + _sqlExpressionFactory.Fragment("(SELECT NULL)"), + ascending: true)], + longTypeMapping), + + nameof(WindowFunction.Rank) when arguments.Count >= 1 && arguments[0] is WindowSpecSqlExpression spec + => new WindowFunctionSqlExpression("RANK", [], spec.Partitions, spec.Orderings, typeof(long), longTypeMapping), + + nameof(WindowFunction.DenseRank) when arguments.Count >= 1 && arguments[0] is WindowSpecSqlExpression spec + => new WindowFunctionSqlExpression("DENSE_RANK", [], spec.Partitions, spec.Orderings, typeof(long), longTypeMapping), + + nameof(WindowFunction.Ntile) when arguments.Count >= 2 && arguments[1] is WindowSpecSqlExpression spec + => new WindowFunctionSqlExpression("NTILE", + [_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[0])], + spec.Partitions, spec.Orderings, typeof(long), longTypeMapping), + + _ => null + }; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessor.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessor.cs new file mode 100644 index 0000000..38b87c1 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessor.cs @@ -0,0 +1,58 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Wraps the provider's to temporarily +/// hide nodes during nullability processing. +/// The provider's own processor (with all its provider-specific customizations) handles +/// the actual work — we only intercept the public entry points to wrap/unwrap. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required for custom SQL expression nullability processing")] +internal sealed class WindowFunctionParameterBasedSqlProcessor : RelationalParameterBasedSqlProcessor +{ + private readonly RelationalParameterBasedSqlProcessor _inner; + +#if NET10_0_OR_GREATER + public WindowFunctionParameterBasedSqlProcessor( + RelationalParameterBasedSqlProcessor inner, + RelationalParameterBasedSqlProcessorDependencies dependencies, + RelationalParameterBasedSqlProcessorParameters parameters) + : base(dependencies, parameters) + { + _inner = inner; + } + + public override Expression Process( + Expression queryExpression, + ParametersCacheDecorator parametersDecorator) + { + var wrapped = WindowFunctionSqlExpressionWrapper.WrapAll(queryExpression, out var stash); + var processed = _inner.Process(wrapped, parametersDecorator); + return WindowFunctionSqlExpressionWrapper.UnwrapAll(processed, stash); + } +#else + public WindowFunctionParameterBasedSqlProcessor( + RelationalParameterBasedSqlProcessor inner, + RelationalParameterBasedSqlProcessorDependencies dependencies, + bool useRelationalNulls) + : base(dependencies, useRelationalNulls) + { + _inner = inner; + } +#endif + +#if !NET10_0_OR_GREATER + public override Expression Optimize( + Expression queryExpression, + IReadOnlyDictionary parametersValues, + out bool canCache) + { + var wrapped = WindowFunctionSqlExpressionWrapper.WrapAll(queryExpression, out var stash); + var processed = _inner.Optimize(wrapped, parametersValues, out canCache); + return WindowFunctionSqlExpressionWrapper.UnwrapAll(processed, stash); + } +#endif +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessorFactory.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessorFactory.cs new file mode 100644 index 0000000..10bdf29 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionParameterBasedSqlProcessorFactory.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Query; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Decorates the existing to wrap +/// the provider's processor with handling. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required for custom SQL expression nullability processing")] +internal sealed class WindowFunctionParameterBasedSqlProcessorFactory : IRelationalParameterBasedSqlProcessorFactory +{ + private readonly IRelationalParameterBasedSqlProcessorFactory _inner; + private readonly RelationalParameterBasedSqlProcessorDependencies _dependencies; + + public WindowFunctionParameterBasedSqlProcessorFactory( + IRelationalParameterBasedSqlProcessorFactory inner, + RelationalParameterBasedSqlProcessorDependencies dependencies) + { + _inner = inner; + _dependencies = dependencies; + } + +#if NET10_0_OR_GREATER + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) => + new WindowFunctionParameterBasedSqlProcessor(_inner.Create(parameters), _dependencies, parameters); +#else + public RelationalParameterBasedSqlProcessor Create(bool useRelationalNulls) => + new WindowFunctionParameterBasedSqlProcessor(_inner.Create(useRelationalNulls), _dependencies, useRelationalNulls); +#endif +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpression.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpression.cs new file mode 100644 index 0000000..1a7b1f7 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpression.cs @@ -0,0 +1,120 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// SQL expression representing a window function call: FUNC_NAME(args) OVER(PARTITION BY ... ORDER BY ...). +/// Used for RANK, DENSE_RANK, NTILE (ROW_NUMBER uses the built-in ). +/// +/// This expression is self-rendering: produces correct SQL through +/// any provider's by interleaving +/// nodes with the actual column/ordering expressions. This makes it fully provider-agnostic — +/// no custom QuerySqlGenerator replacement is needed. +/// +/// +internal sealed class WindowFunctionSqlExpression : SqlExpression +{ + public string FunctionName { get; } + public IReadOnlyList Arguments { get; } + public IReadOnlyList Partitions { get; } + public IReadOnlyList Orderings { get; } + + public WindowFunctionSqlExpression( + string functionName, + IReadOnlyList arguments, + IReadOnlyList partitions, + IReadOnlyList orderings, + Type type, + RelationalTypeMapping? typeMapping) + : base(type, typeMapping) + { + FunctionName = functionName; + Arguments = arguments; + Partitions = partitions; + Orderings = orderings; + } + + /// + /// Self-rendering: when any QuerySqlGenerator visits this expression via VisitExtension, + /// it calls VisitChildren, which visits SqlFragmentExpression and child SqlExpression nodes + /// in the correct order to produce FUNC(args) OVER(PARTITION BY ... ORDER BY ...). + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + EmitWindowFunction( + text => visitor.Visit(new SqlFragmentExpression(text)), + expr => visitor.Visit(expr)); + return this; + } + + /// Diagnostic output for logging and ToString(). Not used for SQL generation. + protected override void Print(ExpressionPrinter expressionPrinter) => + EmitWindowFunction( + text => expressionPrinter.Append(text), + expr => expressionPrinter.Visit(expr)); + + /// + /// Shared rendering logic for both SQL generation () and + /// diagnostic output (). Produces the + /// FUNC(args) OVER(PARTITION BY ... ORDER BY ...) structure. + /// + private void EmitWindowFunction(Action appendText, Action visitExpression) + { + appendText($"{FunctionName}("); + for (var i = 0; i < Arguments.Count; i++) + { + if (i > 0) appendText(", "); + visitExpression(Arguments[i]); + } + appendText(") OVER("); + + if (Partitions.Count > 0) + { + appendText("PARTITION BY "); + for (var i = 0; i < Partitions.Count; i++) + { + if (i > 0) appendText(", "); + visitExpression(Partitions[i]); + } + } + + if (Orderings.Count > 0) + { + if (Partitions.Count > 0) appendText(" "); + appendText("ORDER BY "); + for (var i = 0; i < Orderings.Count; i++) + { + if (i > 0) appendText(", "); + visitExpression(Orderings[i].Expression); + appendText(Orderings[i].IsAscending ? " ASC" : " DESC"); + } + } + + appendText(")"); + } + +#if NET10_0_OR_GREATER + public override Expression Quote() => + throw new InvalidOperationException("WindowFunctionSqlExpression quoting is not supported."); +#endif + + public override bool Equals(object? obj) => + obj is WindowFunctionSqlExpression other + && FunctionName == other.FunctionName + && Arguments.SequenceEqual(other.Arguments) + && Partitions.SequenceEqual(other.Partitions) + && Orderings.SequenceEqual(other.Orderings); + + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(FunctionName); + foreach (var a in Arguments) hash.Add(a); + foreach (var p in Partitions) hash.Add(p); + foreach (var o in Orderings) hash.Add(o); + return hash.ToHashCode(); + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs new file mode 100644 index 0000000..a13f3c3 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionSqlExpressionWrapper.cs @@ -0,0 +1,74 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Temporarily wraps nodes in +/// placeholders so the provider's +/// +/// can traverse the tree without throwing on unknown expression types. +/// After nullability processing, the originals are restored. +/// +internal static class WindowFunctionSqlExpressionWrapper +{ + private const string PlaceholderPrefix = "__wf_placeholder_"; + + /// + /// Replaces all nodes in the tree with + /// placeholders, stashing the originals for later restoration. + /// + public static Expression WrapAll(Expression expression, out Dictionary stash) + { + var visitor = new WrapVisitor(); + var result = visitor.Visit(expression); + stash = visitor.Stash; + return result; + } + + /// + /// Restores all placeholder nodes back to + /// their original . + /// + public static Expression UnwrapAll(Expression expression, Dictionary stash) + => new UnwrapVisitor(stash).Visit(expression); + + private sealed class WrapVisitor : ExpressionVisitor + { + public Dictionary Stash { get; } = new(); + private int _counter; + + [return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) + { + if (node is WindowFunctionSqlExpression windowFunc) + { + var key = $"{PlaceholderPrefix}{_counter++}"; + Stash[key] = windowFunc; + return new SqlFragmentExpression(key); + } + + return base.Visit(node); + } + } + + private sealed class UnwrapVisitor : ExpressionVisitor + { + private readonly Dictionary _stash; + + public UnwrapVisitor(Dictionary stash) => _stash = stash; + + [return: System.Diagnostics.CodeAnalysis.NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) + { + if (node is SqlFragmentExpression fragment + && fragment.Sql.StartsWith(PlaceholderPrefix, StringComparison.Ordinal) + && _stash.Remove(fragment.Sql, out var original)) + { + return original; + } + + return base.Visit(node); + } + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionTranslatorPlugin.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionTranslatorPlugin.cs new file mode 100644 index 0000000..e64705a --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowFunctionTranslatorPlugin.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Registers window function method call translators with EF Core's query pipeline. +/// +internal sealed class WindowFunctionTranslatorPlugin : IMethodCallTranslatorPlugin +{ + public IEnumerable Translators { get; } + + public WindowFunctionTranslatorPlugin( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + Translators = + [ + new WindowSpecMethodCallTranslator(), + new WindowFunctionMethodCallTranslator(sqlExpressionFactory, typeMappingSource) + ]; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecMethodCallTranslator.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecMethodCallTranslator.cs new file mode 100644 index 0000000..9e5ac9a --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecMethodCallTranslator.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Translates static methods and instance methods +/// into intermediate nodes. +/// +internal sealed class WindowSpecMethodCallTranslator : IMethodCallTranslator +{ + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + var declaringType = method.DeclaringType; + + // Static methods on Window class + if (declaringType == typeof(Window)) + { + return method.Name switch + { + nameof(Window.PartitionBy) => new WindowSpecSqlExpression( + [arguments[0]], [], typeMapping: null), + nameof(Window.OrderBy) => new WindowSpecSqlExpression( + [], [new OrderingExpression(arguments[0], ascending: true)], typeMapping: null), + nameof(Window.OrderByDescending) => new WindowSpecSqlExpression( + [], [new OrderingExpression(arguments[0], ascending: false)], typeMapping: null), + _ => null + }; + } + + // Instance methods on WindowDefinition + if (declaringType == typeof(WindowDefinition) && instance is WindowSpecSqlExpression spec) + { + return method.Name switch + { + nameof(WindowDefinition.PartitionBy) => spec.WithPartition(arguments[0]), + nameof(WindowDefinition.OrderBy) or nameof(WindowDefinition.ThenBy) => + spec.WithOrdering(arguments[0], ascending: true), + nameof(WindowDefinition.OrderByDescending) or nameof(WindowDefinition.ThenByDescending) => + spec.WithOrdering(arguments[0], ascending: false), + _ => null + }; + } + + return null; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecSqlExpression.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecSqlExpression.cs new file mode 100644 index 0000000..27f02b6 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Infrastructure/Internal/WindowSpecSqlExpression.cs @@ -0,0 +1,111 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; + +/// +/// Intermediate SQL expression that carries the PARTITION BY and ORDER BY clauses +/// of a window specification. This node is consumed by the window function translator +/// and should never reach final SQL rendering. +/// +internal sealed class WindowSpecSqlExpression : SqlExpression +{ + public IReadOnlyList Partitions { get; } + public IReadOnlyList Orderings { get; } + + public WindowSpecSqlExpression( + IReadOnlyList partitions, + IReadOnlyList orderings, + RelationalTypeMapping? typeMapping) + : base(typeof(object), typeMapping) + { + Partitions = partitions; + Orderings = orderings; + } + + public WindowSpecSqlExpression WithPartition(SqlExpression partition) + { + var newPartitions = new List(Partitions) { partition }; + return new WindowSpecSqlExpression(newPartitions, Orderings, TypeMapping); + } + + public WindowSpecSqlExpression WithOrdering(SqlExpression expression, bool ascending) + { + var newOrderings = new List(Orderings) + { + new(expression, ascending) + }; + return new WindowSpecSqlExpression(Partitions, newOrderings, TypeMapping); + } + + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var changed = false; + + 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 WindowSpecSqlExpression(newPartitions, newOrderings, TypeMapping) + : this; + } + + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append("WindowSpec("); + if (Partitions.Count > 0) + { + expressionPrinter.Append("PARTITION BY "); + for (var i = 0; i < Partitions.Count; i++) + { + if (i > 0) expressionPrinter.Append(", "); + expressionPrinter.Visit(Partitions[i]); + } + } + + if (Orderings.Count > 0) + { + if (Partitions.Count > 0) expressionPrinter.Append(" "); + expressionPrinter.Append("ORDER BY "); + for (var i = 0; i < Orderings.Count; i++) + { + if (i > 0) expressionPrinter.Append(", "); + expressionPrinter.Visit(Orderings[i].Expression); + expressionPrinter.Append(Orderings[i].IsAscending ? " ASC" : " DESC"); + } + } + + expressionPrinter.Append(")"); + } + +#if NET10_0_OR_GREATER + public override Expression Quote() => + throw new InvalidOperationException("WindowSpecSqlExpression is an intermediate node and should not be quoted."); +#endif + + public override bool Equals(object? obj) => + obj is WindowSpecSqlExpression other + && Partitions.SequenceEqual(other.Partitions) + && Orderings.SequenceEqual(other.Orderings); + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var p in Partitions) hash.Add(p); + foreach (var o in Orderings) hash.Add(o); + return hash.ToHashCode(); + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/RelationalExpressivePlugin.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/RelationalExpressivePlugin.cs new file mode 100644 index 0000000..63e9c08 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/RelationalExpressivePlugin.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal; +using ExpressiveSharp.EntityFrameworkCore.Relational.Transformers; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational; + +/// +/// Plugin that registers window function services into the EF Core service provider. +/// Activated via .UseExpressives(o => o.UseRelationalExtensions()). +/// +public sealed class RelationalExpressivePlugin : IExpressivePlugin +{ + [SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to decorate EF Core services")] + public void ApplyServices(IServiceCollection services) + { + // Register method call translator plugin (scoped — matches EF Core's service lifetimes) + services.AddScoped(); + + // Register evaluatable expression filter + services.AddSingleton(); + + // Decorate the SQL nullability processor factory to handle WindowFunctionSqlExpression. + // Uses the decorator pattern to preserve the provider's existing factory. + DecorateParameterBasedSqlProcessorFactory(services); + } + + public IExpressionTreeTransformer[] GetTransformers() => + [new RewriteIndexedSelectToRowNumber()]; + + private static void DecorateParameterBasedSqlProcessorFactory(IServiceCollection services) + { + var targetDescriptor = services.FirstOrDefault( + x => x.ServiceType == typeof(IRelationalParameterBasedSqlProcessorFactory)); + if (targetDescriptor is null) return; + + services.Replace(ServiceDescriptor.Describe( + typeof(IRelationalParameterBasedSqlProcessorFactory), + sp => + { + var inner = CreateTargetInstance(sp, targetDescriptor); + var dependencies = sp.GetRequiredService(); + return new WindowFunctionParameterBasedSqlProcessorFactory(inner, dependencies); + }, + targetDescriptor.Lifetime)); + } + + private static T CreateTargetInstance(IServiceProvider services, ServiceDescriptor descriptor) + { + if (descriptor.ImplementationInstance is T instance) + return instance; + + if (descriptor.ImplementationFactory is not null) + return (T)descriptor.ImplementationFactory(services); + + Debug.Assert(descriptor.ImplementationType is not null); + return (T)ActivatorUtilities.CreateInstance(services, descriptor.ImplementationType!); + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/Transformers/RewriteIndexedSelectToRowNumber.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Transformers/RewriteIndexedSelectToRowNumber.cs new file mode 100644 index 0000000..0cff160 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/Transformers/RewriteIndexedSelectToRowNumber.cs @@ -0,0 +1,93 @@ +using System.Linq.Expressions; +using System.Reflection; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Transformers; + +/// +/// Rewrites Queryable.Select(source, (elem, index) => body) into +/// Queryable.Select(source, elem => body') where references to the +/// int index parameter are replaced with WindowFunction.RowNumber() - 1. +/// +/// The resulting WindowFunction.RowNumber() call produces +/// ROW_NUMBER() OVER() with no ordering — row numbering is non-deterministic +/// unless the query includes an explicit OrderBy. +/// +/// +public sealed class RewriteIndexedSelectToRowNumber : IExpressionTreeTransformer +{ + private static readonly MethodInfo RowNumberMethod = + typeof(WindowFunction).GetMethod(nameof(WindowFunction.RowNumber), Type.EmptyTypes)!; + + public Expression Transform(Expression expression) + => new IndexedSelectRewriter().Visit(expression); + + private sealed class IndexedSelectRewriter : ExpressionVisitor + { + protected override Expression VisitMethodCall(MethodCallExpression node) + { + // Look for Queryable.Select(source, Expression>) + if (node.Method.DeclaringType != typeof(Queryable) + || node.Method.Name != "Select" + || node.Arguments.Count != 2) + return base.VisitMethodCall(node); + + // The second argument must be a quoted lambda: Expression> + if (UnwrapQuote(node.Arguments[1]) is not LambdaExpression lambda + || lambda.Parameters.Count != 2 + || lambda.Parameters[1].Type != typeof(int)) + return base.VisitMethodCall(node); + + var elemParam = lambda.Parameters[0]; + var indexParam = lambda.Parameters[1]; + + // Build: (long)WindowFunction.RowNumber() - 1L + var rowNumberCall = Expression.Call(RowNumberMethod); + var subtractOne = Expression.Subtract(rowNumberCall, Expression.Constant(1L)); + var castToInt = Expression.Convert(subtractOne, typeof(int)); + + // Replace index parameter references with the ROW_NUMBER expression + var rewrittenBody = new ParameterReplacer(indexParam, castToInt).Visit(lambda.Body); + + // Build new 1-parameter lambda + var newLambda = Expression.Lambda(rewrittenBody, elemParam); + + // Find the 1-param Queryable.Select overload + var sourceType = elemParam.Type; + var resultType = lambda.ReturnType; + var selectMethod = typeof(Queryable).GetMethods() + .First(m => m.Name == "Select" + && m.GetGenericArguments().Length == 2 + && m.GetParameters()[1].ParameterType.GetGenericArguments()[0] + .GetGenericArguments().Length == 2) // Func has 2 type args + .MakeGenericMethod(sourceType, resultType); + + // Visit the source in case it also needs transformation + var visitedSource = Visit(node.Arguments[0]); + + return Expression.Call(selectMethod, visitedSource, Expression.Quote(newLambda)); + } + + private static Expression? UnwrapQuote(Expression expression) + { + if (expression is UnaryExpression { NodeType: ExpressionType.Quote } unary) + return unary.Operand; + return expression as LambdaExpression; + } + } + + private sealed class ParameterReplacer : ExpressionVisitor + { + private readonly ParameterExpression _oldParam; + private readonly Expression _replacement; + + public ParameterReplacer(ParameterExpression oldParam, Expression replacement) + { + _oldParam = oldParam; + _replacement = replacement; + } + + protected override Expression VisitParameter(ParameterExpression node) + => node == _oldParam ? _replacement : node; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/Window.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/Window.cs new file mode 100644 index 0000000..1a7a815 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/Window.cs @@ -0,0 +1,21 @@ +namespace ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; + +/// +/// Static entry point for building window specifications used in SQL window functions. +/// These methods are markers — they are translated to SQL by the EF Core query pipeline +/// and throw at runtime if called directly. +/// +public static class Window +{ + /// Creates a window specification with a PARTITION BY clause. + public static WindowDefinition PartitionBy(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Creates a window specification with an ORDER BY ASC clause. + public static WindowDefinition OrderBy(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Creates a window specification with an ORDER BY DESC clause. + public static WindowDefinition OrderByDescending(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowDefinition.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowDefinition.cs new file mode 100644 index 0000000..f6170f4 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowDefinition.cs @@ -0,0 +1,32 @@ +namespace ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; + +/// +/// Represents a window specification (PARTITION BY + ORDER BY clauses) for SQL window functions. +/// This type is a marker — instances are never created at runtime. The expression tree +/// containing calls to its methods is translated by the EF Core method call translator. +/// +public sealed class WindowDefinition +{ + private WindowDefinition() => + throw new InvalidOperationException("WindowDefinition is a marker type for expression trees and cannot be instantiated."); + + /// Adds an ORDER BY ASC clause to the window specification. + public WindowDefinition OrderBy(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Adds an ORDER BY DESC clause to the window specification. + public WindowDefinition OrderByDescending(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Adds a subsequent ORDER BY ASC clause to the window specification. + public WindowDefinition ThenBy(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Adds a subsequent ORDER BY DESC clause to the window specification. + public WindowDefinition ThenByDescending(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// Adds a PARTITION BY clause to the window specification. + public WindowDefinition PartitionBy(TKey key) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowFunction.cs b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowFunction.cs new file mode 100644 index 0000000..b0c8867 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore.Relational/WindowFunctions/WindowFunction.cs @@ -0,0 +1,44 @@ +namespace ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; + +/// +/// Provides SQL window function stubs (ROW_NUMBER, RANK, DENSE_RANK, NTILE) +/// for use in EF Core LINQ queries. These methods are translated to SQL by +/// ExpressiveSharp's method call translator — they throw at runtime if called directly. +/// +public static class WindowFunction +{ + /// + /// Translates to ROW_NUMBER() OVER(...). + /// Returns a sequential number for each row within the window partition. + /// + public static long RowNumber(WindowDefinition window) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// + /// Translates to ROW_NUMBER() OVER() with no ordering or partitioning. + /// Row numbering is non-deterministic. Used internally by the indexed Select transformer. + /// + public static long RowNumber() => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// + /// Translates to RANK() OVER(...). + /// Returns the rank of each row within the window partition, with gaps for ties. + /// + public static long Rank(WindowDefinition window) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// + /// Translates to DENSE_RANK() OVER(...). + /// Returns the rank of each row within the window partition, without gaps for ties. + /// + public static long DenseRank(WindowDefinition window) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); + + /// + /// Translates to NTILE() OVER(...). + /// Distributes rows into the specified number of roughly equal groups. + /// + public static long Ntile(int buckets, WindowDefinition window) => + throw new InvalidOperationException("This method is translated to SQL and cannot be called directly."); +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs new file mode 100644 index 0000000..46e8e5e --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore/ExpressiveOptionsBuilder.cs @@ -0,0 +1,20 @@ +namespace ExpressiveSharp.EntityFrameworkCore; + +/// +/// Builder for configuring ExpressiveSharp EF Core integration. +/// Passed to the UseExpressives(options => ...) callback to register plugins. +/// +public sealed class ExpressiveOptionsBuilder +{ + internal List Plugins { get; } = []; + + /// + /// Registers an that contributes services and/or + /// expression tree transformers to the EF Core pipeline. + /// + public ExpressiveOptionsBuilder AddPlugin(IExpressivePlugin plugin) + { + Plugins.Add(plugin); + return this; + } +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs index f87691f..6eeaa7f 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Extensions/DbContextOptionsExtensions.cs @@ -1,3 +1,4 @@ +using ExpressiveSharp.EntityFrameworkCore; using ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -16,9 +17,21 @@ public static class DbContextOptionsExtensions /// /// public static DbContextOptionsBuilder UseExpressives(this DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseExpressives(_ => { }); + + /// + /// Enables ExpressiveSharp integration with EF Core with additional plugin configuration. + /// + /// The EF Core options builder. + /// A callback to configure plugins (e.g., options.UseRelationalExtensions()). + public static DbContextOptionsBuilder UseExpressives( + this DbContextOptionsBuilder optionsBuilder, + Action configure) { - var extension = optionsBuilder.Options.FindExtension() - ?? new ExpressiveOptionsExtension(); + var builder = new ExpressiveOptionsBuilder(); + configure(builder); + + var extension = new ExpressiveOptionsExtension(builder.Plugins); ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension); @@ -35,4 +48,16 @@ public static DbContextOptionsBuilder UseExpressives( ((DbContextOptionsBuilder)optionsBuilder).UseExpressives(); return optionsBuilder; } + + /// + /// Enables ExpressiveSharp integration with EF Core with additional plugin configuration (generic overload). + /// + public static DbContextOptionsBuilder UseExpressives( + this DbContextOptionsBuilder optionsBuilder, + Action configure) + where TContext : DbContext + { + ((DbContextOptionsBuilder)optionsBuilder).UseExpressives(configure); + return optionsBuilder; + } } diff --git a/src/ExpressiveSharp.EntityFrameworkCore/IExpressivePlugin.cs b/src/ExpressiveSharp.EntityFrameworkCore/IExpressivePlugin.cs new file mode 100644 index 0000000..b11b3b7 --- /dev/null +++ b/src/ExpressiveSharp.EntityFrameworkCore/IExpressivePlugin.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace ExpressiveSharp.EntityFrameworkCore; + +/// +/// Interface for ExpressiveSharp plugins that register additional services into +/// the EF Core service provider. Plugins are registered via +/// . +/// +public interface IExpressivePlugin +{ + /// + /// Registers services into the EF Core internal service collection. + /// Called during UseExpressives() initialization. + /// + void ApplyServices(IServiceCollection services); + + /// + /// Returns expression tree transformers to add to the EF Core transformer pipeline. + /// Called when building . + /// Default implementation returns an empty array. + /// + IExpressionTreeTransformer[] GetTransformers() => []; +} diff --git a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs index 91c59b8..9b24dbd 100644 --- a/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs +++ b/src/ExpressiveSharp.EntityFrameworkCore/Infrastructure/Internal/ExpressiveOptionsExtension.cs @@ -17,8 +17,18 @@ namespace ExpressiveSharp.EntityFrameworkCore.Infrastructure.Internal; /// public class ExpressiveOptionsExtension : IDbContextOptionsExtension { - public ExpressiveOptionsExtension() + private readonly IReadOnlyList _plugins; + private readonly int _pluginHash; + + public ExpressiveOptionsExtension(IReadOnlyList plugins) { + _plugins = plugins; + + var hash = new HashCode(); + foreach (var plugin in plugins) + hash.Add(plugin.GetType().FullName); + _pluginHash = hash.ToHashCode(); + Info = new ExtensionInfo(this); } @@ -44,7 +54,17 @@ public void ApplyServices(IServiceCollection services) serviceProvider => decoratorFactory(serviceProvider, [CreateTargetInstance(serviceProvider, targetDescriptor)]), targetDescriptor.Lifetime)); + // Apply plugin services + foreach (var plugin in _plugins) + plugin.ApplyServices(services); + + // Collect plugin transformers (captured by value in the closure) + var extraTransformers = _plugins + .SelectMany(p => p.GetTransformers()) + .ToArray(); + // Register a dedicated ExpressiveOptions instance with EF Core transformers + // plus any transformers contributed by plugins services.AddSingleton(sp => { var options = new ExpressiveOptions(); @@ -53,6 +73,8 @@ public void ApplyServices(IServiceCollection services) new RemoveNullConditionalPatterns(), new FlattenTupleComparisons(), new FlattenBlockExpressions()); + if (extraTransformers.Length > 0) + options.AddTransformers(extraTransformers); return options; }); } @@ -80,10 +102,13 @@ public ExtensionInfo(IDbContextOptionsExtension extension) : base(extension) { } public override bool IsDatabaseProvider => false; public override string LogFragment => "UseExpressives "; - public override int GetServiceProviderHashCode() => 0; + public override int GetServiceProviderHashCode() + => ((ExpressiveOptionsExtension)Extension)._pluginHash; public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) - => other is ExtensionInfo; + => other is ExtensionInfo otherInfo + && ((ExpressiveOptionsExtension)otherInfo.Extension)._pluginHash + == ((ExpressiveOptionsExtension)Extension)._pluginHash; public override void PopulateDebugInfo(IDictionary debugInfo) => debugInfo["Expressives:Enabled"] = "true"; diff --git a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs index f5f0c18..0ab8b49 100644 --- a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs +++ b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs @@ -314,6 +314,12 @@ public InterceptsLocationAttribute(int version, string data) { } when typeArgs.Length == 1 => EmitWhere(inv, model, spc, interceptAttr, index, elementSymbol, elementFqn, globalOptions, allStaticFields), + "Select" + when typeArgs.Length == 2 && method.Parameters.Length == 1 + && method.Parameters[0].Type is INamedTypeSymbol selFuncType + && selFuncType.TypeArguments.Length == 3 + => EmitSelectIndexed(inv, model, spc, interceptAttr, index, elementSymbol, elementFqn, typeArgs[1], globalOptions, allStaticFields), + "Select" when typeArgs.Length == 2 => EmitSelect(inv, model, spc, interceptAttr, index, elementSymbol, elementFqn, typeArgs[1], globalOptions, allStaticFields), @@ -496,6 +502,64 @@ private static bool IsAnonymousType(ITypeSymbol type) """; } + private static string? EmitSelectIndexed( + InvocationExpressionSyntax inv, SemanticModel model, + SourceProductionContext spc, string interceptAttr, int idx, + INamedTypeSymbol elemSym, string elemFqn, ITypeSymbol resultType, + ExpressiveGlobalOptions globalOptions, + List allStaticFields) + { + if (inv.ArgumentList.Arguments[0].Expression is not LambdaExpressionSyntax lam) return null; + + var isAnon = IsAnonymousType(resultType); + if (!isAnon) + { + var resultFqn = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var delegateFqn = $"global::System.Func<{elemFqn}, int, {resultFqn}>"; + var emitResult = EmitLambdaBody(lam, elemSym, model, spc, globalOptions, delegateFqn, fieldPrefix: $"i{idx}_"); + if (emitResult is null) return null; + allStaticFields.AddRange(emitResult.StaticFields); + + return $$""" + {{interceptAttr}} + internal static global::ExpressiveSharp.IRewritableQueryable<{{resultFqn}}> {{MethodId("Select", idx)}}( + this global::ExpressiveSharp.IRewritableQueryable<{{elemFqn}}> source, + global::System.Func<{{elemFqn}}, int, {{resultFqn}}> _) + { + {{emitResult.Body}} return global::ExpressiveSharp.Extensions.ExpressionRewriteExtensions.WithExpressionRewrite( + global::System.Linq.Queryable.Select( + (global::System.Linq.IQueryable<{{elemFqn}}>)source, + __lambda)); + } + + """; + } + + var typeAliases = new Dictionary(SymbolEqualityComparer.Default) + { + { resultType, "TResult" } + }; + var anonDelegateFqn = $"global::System.Func<{elemFqn}, int, TResult>"; + var anonEmitResult = EmitLambdaBody(lam, elemSym, model, spc, globalOptions, anonDelegateFqn, fieldPrefix: $"i{idx}_", typeAliases: typeAliases); + if (anonEmitResult is null) return null; + allStaticFields.AddRange(anonEmitResult.StaticFields); + + return $$""" + {{interceptAttr}} + internal static global::ExpressiveSharp.IRewritableQueryable {{MethodId("Select", idx)}}( + this global::ExpressiveSharp.IRewritableQueryable source, + global::System.Func _) + { + {{anonEmitResult.Body}} return (global::ExpressiveSharp.IRewritableQueryable)(object) + global::ExpressiveSharp.Extensions.ExpressionRewriteExtensions.WithExpressionRewrite( + global::System.Linq.Queryable.Select( + (global::System.Linq.IQueryable<{{elemFqn}}>)(object)source, + __lambda)); + } + + """; + } + private static string? EmitSelect( InvocationExpressionSyntax inv, SemanticModel model, SourceProductionContext spc, string interceptAttr, int idx, diff --git a/src/ExpressiveSharp/Extensions/RewritableQueryableLinqExtensions.cs b/src/ExpressiveSharp/Extensions/RewritableQueryableLinqExtensions.cs index 180f4c0..edf2575 100644 --- a/src/ExpressiveSharp/Extensions/RewritableQueryableLinqExtensions.cs +++ b/src/ExpressiveSharp/Extensions/RewritableQueryableLinqExtensions.cs @@ -38,6 +38,12 @@ public static IRewritableQueryable Select( Func selector) => throw new UnreachableException(InterceptedMessage); + [EditorBrowsable(EditorBrowsableState.Never)] + public static IRewritableQueryable Select( + this IRewritableQueryable source, + Func selector) + => throw new UnreachableException(InterceptedMessage); + [EditorBrowsable(EditorBrowsableState.Never)] public static IRewritableQueryable SelectMany( this IRewritableQueryable source, diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests.csproj b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests.csproj new file mode 100644 index 0000000..4be56e2 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests.csproj @@ -0,0 +1,35 @@ + + + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Infrastructure/TestProvider.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Infrastructure/TestProvider.cs new file mode 100644 index 0000000..8e2e0b2 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Infrastructure/TestProvider.cs @@ -0,0 +1,45 @@ +using ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Infrastructure; + +/// +/// Creates instances configured for different database providers. +/// Used by SQL shape tests to verify generated SQL across SQLite, SQL Server, and PostgreSQL. +/// No real database connection is needed — only ToQueryString() is called. +/// +internal static class TestProvider +{ + public static WindowTestDbContext CreateSqliteContext() + { + // SQLite needs a real in-memory connection for ToQueryString to work + var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .UseExpressives(o => o.UseRelationalExtensions()) + .Options; + var ctx = new WindowTestDbContext(options); + ctx.Database.EnsureCreated(); + return ctx; + } + + public static WindowTestDbContext CreateSqlServerContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=dummy;Database=dummy;Encrypt=false") + .UseExpressives(o => o.UseRelationalExtensions()) + .Options; + return new WindowTestDbContext(options); + } + + public static WindowTestDbContext CreateNpgsqlContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=dummy;Database=dummy") + .UseExpressives(o => o.UseRelationalExtensions()) + .Options; + return new WindowTestDbContext(options); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/TestModels.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/TestModels.cs new file mode 100644 index 0000000..53c9809 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/TestModels.cs @@ -0,0 +1,16 @@ +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Models; + +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } = ""; +} + +public class Order +{ + public int Id { get; set; } + public double Price { get; set; } + public int Quantity { get; set; } + public int CustomerId { get; set; } + public Customer? Customer { get; set; } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/WindowTestDbContext.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/WindowTestDbContext.cs new file mode 100644 index 0000000..3d7de43 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/Models/WindowTestDbContext.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Models; + +public class WindowTestDbContext : DbContext +{ + public DbSet Orders => Set(); + public ExpressiveDbSet ExpressiveOrders => this.ExpressiveSet(); + public DbSet Customers => Set(); + + public WindowTestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasOne(e => e.Customer) + .WithMany() + .HasForeignKey(e => e.CustomerId); + }); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ModuleInitializer.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ModuleInitializer.cs new file mode 100644 index 0000000..a4572eb --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ModuleInitializer.cs @@ -0,0 +1,15 @@ +using System.Runtime.CompilerServices; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests; + +internal static class ModuleInitializer +{ + [ModuleInitializer] + public static void Init() + { + if (Environment.GetEnvironmentVariable("VERIFY_AUTO_APPROVE") == "true") + { + VerifierSettings.AutoVerify(); + } + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionIntegrationTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionIntegrationTests.cs new file mode 100644 index 0000000..6f4ded7 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionIntegrationTests.cs @@ -0,0 +1,232 @@ +using ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Models; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests; + +/// +/// End-to-end integration tests that seed data into SQLite, execute window function +/// queries, and verify actual result values. These require a real database connection. +/// +[TestClass] +public class WindowFunctionIntegrationTests +{ + private SqliteConnection _connection = null!; + + [TestInitialize] + public void Setup() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + } + + [TestCleanup] + public void Cleanup() + { + _connection.Dispose(); + } + + private WindowTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .UseExpressives(o => o.UseRelationalExtensions()) + .Options; + var ctx = new WindowTestDbContext(options); + ctx.Database.EnsureCreated(); + return ctx; + } + + private static void SeedTestData(WindowTestDbContext ctx) + { + ctx.Customers.AddRange( + new Customer { Id = 1, Name = "Alice" }, + new Customer { Id = 2, Name = "Bob" }); + ctx.SaveChanges(); + + // Prices: 50, 20, 10, 30, 20, 40, 15, 25, 35, 45 (note duplicates for tie-testing) + ctx.Orders.AddRange( + new Order { Id = 1, Price = 50, Quantity = 1, CustomerId = 1 }, + new Order { Id = 2, Price = 20, Quantity = 2, CustomerId = 1 }, + new Order { Id = 3, Price = 10, Quantity = 3, CustomerId = 2 }, + new Order { Id = 4, Price = 30, Quantity = 1, CustomerId = 2 }, + new Order { Id = 5, Price = 20, Quantity = 5, CustomerId = 1 }, + new Order { Id = 6, Price = 40, Quantity = 2, CustomerId = 2 }, + new Order { Id = 7, Price = 15, Quantity = 1, CustomerId = 1 }, + new Order { Id = 8, Price = 25, Quantity = 3, CustomerId = 2 }, + new Order { Id = 9, Price = 35, Quantity = 2, CustomerId = 1 }, + new Order { Id = 10, Price = 45, Quantity = 1, CustomerId = 2 }); + ctx.SaveChanges(); + } + + private static void SeedTieData(WindowTestDbContext ctx) + { + ctx.Customers.Add(new Customer { Id = 1, Name = "Alice" }); + ctx.SaveChanges(); + ctx.Orders.AddRange( + new Order { Id = 1, Price = 10, Quantity = 1, CustomerId = 1 }, + new Order { Id = 2, Price = 20, Quantity = 1, CustomerId = 1 }, + new Order { Id = 3, Price = 20, Quantity = 1, CustomerId = 1 }, + new Order { Id = 4, Price = 30, Quantity = 1, CustomerId = 1 }); + ctx.SaveChanges(); + } + + [TestMethod] + public async Task RowNumber_ReturnsCorrectSequentialNumbers() + { + using var ctx = CreateContext(); + SeedTestData(ctx); + + var results = await ctx.Orders + .Select(o => new + { + o.Id, + o.Price, + RowNum = WindowFunction.RowNumber(Window.OrderBy(o.Price)) + }) + .OrderBy(x => x.RowNum) + .ToListAsync(); + + Assert.AreEqual(10, results.Count); + for (var i = 0; i < results.Count; i++) + { + Assert.AreEqual(i + 1, results[i].RowNum, + $"Expected RowNum {i + 1} at index {i}, got {results[i].RowNum}"); + } + + // Prices should be non-decreasing + for (var i = 1; i < results.Count; i++) + { + Assert.IsTrue(results[i].Price >= results[i - 1].Price, + $"Expected non-decreasing prices, but {results[i].Price} < {results[i - 1].Price}"); + } + } + + [TestMethod] + public async Task Rank_WithTies_ReturnsGaps() + { + using var ctx = CreateContext(); + SeedTieData(ctx); + + var results = await ctx.Orders + .Select(o => new + { + o.Price, + PriceRank = WindowFunction.Rank(Window.OrderBy(o.Price)) + }) + .OrderBy(x => x.PriceRank) + .ToListAsync(); + + // RANK with ties: 1, 2, 2, 4 (gap after ties) + Assert.AreEqual(4, results.Count); + Assert.AreEqual(1, results[0].PriceRank); + Assert.AreEqual(2, results[1].PriceRank); + Assert.AreEqual(2, results[2].PriceRank); + Assert.AreEqual(4, results[3].PriceRank); + } + + [TestMethod] + public async Task DenseRank_WithTies_ReturnsNoGaps() + { + using var ctx = CreateContext(); + SeedTieData(ctx); + + var results = await ctx.Orders + .Select(o => new + { + o.Price, + Dense = WindowFunction.DenseRank(Window.OrderBy(o.Price)) + }) + .OrderBy(x => x.Dense) + .ThenBy(x => x.Price) + .ToListAsync(); + + // DENSE_RANK with ties: 1, 2, 2, 3 (no gaps) + Assert.AreEqual(4, results.Count); + Assert.AreEqual(1, results[0].Dense); + Assert.AreEqual(2, results[1].Dense); + Assert.AreEqual(2, results[2].Dense); + Assert.AreEqual(3, results[3].Dense); + } + + [TestMethod] + public async Task Ntile_DistributesIntoBuckets() + { + using var ctx = CreateContext(); + SeedTestData(ctx); + + var results = await ctx.Orders + .Select(o => new + { + o.Id, + Quartile = WindowFunction.Ntile(4, Window.OrderBy(o.Price)) + }) + .OrderBy(x => x.Quartile) + .ToListAsync(); + + Assert.AreEqual(10, results.Count); + Assert.IsTrue(results.All(r => r.Quartile >= 1 && r.Quartile <= 4), + "All quartile values should be between 1 and 4"); + + // NTILE(4) over 10 rows gives 3, 3, 2, 2 + var bucketCounts = results.GroupBy(r => r.Quartile).OrderBy(g => g.Key).Select(g => g.Count()).ToList(); + Assert.AreEqual(4, bucketCounts.Count, "Should have exactly 4 buckets"); + Assert.AreEqual(3, bucketCounts[0], "Bucket 1 should have 3 rows"); + Assert.AreEqual(3, bucketCounts[1], "Bucket 2 should have 3 rows"); + Assert.AreEqual(2, bucketCounts[2], "Bucket 3 should have 2 rows"); + Assert.AreEqual(2, bucketCounts[3], "Bucket 4 should have 2 rows"); + } + + [TestMethod] + public async Task RowNumber_WithPartitionBy_ResetsPerGroup() + { + using var ctx = CreateContext(); + SeedTestData(ctx); + + var results = await ctx.Orders + .Select(o => new + { + o.Id, + o.CustomerId, + o.Price, + RowNum = WindowFunction.RowNumber( + Window.PartitionBy(o.CustomerId).OrderBy(o.Price)) + }) + .OrderBy(x => x.CustomerId) + .ThenBy(x => x.RowNum) + .ToListAsync(); + + var customer1Rows = results.Where(r => r.CustomerId == 1).ToList(); + var customer2Rows = results.Where(r => r.CustomerId == 2).ToList(); + + Assert.AreEqual(1, customer1Rows.First().RowNum, "Customer 1 should start at 1"); + Assert.AreEqual(1, customer2Rows.First().RowNum, "Customer 2 should start at 1"); + + for (var i = 0; i < customer1Rows.Count; i++) + Assert.AreEqual(i + 1, customer1Rows[i].RowNum); + for (var i = 0; i < customer2Rows.Count; i++) + Assert.AreEqual(i + 1, customer2Rows[i].RowNum); + } + + [TestMethod] + public async Task IndexedSelect_ReturnsZeroBasedIndices() + { + using var ctx = CreateContext(); + ctx.Customers.Add(new Customer { Id = 1, Name = "Alice" }); + ctx.SaveChanges(); + ctx.Orders.AddRange( + new Order { Id = 1, Price = 10, Quantity = 1, CustomerId = 1 }, + new Order { Id = 2, Price = 20, Quantity = 1, CustomerId = 1 }, + new Order { Id = 3, Price = 30, Quantity = 1, CustomerId = 1 }); + ctx.SaveChanges(); + + var results = await ctx.ExpressiveOrders + .Select((o, index) => new { o.Id, Position = index }) + .ToListAsync(); + + Assert.AreEqual(3, results.Count); + var positions = results.Select(r => r.Position).OrderBy(p => p).ToList(); + CollectionAssert.AreEqual(new[] { 0, 1, 2 }, positions); + } +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests.cs b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests.cs new file mode 100644 index 0000000..322a422 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests.cs @@ -0,0 +1,111 @@ +using ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Infrastructure; +using ExpressiveSharp.EntityFrameworkCore.Relational.Tests.Models; +using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions; +using Microsoft.EntityFrameworkCore; +using VerifyMSTest; + +namespace ExpressiveSharp.EntityFrameworkCore.Relational.Tests; + +/// +/// Verifies the exact SQL generated for window functions across multiple database providers. +/// Uses Verify snapshot testing — each derived class produces its own .verified.txt files. +/// No real database connection is required; only ToQueryString() is used. +/// +public abstract class WindowFunctionSqlTests : VerifyBase +{ + protected abstract WindowTestDbContext CreateContext(); + + [TestMethod] + public Task RowNumber_WithOrderBy() + { + using var ctx = CreateContext(); + var sql = ctx.Orders.Select(o => new + { + o.Id, + RowNum = WindowFunction.RowNumber(Window.OrderBy(o.Price)) + }).ToQueryString(); + return Verify(sql); + } + + [TestMethod] + public Task Rank_WithPartitionAndOrder() + { + using var ctx = CreateContext(); + var sql = ctx.Orders.Select(o => new + { + o.Id, + PriceRank = WindowFunction.Rank( + Window.PartitionBy(o.CustomerId).OrderByDescending(o.Price)) + }).ToQueryString(); + return Verify(sql); + } + + [TestMethod] + public Task DenseRank_WithOrderBy() + { + using var ctx = CreateContext(); + var sql = ctx.Orders.Select(o => new + { + o.Id, + Dense = WindowFunction.DenseRank(Window.OrderBy(o.Price)) + }).ToQueryString(); + return Verify(sql); + } + + [TestMethod] + public Task Ntile_WithBuckets() + { + using var ctx = CreateContext(); + var sql = ctx.Orders.Select(o => new + { + o.Id, + Quartile = WindowFunction.Ntile(4, Window.OrderBy(o.Price)) + }).ToQueryString(); + return Verify(sql); + } + + [TestMethod] + public Task MultipleWindowFunctions_InSameSelect() + { + using var ctx = CreateContext(); + var sql = ctx.Orders.Select(o => new + { + o.Id, + RowNum = WindowFunction.RowNumber(Window.OrderBy(o.Price)), + PriceRank = WindowFunction.Rank(Window.OrderBy(o.Price)), + Dense = WindowFunction.DenseRank(Window.OrderBy(o.Price)) + }).ToQueryString(); + return Verify(sql); + } + + [TestMethod] + public Task IndexedSelect() + { + using var ctx = CreateContext(); + var sql = ctx.ExpressiveOrders + .Select((o, index) => new { o.Id, Position = index }) + .ToQueryString(); + return Verify(sql); + } +} + +[TestClass] +public class WindowFunctionSqlTests_Sqlite : WindowFunctionSqlTests +{ + protected override WindowTestDbContext CreateContext() => + TestProvider.CreateSqliteContext(); +} + +[TestClass] +public class WindowFunctionSqlTests_SqlServer : WindowFunctionSqlTests +{ + protected override WindowTestDbContext CreateContext() => + TestProvider.CreateSqlServerContext(); +} + +[TestClass] +public class WindowFunctionSqlTests_Npgsql : WindowFunctionSqlTests +{ + protected override WindowTestDbContext CreateContext() => + TestProvider.CreateNpgsqlContext(); +} diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.DenseRank_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.DenseRank_WithOrderBy.verified.txt new file mode 100644 index 0000000..10985ae --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.DenseRank_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", DENSE_RANK() OVER(ORDER BY o."Price" ASC) AS "Dense" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.IndexedSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.IndexedSelect.verified.txt new file mode 100644 index 0000000..e11ff76 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.IndexedSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", CAST(ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) - 1 AS integer) AS "Position" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.MultipleWindowFunctions_InSameSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.MultipleWindowFunctions_InSameSelect.verified.txt new file mode 100644 index 0000000..ff0767d --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.MultipleWindowFunctions_InSameSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", ROW_NUMBER() OVER(ORDER BY o."Price") AS "RowNum", RANK() OVER(ORDER BY o."Price" ASC) AS "PriceRank", DENSE_RANK() OVER(ORDER BY o."Price" ASC) AS "Dense" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Ntile_WithBuckets.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Ntile_WithBuckets.verified.txt new file mode 100644 index 0000000..35fe316 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Ntile_WithBuckets.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", NTILE(4) OVER(ORDER BY o."Price" ASC) AS "Quartile" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Rank_WithPartitionAndOrder.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Rank_WithPartitionAndOrder.verified.txt new file mode 100644 index 0000000..a2220ec --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.Rank_WithPartitionAndOrder.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", RANK() OVER(PARTITION BY o."CustomerId" ORDER BY o."Price" DESC) AS "PriceRank" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.RowNumber_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.RowNumber_WithOrderBy.verified.txt new file mode 100644 index 0000000..18a1c4c --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Npgsql.RowNumber_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT o."Id", ROW_NUMBER() OVER(ORDER BY o."Price") AS "RowNum" +FROM "Orders" AS o \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.DenseRank_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.DenseRank_WithOrderBy.verified.txt new file mode 100644 index 0000000..ab9795a --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.DenseRank_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], DENSE_RANK() OVER(ORDER BY [o].[Price] ASC) AS [Dense] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.IndexedSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.IndexedSelect.verified.txt new file mode 100644 index 0000000..7d75ba3 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.IndexedSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], CAST(ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) - CAST(1 AS bigint) AS int) AS [Position] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.MultipleWindowFunctions_InSameSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.MultipleWindowFunctions_InSameSelect.verified.txt new file mode 100644 index 0000000..bedeb56 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.MultipleWindowFunctions_InSameSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], ROW_NUMBER() OVER(ORDER BY [o].[Price]) AS [RowNum], RANK() OVER(ORDER BY [o].[Price] ASC) AS [PriceRank], DENSE_RANK() OVER(ORDER BY [o].[Price] ASC) AS [Dense] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Ntile_WithBuckets.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Ntile_WithBuckets.verified.txt new file mode 100644 index 0000000..a35a440 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Ntile_WithBuckets.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], NTILE(4) OVER(ORDER BY [o].[Price] ASC) AS [Quartile] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Rank_WithPartitionAndOrder.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Rank_WithPartitionAndOrder.verified.txt new file mode 100644 index 0000000..73925df --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.Rank_WithPartitionAndOrder.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], RANK() OVER(PARTITION BY [o].[CustomerId] ORDER BY [o].[Price] DESC) AS [PriceRank] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.RowNumber_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.RowNumber_WithOrderBy.verified.txt new file mode 100644 index 0000000..48d914d --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_SqlServer.RowNumber_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT [o].[Id], ROW_NUMBER() OVER(ORDER BY [o].[Price]) AS [RowNum] +FROM [Orders] AS [o] \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.DenseRank_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.DenseRank_WithOrderBy.verified.txt new file mode 100644 index 0000000..ffcafbd --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.DenseRank_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", DENSE_RANK() OVER(ORDER BY "o"."Price" ASC) AS "Dense" +FROM "Orders" AS "o" \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.IndexedSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.IndexedSelect.verified.txt new file mode 100644 index 0000000..bcf2199 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.IndexedSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", CAST(ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) - 1 AS INTEGER) AS "Position" +FROM "Orders" AS "o" \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.MultipleWindowFunctions_InSameSelect.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.MultipleWindowFunctions_InSameSelect.verified.txt new file mode 100644 index 0000000..c8875f8 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.MultipleWindowFunctions_InSameSelect.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", ROW_NUMBER() OVER(ORDER BY "o"."Price") AS "RowNum", RANK() OVER(ORDER BY "o"."Price" ASC) AS "PriceRank", DENSE_RANK() OVER(ORDER BY "o"."Price" ASC) AS "Dense" +FROM "Orders" AS "o" \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Ntile_WithBuckets.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Ntile_WithBuckets.verified.txt new file mode 100644 index 0000000..f197279 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Ntile_WithBuckets.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", NTILE(4) OVER(ORDER BY "o"."Price" ASC) AS "Quartile" +FROM "Orders" AS "o" \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Rank_WithPartitionAndOrder.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Rank_WithPartitionAndOrder.verified.txt new file mode 100644 index 0000000..784d114 --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.Rank_WithPartitionAndOrder.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", RANK() OVER(PARTITION BY "o"."CustomerId" ORDER BY "o"."Price" DESC) AS "PriceRank" +FROM "Orders" AS "o" \ No newline at end of file diff --git a/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.RowNumber_WithOrderBy.verified.txt b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.RowNumber_WithOrderBy.verified.txt new file mode 100644 index 0000000..ac0f4ba --- /dev/null +++ b/tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/WindowFunctionSqlTests_Sqlite.RowNumber_WithOrderBy.verified.txt @@ -0,0 +1,2 @@ +SELECT "o"."Id", ROW_NUMBER() OVER(ORDER BY "o"."Price") AS "RowNum" +FROM "Orders" AS "o" \ No newline at end of file