Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Abstractions (net8.0;ne
LAST_VALUE, NTH_VALUE), Window, OrderedWindowDefinition,
PartitionedWindowDefinition, FramedWindowDefinition, WindowFrameBound

ExpressiveSharp.EntityFrameworkCore.RelationalExtensions (net8.0;net10.0, experimental)
ExpressiveSharp.EntityFrameworkCore.RelationalExtensions (net8.0;net10.0)
├── ExpressiveSharp.EntityFrameworkCore
├── ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.Abstractions
├── EF Core Relational 8.0.25 / 10.0.0
Expand All @@ -119,7 +119,7 @@ ExpressiveSharp.EntityFrameworkCore.CodeFixers (Roslyn analyzer, netstandard2.0)

### Diagnostics

20 diagnostic codes (EXP0001–EXP0012, EXP0018 in `src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs`, EXP0013 in CodeFixers, EXP0014–EXP0020 for `[ExpressiveFor]` validation). Key ones: EXP0001 (requires body), EXP0004 (block body requires opt-in), EXP0008 (unsupported operation, default value used), EXP0018 (unsupported operation ignored, e.g. alignment specifiers), EXP0019 (`[ExpressiveFor]` conflicts with `[Expressive]`).
22 diagnostic codes (EXP0001–EXP0012, EXP0018 in `src/ExpressiveSharp.Generator/Infrastructure/Diagnostics.cs`, EXP0013 in CodeFixers, EXP0014–EXP0020 for `[ExpressiveFor]` validation, EXP0036/EXP0037 in `WindowFunctionLiteralArgsAnalyzer`). Key ones: EXP0001 (requires body), EXP0004 (block body requires opt-in), EXP0008 (unsupported operation, default value used), EXP0018 (unsupported operation ignored, e.g. alignment specifiers), EXP0019 (`[ExpressiveFor]` conflicts with `[Expressive]`), EXP0036 (`Ntile` non-positive literal), EXP0037 (`Lag`/`Lead` negative literal offset).

## Testing

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Mark computed properties and methods with `[Expressive]` to generate companion e
| External member mapping | `[ExpressiveFor]` for BCL/third-party members |
| Tuples, index/range, `with`, collection expressions | And more modern C# syntax |
| Expression transformers | Built-in + custom `IExpressionTreeTransformer` pipeline |
| SQL window functions | ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, SUM/AVG/COUNT/MIN/MAX OVER, LAG/LEAD, FIRST_VALUE/LAST_VALUE/NTH_VALUE with ROWS/RANGE frames (experimental) |
| SQL window functions | ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST, SUM/AVG/COUNT/MIN/MAX OVER, LAG/LEAD, FIRST_VALUE/LAST_VALUE/NTH_VALUE with ROWS/RANGE frames |
| Hot reload | Compatible with `dotnet watch` — edits to `[Expressive]` bodies propagate to generated expression trees |

See the [full documentation](https://efnext.github.io/ExpressiveSharp/guide/introduction) for detailed usage, [reference](https://efnext.github.io/ExpressiveSharp/reference/expressive-attribute), and [recipes](https://efnext.github.io/ExpressiveSharp/recipes/computed-properties).
Expand Down
14 changes: 5 additions & 9 deletions docs/advanced/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,22 +158,18 @@ String interpolation with format specifiers like `$"{Price:F2}"` introduces a `T
| `while`/`do-while`, `try`/`catch`, `async`/`await` | Not supported |
| Assignments, `++`, `--` | Not supported |

## Window Functions: Experimental Status
## Window Functions: Provider Support

The `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package providing window functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) is **experimental**.

::: warning
EF Core has an [open issue](https://github.com/dotnet/efcore/issues/12747) for native window function support. This package may be superseded when that ships. The API surface may change in future releases.
:::

Window functions are limited to relational providers compatible with SQL:2003 window function syntax:
The `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package implements SQL window functions for relational providers compatible with SQL:2003 window function syntax:

| Provider | Status |
|---|---|
| SQL Server | Supported |
| SQL Server | Supported (`NTH_VALUE` not implemented by SQL Server itself) |
| PostgreSQL | Supported |
| SQLite | Supported |
| MySQL | Supported |
| Oracle | Supported |

Non-relational providers (Cosmos DB, in-memory) are not supported for window functions.

EF Core also tracks native window function support in [dotnet/efcore#12747](https://github.com/dotnet/efcore/issues/12747); see the [window functions guide](../guide/window-functions#forward-compatibility) for forward-compatibility notes.
2 changes: 1 addition & 1 deletion docs/guide/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ dotnet add package ExpressiveSharp.MongoDB
| `ExpressiveSharp.Abstractions` | Lightweight — `[Expressive]` attribute, `[ExpressiveFor]`, `IExpressionTreeTransformer`, source generator only (no runtime services) |
| `ExpressiveSharp.EntityFrameworkCore` | EF Core integration — `UseExpressives()`, `ExpressiveDbSet<T>`, Include/ThenInclude, async methods, analyzers and code fixes |
| `ExpressiveSharp.MongoDB` | MongoDB integration — `.AsExpressive()` on `IMongoCollection<T>`, MQL aggregation translation |
| `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` | SQL window functions — ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE). Experimental. |
| `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` | SQL window functions — ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE) with PARTITION BY / ORDER BY / ROWS\|RANGE frame support, plus indexed Select. |

## Step 2 — Define Your Entities

Expand Down
40 changes: 32 additions & 8 deletions docs/guide/window-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@

ExpressiveSharp provides SQL window function support through the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package. This enables ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE) functions directly in LINQ queries with a fluent window specification API.

::: warning Experimental
This package is experimental. EF Core has an [open issue (#12747)](https://github.com/dotnet/efcore/issues/12747) for native window function support. This package may be superseded when that ships.
:::

## Installation

```bash
Expand Down Expand Up @@ -239,14 +235,19 @@ Window functions are supported across all major relational database providers:

| Provider | Supported | Notes |
|----------|-----------|-------|
| SQLite | Yes | |
| SQL Server | Yes | `NTH_VALUE` is not supported |
| PostgreSQL | Yes | |
| MySQL | Yes | |
| SQLite | Yes | Full support (3.25+) |
| PostgreSQL | Yes | Full support |
| SQL Server | Yes | See note on `NTH_VALUE` below |
| MySQL (Pomelo) | Yes | Pomelo provider unavailable on .NET 10 until upstream support returns |
| Oracle | Yes | |
| Cosmos | No | Cosmos has no SQL window functions |

The generated SQL uses standard ANSI window function syntax. Each provider translates the expressions using its native SQL dialect.

::: warning `NTH_VALUE` on SQL Server
SQL Server does not implement the `NTH_VALUE` window function. Queries using `WindowFunction.NthValue(...)` will fail at execution with a "not a recognized built-in function" error. Workaround: use `FIRST_VALUE`/`LAST_VALUE` with a constrained frame, or switch to PostgreSQL/SQLite/MySQL.
:::

## Full Configuration Example

```csharp
Expand Down Expand Up @@ -284,6 +285,29 @@ var rankings = ctx.Orders
Window functions are implemented as a plugin using the `IExpressivePlugin` architecture. The `UseRelationalExtensions()` call registers custom EF Core services and expression translators that handle the `WindowFunction.*` method calls during SQL generation.
:::

## Argument validation

The translator rejects clearly-invalid arguments at translation time (before the query reaches the database) and throws `InvalidOperationException`:

- `WindowFunction.Ntile(n, ...)` with literal `n <= 0`
- `WindowFunction.Lag(expr, n, ...)` / `WindowFunction.Lead(expr, n, ...)` with literal `n < 0`
- `WindowFunction.NthValue(expr, n, ...)` with literal `n < 1`
- `WindowFunction.PercentRank(...)` / `WindowFunction.CumeDist(...)` constructed without an `OrderBy` (only reachable via manually-built expression trees, since the fluent builder requires an ordered window)

Non-literal (parameter) values are forwarded to the database, which performs its own validation.

## Forward compatibility

EF Core tracks native window function support in [dotnet/efcore#12747](https://github.com/dotnet/efcore/issues/12747). When that ships, the fluent builder API in this package is expected to remain stable; the underlying translator may delegate to EF's primitives in a future release.

## Upgrading from earlier versions

The package was previously labeled experimental. Upgrading is API-compatible; three observable behaviors changed:

- Direct invocation of a `WindowFunction.*` stub (i.e. outside an EF Core query) now throws an exception that names the method and points at this guide.
- `Ntile(0)` / `Ntile(-1)`, negative literal `Lag`/`Lead` offsets, and `NthValue(0)` now throw `InvalidOperationException` at translation time. Previously these reached the database and produced a provider-specific error.
- New analyzer warnings **EXP0036** (`Ntile` non-positive literal buckets) and **EXP0037** (`Lag`/`Lead` negative literal offsets) may surface on existing code.

## Next Steps

- [EF Core Integration](./integrations/ef-core) -- full EF Core setup and features
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ features:

- icon: "\U0001F4CA"
title: SQL Window Functions
details: "Ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE) functions with a fluent PARTITION BY / ORDER BY / frame API. Experimental — via the RelationalExtensions package."
details: "Ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE) functions with a fluent PARTITION BY / ORDER BY / frame API — via the RelationalExtensions package."

- icon: "\U0001F527"
title: Customizable Transformer Pipeline
Expand Down Expand Up @@ -116,4 +116,4 @@ Computed properties are **inlined into the provider's native query language**
| [`ExpressiveSharp.Abstractions`](https://www.nuget.org/packages/ExpressiveSharp.Abstractions/) | Lightweight — attributes (`[Expressive]`, `[ExpressiveFor]`), `IExpressionTreeTransformer`, source generator only |
| [`ExpressiveSharp.EntityFrameworkCore`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore/) | EF Core integration — `UseExpressives()`, `ExpressiveDbSet<T>`, Include/ThenInclude, async methods |
| [`ExpressiveSharp.MongoDB`](https://www.nuget.org/packages/ExpressiveSharp.MongoDB/) | MongoDB integration — `.AsExpressive()` on `IMongoCollection<T>`, MQL translation |
| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | SQL window functions — ROW_NUMBER, RANK, DENSE_RANK, NTILE (experimental) |
| [`ExpressiveSharp.EntityFrameworkCore.RelationalExtensions`](https://www.nuget.org/packages/ExpressiveSharp.EntityFrameworkCore.RelationalExtensions/) | SQL window functions — ranking, aggregate, navigation with ROWS/RANGE frames |
4 changes: 0 additions & 4 deletions docs/recipes/window-functions-ranking.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@

This recipe shows how to use SQL window functions in EF Core LINQ queries via the `ExpressiveSharp.EntityFrameworkCore.RelationalExtensions` package. Coverage includes ranking (ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST), aggregate (SUM, AVG, COUNT, MIN, MAX), and navigation (LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE) functions.

::: warning Experimental
This package is experimental. EF Core has an [open issue](https://github.com/dotnet/efcore/issues/12747) for native window function support -- this package may be superseded when that ships.
:::

## Setup

Install the package:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace ExpressiveSharp.EntityFrameworkCore.CodeFixers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class WindowFunctionLiteralArgsAnalyzer : DiagnosticAnalyzer
{
private const string WindowFunctionType =
"ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions.WindowFunction";

public static readonly DiagnosticDescriptor NtileRequiresPositiveBuckets = new(
id: "EXP0036",
title: "WindowFunction.Ntile requires a positive bucket count",
messageFormat: "WindowFunction.Ntile requires a positive bucket count; literal value {0} produces invalid SQL",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "NTILE(n) divides ordered rows into n buckets. SQL requires n >= 1; non-positive values raise a database error at execution time.");

public static readonly DiagnosticDescriptor NavigationOffsetMustBeNonNegative = new(
id: "EXP0037",
title: "WindowFunction.Lag/Lead offset must be non-negative",
messageFormat: "WindowFunction.{0} offset must be non-negative; literal value {1} is rejected during EF translation",
category: "Usage",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "LAG and LEAD offsets count rows backward or forward from the current row. SQL requires the offset to be >= 0; negative literals are rejected during EF translation.");

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(NtileRequiresPositiveBuckets, NavigationOffsetMustBeNonNegative);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}

private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;

if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return;

var name = memberAccess.Name.Identifier.Text;
if (name != "Ntile" && name != "Lag" && name != "Lead")
return;

var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken);
if (symbolInfo.Symbol is not IMethodSymbol method)
return;

if (method.ContainingType?.ToDisplayString() != WindowFunctionType)
return;

var args = invocation.ArgumentList.Arguments;
if (args.Count == 0)
return;

switch (name)
{
case "Ntile":
if (TryGetIntLiteral(context.SemanticModel, args[0].Expression, context.CancellationToken, out var buckets) && buckets <= 0)
{
context.ReportDiagnostic(Diagnostic.Create(
NtileRequiresPositiveBuckets,
args[0].GetLocation(),
buckets));
}
break;

case "Lag":
case "Lead":
if (args.Count >= 2 &&
TryGetIntLiteral(context.SemanticModel, args[1].Expression, context.CancellationToken, out var offset) &&
offset < 0)
{
context.ReportDiagnostic(Diagnostic.Create(
NavigationOffsetMustBeNonNegative,
args[1].GetLocation(),
name,
offset));
}
break;
}
}

private static bool TryGetIntLiteral(
SemanticModel semanticModel,
ExpressionSyntax expression,
System.Threading.CancellationToken cancellationToken,
out int value)
{
var constant = semanticModel.GetConstantValue(expression, cancellationToken);
if (constant.HasValue && constant.Value is int i)
{
value = i;
return true;
}

value = 0;
return false;
}
}
Loading
Loading