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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,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 (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 (experimental) |

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
117 changes: 116 additions & 1 deletion docs/guide/window-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,77 @@ services.AddDbContext<MyDbContext>(options =>
.UseExpressives(o => o.UseRelationalExtensions()));
```

::: tip Concise syntax with `using static`
Add these imports for a compact, SQL-like syntax without class prefixes:
```csharp
using static ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions.WindowFunction;
using static ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions.WindowFrameBound;
using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions;

// Then in queries:
RowNumber(Window.PartitionBy(o.CustomerId).OrderBy(o.Price))
Sum(o.Price, Window.OrderBy(o.Date).RowsBetween(UnboundedPreceding, CurrentRow))
Lag(o.Price, 1, 0.0, Window.OrderBy(o.Date))
```
:::

## Available Functions

### Ranking Functions

| Function | SQL | Description |
|----------|-----|-------------|
| `WindowFunction.RowNumber(window)` | `ROW_NUMBER() OVER(...)` | Sequential row number within the partition. Returns `long`. |
| `WindowFunction.Rank(window)` | `RANK() OVER(...)` | Rank with gaps for ties. Returns `long`. |
| `WindowFunction.DenseRank(window)` | `DENSE_RANK() OVER(...)` | Rank without gaps for ties. Returns `long`. |
| `WindowFunction.Ntile(n, window)` | `NTILE(n) OVER(...)` | Distributes rows into `n` roughly equal groups. Returns `long`. |
| `WindowFunction.PercentRank(window)` | `PERCENT_RANK() OVER(...)` | Relative rank as a value between 0.0 and 1.0. Returns `double`. |
| `WindowFunction.CumeDist(window)` | `CUME_DIST() OVER(...)` | Cumulative distribution (0.0–1.0]. Returns `double`. |

### Aggregate Functions

Aggregate window functions compute values over a set of rows defined by the window specification. Unlike ranking functions, they support [window frame clauses](#window-frame-specification).

| Function | SQL | Description |
|----------|-----|-------------|
| `WindowFunction.Sum(expr, window)` | `SUM(expr) OVER(...)` | Sum of values. Returns same type as input. |
| `WindowFunction.Average(expr, window)` | `AVG(expr) OVER(...)` | Average of values. Returns `T?` (or `double` for `int`/`long` input). |
| `WindowFunction.Count(window)` | `COUNT(*) OVER(...)` | Count of all rows. Returns `long`. |
| `WindowFunction.Count(expr, window)` | `COUNT(expr) OVER(...)` | Count of non-null values. Returns `long`. |
| `WindowFunction.Min(expr, window)` | `MIN(expr) OVER(...)` | Minimum value. Returns same type as input. |
| `WindowFunction.Max(expr, window)` | `MAX(expr) OVER(...)` | Maximum value. Returns same type as input. |

### Navigation Functions

Navigation functions access specific rows relative to the current row. LAG/LEAD do not support frame clauses; FIRST_VALUE/LAST_VALUE do.

| Function | SQL | Frame? | Description |
|----------|-----|--------|-------------|
| `WindowFunction.Lag(expr, window)` | `LAG(expr) OVER(...)` | No | Previous row's value (offset 1). |
| `WindowFunction.Lag(expr, n, window)` | `LAG(expr, n) OVER(...)` | No | Value `n` rows back. |
| `WindowFunction.Lag(expr, n, default, window)` | `LAG(expr, n, default) OVER(...)` | No | Value `n` rows back, with default. |
| `WindowFunction.Lead(expr, window)` | `LEAD(expr) OVER(...)` | No | Next row's value (offset 1). |
| `WindowFunction.Lead(expr, n, window)` | `LEAD(expr, n) OVER(...)` | No | Value `n` rows ahead. |
| `WindowFunction.Lead(expr, n, default, window)` | `LEAD(expr, n, default) OVER(...)` | No | Value `n` rows ahead, with default. |
| `WindowFunction.FirstValue(expr, window)` | `FIRST_VALUE(expr) OVER(...)` | Yes | First value in the frame. |
| `WindowFunction.LastValue(expr, window)` | `LAST_VALUE(expr) OVER(...)` | Yes | Last value in the frame. |
| `WindowFunction.NthValue(expr, n, window)` | `NTH_VALUE(expr, n) OVER(...)` | Yes | Value at the Nth row (1-based) in the frame. |

::: tip Nullable results from LAG/LEAD
When no row exists at the requested offset (e.g. LAG on the first row), SQL returns NULL. For value-type columns, cast to a nullable type in the projection to detect this: `(double?)WindowFunction.Lag(o.Price, window)`. When a default value is provided (3-arg overload), NULL is never returned.
:::

::: warning LAST_VALUE needs an explicit frame
With the default frame (`RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`), `LAST_VALUE` returns the *current row's* value — not the partition's last. Use an explicit frame:
```csharp
WindowFunction.LastValue(o.Price,
Window.OrderBy(o.Price)
.RowsBetween(WindowFrameBound.UnboundedPreceding, WindowFrameBound.UnboundedFollowing))
```
:::

::: tip
All window functions return `long`. When projecting into a typed DTO with `int` properties, use an explicit cast: `(int)WindowFunction.RowNumber(...)`.
Ranking functions return `long`. When projecting into a typed DTO with `int` properties, use an explicit cast: `(int)WindowFunction.RowNumber(...)`.
:::

## Window Specification API
Expand All @@ -55,6 +115,8 @@ Build window specifications using the fluent `Window` API:
| `Window.PartitionBy(expr)` | `PARTITION BY expr` |
| `.ThenBy(expr)` | Additional `ORDER BY expr ASC` column |
| `.ThenByDescending(expr)` | Additional `ORDER BY expr DESC` column |
| `.RowsBetween(start, end)` | `ROWS BETWEEN start AND end` (see [Window Frame Specification](#window-frame-specification)) |
| `.RangeBetween(start, end)` | `RANGE BETWEEN start AND end` (see [Window Frame Specification](#window-frame-specification)) |

Chain these methods to build the full window specification:

Expand All @@ -74,6 +136,59 @@ Window.PartitionBy(o.CustomerId)
.ThenBy(o.Id)
```

## Window Frame Specification

Aggregate window functions support frame clauses that narrow the set of rows used for the computation. Frames use `RowsBetween` or `RangeBetween` chained onto an ordered window specification:

```csharp
Window.OrderBy(o.Price)
.RowsBetween(WindowFrameBound.UnboundedPreceding, WindowFrameBound.CurrentRow)
```

The `WindowFrameBound` factory members produce the five SQL:2003 frame boundaries:

| Bound | SQL |
|-------|-----|
| `WindowFrameBound.UnboundedPreceding` | `UNBOUNDED PRECEDING` |
| `WindowFrameBound.Preceding(n)` | `n PRECEDING` |
| `WindowFrameBound.CurrentRow` | `CURRENT ROW` |
| `WindowFrameBound.Following(n)` | `n FOLLOWING` |
| `WindowFrameBound.UnboundedFollowing` | `UNBOUNDED FOLLOWING` |

Example — running total with `SUM`:

```csharp
var results = db.Orders.Select(o => new
{
o.Id,
o.Price,
RunningTotal = WindowFunction.Sum(o.Price,
Window.PartitionBy(o.CustomerId)
.OrderBy(o.Price)
.RowsBetween(WindowFrameBound.UnboundedPreceding, WindowFrameBound.CurrentRow))
});
```

Generated SQL (SQLite):

```sql
SELECT "o"."Id", "o"."Price",
SUM("o"."Price") OVER(PARTITION BY "o"."CustomerId" ORDER BY "o"."Price" ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS "RunningTotal"
FROM "Orders" AS "o"
```

::: tip Default frame behavior
When no explicit frame is specified, SQL defaults to `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` in the presence of `ORDER BY`. This produces a running total/min/max by default.
:::

::: warning Ranking functions don't support frames
The SQL standard forbids frame clauses on ranking functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE) — SQL Server and PostgreSQL will reject the query. Aggregate functions (SUM, AVG, COUNT, MIN, MAX) and value functions (FIRST_VALUE, LAST_VALUE, NTH_VALUE) accept frames.
:::

::: warning Literal offsets only
`Preceding(n)` and `Following(n)` accept an integer **constant**. Passing a variable or captured value will fail translation: SQL requires literal integer constants in the frame clause.
:::

## Complete Example

```csharp
Expand Down
29 changes: 29 additions & 0 deletions docs/recipes/window-functions-ranking.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,35 @@ Window functions are supported across all major relational providers:

The generated SQL uses standard window function syntax, which all these providers support.

## Aggregate Window Functions

In addition to ranking, ExpressiveSharp supports aggregate window functions (`SUM`, `AVG`, `COUNT`, `MIN`, `MAX`) with optional frame clauses. These are useful for running totals, moving averages, and cumulative min/max:

```csharp
using ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions;

var results = dbContext.Orders
.Select(o => new
{
o.Id,
o.Price,
RunningTotal = WindowFunction.Sum(o.Price,
Window.PartitionBy(o.CustomerId)
.OrderBy(o.Price)
.RowsBetween(WindowFrameBound.UnboundedPreceding, WindowFrameBound.CurrentRow)),
MovingAvg = WindowFunction.Average(o.Price,
Window.OrderBy(o.Price)
.RowsBetween(WindowFrameBound.Preceding(2), WindowFrameBound.CurrentRow))
})
.ToList();
```

See the [Window Functions guide](/guide/window-functions#window-frame-specification) for the full frame specification reference.

::: warning Frames apply to aggregate functions only
The SQL standard forbids frame clauses on ranking functions (ROW_NUMBER, RANK, DENSE_RANK, NTILE). SQL Server and PostgreSQL reject the syntax. Aggregate functions (SUM, AVG, COUNT, MIN, MAX) and value functions (FIRST_VALUE, LAST_VALUE, NTH_VALUE) support frames.
:::

## Tips

::: tip Combine with other ExpressiveSharp features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,26 @@ public OrderedWindowDefinition ThenBy<TKey>(TKey key) =>
/// <summary>Adds a subsequent ORDER BY column (descending).</summary>
public OrderedWindowDefinition ThenByDescending<TKey>(TKey key) =>
throw new InvalidOperationException("This method is translated to SQL and cannot be called directly.");

/// <summary>
/// Applies a row-based window frame: <c>ROWS BETWEEN <paramref name="start"/> AND <paramref name="end"/></c>.
/// </summary>
public FramedWindowDefinition RowsBetween(WindowFrameBound start, WindowFrameBound end) =>
throw new InvalidOperationException("This method is translated to SQL and cannot be called directly.");

/// <summary>
/// Applies a range-based window frame: <c>RANGE BETWEEN <paramref name="start"/> AND <paramref name="end"/></c>.
/// </summary>
public FramedWindowDefinition RangeBetween(WindowFrameBound start, WindowFrameBound end) =>
throw new InvalidOperationException("This method is translated to SQL and cannot be called directly.");
}

/// <summary>
/// Represents a window specification after a frame clause (ROWS/RANGE BETWEEN) has been applied.
/// Terminal type — no further chaining is possible.
/// </summary>
public sealed class FramedWindowDefinition
{
private FramedWindowDefinition() =>
throw new InvalidOperationException("FramedWindowDefinition is a marker type for expression trees and cannot be instantiated.");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace ExpressiveSharp.EntityFrameworkCore.RelationalExtensions.WindowFunctions;

/// <summary>
/// Represents a boundary of a SQL window frame (e.g. <c>UNBOUNDED PRECEDING</c>,
/// <c>3 PRECEDING</c>, <c>CURRENT ROW</c>, <c>5 FOLLOWING</c>, <c>UNBOUNDED FOLLOWING</c>).
/// These factory members are translated to SQL by ExpressiveSharp's translators —
/// they throw at runtime if accessed directly.
/// </summary>
public sealed class WindowFrameBound
{
private WindowFrameBound() =>
throw new InvalidOperationException("WindowFrameBound is a marker type for expression trees and cannot be instantiated.");

/// <summary>Translates to <c>UNBOUNDED PRECEDING</c>.</summary>
public static WindowFrameBound UnboundedPreceding =>
throw new InvalidOperationException("This property is translated to SQL and cannot be accessed directly.");

/// <summary>Translates to <c><paramref name="offset"/> PRECEDING</c>.</summary>
public static WindowFrameBound Preceding(int offset) =>
throw new InvalidOperationException("This method is translated to SQL and cannot be called directly.");

/// <summary>Translates to <c>CURRENT ROW</c>.</summary>
public static WindowFrameBound CurrentRow =>
throw new InvalidOperationException("This property is translated to SQL and cannot be accessed directly.");

/// <summary>Translates to <c><paramref name="offset"/> FOLLOWING</c>.</summary>
public static WindowFrameBound Following(int offset) =>
throw new InvalidOperationException("This method is translated to SQL and cannot be called directly.");

/// <summary>Translates to <c>UNBOUNDED FOLLOWING</c>.</summary>
public static WindowFrameBound UnboundedFollowing =>
throw new InvalidOperationException("This property is translated to SQL and cannot be accessed directly.");
}
Loading
Loading