Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ ExpressiveSharp.EntityFrameworkCore (net8.0;net10.0)
└── Provides: ExpressiveDbSet<T>, IIncludableRewritableQueryable<T,P>,
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)
Expand All @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageVersion Include="Basic.Reference.Assemblies.Net100" Version="1.8.4" />
<PackageVersion Include="MSTest.TestFramework" Version="4.1.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.25" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.25" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
</ItemGroup>
Expand Down
2 changes: 2 additions & 0 deletions ExpressiveSharp.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Project Path="src/ExpressiveSharp.CodeFixers/ExpressiveSharp.CodeFixers.csproj" />
<Project Path="src/ExpressiveSharp.EntityFrameworkCore.CodeFixers/ExpressiveSharp.EntityFrameworkCore.CodeFixers.csproj" />
<Project Path="src/ExpressiveSharp.EntityFrameworkCore/ExpressiveSharp.EntityFrameworkCore.csproj" />
<Project Path="src/ExpressiveSharp.EntityFrameworkCore.Relational/ExpressiveSharp.EntityFrameworkCore.Relational.csproj" />
<Project Path="src/ExpressiveSharp.Generator/ExpressiveSharp.Generator.csproj" />
<Project Path="src/ExpressiveSharp/ExpressiveSharp.csproj" />
</Folder>
Expand All @@ -19,6 +20,7 @@
<Project Path="tests/ExpressiveSharp.IntegrationTests/ExpressiveSharp.IntegrationTests.csproj" />
<Project Path="tests/ExpressiveSharp.IntegrationTests.ExpressionCompile/ExpressiveSharp.IntegrationTests.ExpressionCompile.csproj" />
<Project Path="tests/ExpressiveSharp.IntegrationTests.EntityFrameworkCore/ExpressiveSharp.IntegrationTests.EntityFrameworkCore.csproj" />
<Project Path="tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests/ExpressiveSharp.EntityFrameworkCore.Relational.Tests.csproj" />
<Project Path="tests/ExpressiveSharp.Tests/ExpressiveSharp.Tests.csproj" />
</Folder>
</Solution>
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ Mark computed properties and methods with [`[Expressive]`](#expressive-attribute
|---|---|
| **EF Core** — modern syntax + `[Expressive]` expansion on `DbSet` | [`ExpressiveDbSet<T>`](#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<T>` 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) |
Expand Down Expand Up @@ -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<MyDbContext>()
.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
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Enables relational database extensions: SQL window functions
/// (ROW_NUMBER, RANK, DENSE_RANK, NTILE) and indexed Select support.
/// </summary>
public static ExpressiveOptionsBuilder UseRelationalExtensions(this ExpressiveOptionsBuilder builder)
=> builder.AddPlugin(new RelationalExpressivePlugin());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>Relational database extensions for ExpressiveSharp EF Core — window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) and indexed Select</Description>
</PropertyGroup>

<!-- EF Core Relational major version must match the target framework (8.x for net8.0, 10.x for net10.0).
Do NOT merge these into a single unconditional reference. -->
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational"
Condition="'$(TargetFramework)' == 'net8.0'"
VersionOverride="8.0.25" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational"
Condition="'$(TargetFramework)' == 'net10.0'"
VersionOverride="10.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ExpressiveSharp.EntityFrameworkCore\ExpressiveSharp.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\ExpressiveSharp\ExpressiveSharp.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Linq.Expressions;
using ExpressiveSharp.EntityFrameworkCore.Relational.WindowFunctions;
using Microsoft.EntityFrameworkCore.Query;

namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal;

/// <summary>
/// Prevents <see cref="Window"/>, <see cref="WindowDefinition"/>, and <see cref="WindowFunction"/>
/// method calls from being client-evaluated. These must remain as expression tree nodes
/// for the method call translators to handle.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Translates <see cref="WindowFunction"/> static methods into SQL window function expressions.
/// ROW_NUMBER uses the built-in <see cref="RowNumberExpression"/>;
/// RANK, DENSE_RANK, and NTILE use <see cref="WindowFunctionSqlExpression"/>.
/// </summary>
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<SqlExpression> arguments,
IDiagnosticsLogger<DbLoggerCategory.Query> 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
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore.Query;

namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal;

[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required for custom SQL expression nullability processing")]
internal sealed class WindowFunctionParameterBasedSqlProcessor : RelationalParameterBasedSqlProcessor
{
#if NET10_0_OR_GREATER
public WindowFunctionParameterBasedSqlProcessor(
RelationalParameterBasedSqlProcessorDependencies dependencies,
RelationalParameterBasedSqlProcessorParameters parameters)
: base(dependencies, parameters)
{
}

protected override Expression ProcessSqlNullability(
Expression queryExpression,
ParametersCacheDecorator parametersDecorator)
{
var processor = new WindowFunctionSqlNullabilityProcessor(Dependencies, Parameters);
return processor.Process(queryExpression, parametersDecorator);
}
#else
public WindowFunctionParameterBasedSqlProcessor(
RelationalParameterBasedSqlProcessorDependencies dependencies,
bool useRelationalNulls)
: base(dependencies, useRelationalNulls)
{
}

protected override Expression ProcessSqlNullability(
Expression queryExpression,
IReadOnlyDictionary<string, object?> parametersValues,
out bool canCache)
{
var processor = new WindowFunctionSqlNullabilityProcessor(Dependencies, UseRelationalNulls);
return processor.Process(queryExpression, parametersValues, out canCache);
}
#endif
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Query;

namespace ExpressiveSharp.EntityFrameworkCore.Relational.Infrastructure.Internal;

[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required for custom SQL expression nullability processing")]
internal sealed class WindowFunctionParameterBasedSqlProcessorFactory : IRelationalParameterBasedSqlProcessorFactory
{
private readonly RelationalParameterBasedSqlProcessorDependencies _dependencies;

public WindowFunctionParameterBasedSqlProcessorFactory(
RelationalParameterBasedSqlProcessorDependencies dependencies)
{
_dependencies = dependencies;
}

#if NET10_0_OR_GREATER
public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) =>
new WindowFunctionParameterBasedSqlProcessor(_dependencies, parameters);
#else
public RelationalParameterBasedSqlProcessor Create(bool useRelationalNulls) =>
new WindowFunctionParameterBasedSqlProcessor(_dependencies, useRelationalNulls);
#endif
}
Loading
Loading