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
18 changes: 17 additions & 1 deletion .github/workflows/pagination.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,24 @@ jobs:
- name: Test IR
run: dotnet test tests/BuildingBlocks/Pagination.Tests/BuildingBlocks.Pagination.Tests.csproj -c Release --verbosity minimal

- name: Verify Docker
run: docker info

- name: Test EF Core package
run: dotnet test tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj -c Release --verbosity minimal
env:
PAGINATION_POSTGRES_REQUIRED: "true"
run: |
set -euo pipefail
log="${RUNNER_TEMP}/pagination-ef-test.log"
dotnet test tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj -c Release --verbosity minimal | tee "$log"
if grep -E 'Skipped:[[:space:]]+[1-9]' "$log"; then
echo "::error::PostgreSQL EF tuple tests were skipped. CI must execute them against Testcontainers Postgres."
exit 1
fi
if ! grep -E 'Passed![[:space:]]+-[[:space:]]+Failed:[[:space:]]+0,[[:space:]]+Passed:[[:space:]]+[1-9]' "$log"; then
echo "::error::Pagination EF CI required a passing run with no failures."
exit 1
fi

- name: Test Dapper project (not packed)
run: dotnet test tests/BuildingBlocks/Pagination.Dapper.Tests/BuildingBlocks.Pagination.Dapper.Tests.csproj -c Release --verbosity minimal
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to **BuildingBlocks** packages in this repository are docume
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## BuildingBlocks.Pagination.EntityFrameworkCore [1.1.0] - 2026-09-04

### Added

- **Npgsql row-comparison seek** when the `SortKey` is tuple-eligible (uniform ASC/DESC) and every slot is a non-nullable value type: emits `(a, b, …) > (@0, @1, …)` via soft-resolved `EF.Functions.GreaterThan/LessThan` for **any column count** (9+ uses nested `ValueTuple` `TRest`). Mixed directions, strings, Sqlite, and SQL Server keep the expanded OR form.
- **`AddBuildingBlocksPagination` / `UseBuildingBlocksPagination`** — registers a command interceptor that appends `NULLS FIRST/LAST` to tagged pagination `ORDER BY` on Npgsql and Sqlite (null placement is encoded in the query tag; inverted on backward walks).
- **`HasKeysetIndex(sortKey, NullOrder)`** — optional Npgsql `HasNullSortOrder` on the composite index (soft API). The one-argument overload does not write null-sort metadata.
- Dapper: `ORDER BY … NULLS FIRST/LAST` on PostgreSQL and Sqlite from `PaginationOptions.Nulls` (also inverted on backward walks). SQL Server still omits `NULLS`.

### Changed

- `NullOrder` now documents seek **and** ORDER BY null placement where supported (was seek-predicate only).
- EF seek null comparisons for reference slots align with true NULLS FIRST/LAST semantics (fix for end-to-end null ordering).

### Notes

- Nullable value-type sort slots (`int?`, `DateTime?`) remain unsupported.
- No hard PackageReference on Npgsql in the nupkg; row comparison requires the host already using Npgsql.EntityFrameworkCore.PostgreSQL.
- Lab: `NameThenPrice` product sort (3-column keyset) + `AddBuildingBlocksPagination` / `UseBuildingBlocksPagination` on FeatureFusion.

## BuildingBlocks.Idempotency [1.0.0] - 2026-09-04

### Added
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Release tags (must match the project `<Version>`): `mediator-v*`, `mcp-v*`, `ide

- **One** packable project: `Pagination.EntityFrameworkCore`. Core IR and Dapper are `IsPackable=false`.
- Keep EF layout: `Extensions/` (public API), `Query/Internal/`, `Infrastructure/Internal/`.
- Npgsql NULLS: `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination` (tagged command interceptor `BuildingBlocks.Pagination:First|Last`, not `AsyncLocal`). Do not CASE-wrap `OrderBy`. `HasKeysetIndex(sortKey, NullOrder)` is optional Npgsql index metadata; the one-argument overload does not write it.
- Core stays dependency-free of ORMs. Map host enums to prebuilt `SortKey`s; do not parse property-name strings.
- Add/adjust tests for supported and unsupported rows ([PAGINATION_TEST_MATRIX.md](docs/building-blocks/PAGINATION_TEST_MATRIX.md)).
- Public API changes: update `PublicAPI.Shipped.txt` / XML docs.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,8 @@ app.MapPost("/orders", CreateAsync).WithIdempotency(useLock: true);

Typed **keyset (cursor)** pagination for EF Core. **One package** — `SortKey` / cursors ship inside it. Hosts map a sort enum to a **prebuilt** key — the library never reflects `"Price"` into a property. Unique last column required. Set `SigningKey` on public HTTP APIs. Dapper is an in-repo lab project, not a nupkg. There is no `IEnumerable` adapter.

**What's new in 1.1.0:** Npgsql row-comparison seek for uniform non-nullable composite keys of any width; `ORDER BY NULLS FIRST/LAST` on Npgsql/Sqlite via `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`; `HasKeysetIndex(sortKey, NullOrder)`.

```bash
dotnet add package BuildingBlocks.Pagination.EntityFrameworkCore
```
Expand All @@ -409,7 +411,7 @@ var page = await db.Products
.ToCursorPageAsync(new CursorRequest(cursor, 20), key);
```

Optional `PaginationOptions.Hint` defaults to `None`. `ReadUncommitted` is SQL Server session isolation (not `WITH (NOLOCK)`): EF starts one transaction around COUNT+PAGE when there is no ambient transaction, then restores `READ COMMITTED` on the still-open connection; ambient is ignored; PostgreSQL and Sqlite ignore it. Host `AsNoTracking` / Dapper `WITH (NOLOCK)` still work. Host `OrderBy` is replaced by the `SortKey`. Composite indexes should match each key, e.g. `(Price, Id)` and `(CreatedAt, Id)`. Nullable `T?` sort columns are unsupported. Guid CLR order is not SQL Server `uniqueidentifier` order. `NullOrder` is seek-predicate only (no SQL `NULLS FIRST/LAST`). Updates to a sort column can make a row vanish or reappear (inherent keyset).
Optional `PaginationOptions.Hint` defaults to `None`. `ReadUncommitted` is SQL Server session isolation (not `WITH (NOLOCK)`): EF starts one transaction around COUNT+PAGE when there is no ambient transaction, then restores `READ COMMITTED` on the still-open connection; ambient is ignored; PostgreSQL and Sqlite ignore it. Host `AsNoTracking` / Dapper `WITH (NOLOCK)` still work. Host `OrderBy` is replaced by the `SortKey`. Composite indexes should match each key, e.g. `(Price, Id)` and `(CreatedAt, Id)`. Nullable `T?` sort columns are unsupported. Guid CLR order is not SQL Server `uniqueidentifier` order. `NullOrder` drives seek and, on PostgreSQL/Sqlite, `ORDER BY NULLS FIRST/LAST` (EF: register `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`). Updates to a sort column can make a row vanish or reappear (inherent keyset).

Indexed keyset on **file SQLite** SQL with index `(Price, Id)`, page 20. `--probe` is Stopwatch (1 warmup + 5 repeats), not BenchmarkDotNet, not Dry, not EF InMemory. Times below are **this machine’s** catalog; do not treat them as PostgreSQL or SQL Server timings. **KB** is mean managed allocations per page (`GC.GetAllocatedBytesForCurrentThread`), not process working set. First page is cheap for all three; allocations stay in the same band (~75–86 KB) because each call opens a context and materializes 20 rows.

Expand Down Expand Up @@ -810,7 +812,7 @@ src/ # C# only
FeatureFusion.AppHost/ # Aspire AppHost (+ SigNoz stack)
FeatureFusion.ServiceDefaults/
EventBus/ # Reusable RabbitMQ event bus (namespaces stay EventBusRabbitMQ)
web/ # reserved — Next.js project root (not created yet; not in the .sln)
web/ # reserved Next.js showcase (README only; not in the .sln)
tests/
BuildingBlocks/
Mediator.Tests/
Expand Down Expand Up @@ -1002,7 +1004,7 @@ BuildingBlocks.Mediator: [NuGet v1.0.1](https://lnkd.in/p/eU5TsuR4) · [manual p
This remains a **public .NET lab**. Near-term direction:

- More **BuildingBlocks.\*** packages extracted from the showcase
- Frontend showcase (`web/`, Next.js project root)
- Frontend showcase (`web/`, Next.js project root — folder exists with a README; app not scaffolded yet)
- Keep the LinkedIn catalog in sync when new posts ship (`mcp-message-tools` planned)
- Pub/sub stays a **sibling** story (not Mediator notifications)

Expand All @@ -1021,7 +1023,7 @@ dotnet test FeatureFusion.sln -c Release
| `BuildingBlocks.Mcp.Tests` | Catalog, invoker, endpoint methods, MapTool scoped SP, idempotency, filters |
| `BuildingBlocks.Mcp.Analyzers.Tests` | BBMCP001–005 |
| `BuildingBlocks.Pagination.Tests` | Codec, registry, identifiers (net8 / net9 / net10) |
| `BuildingBlocks.Pagination.EntityFrameworkCore.Tests` | Sqlite keyset + shadow + projection |
| `BuildingBlocks.Pagination.EntityFrameworkCore.Tests` | Sqlite keyset + shadow + projection; Postgres Testcontainers when Docker is available |
| `BuildingBlocks.Pagination.Dapper.Tests` | Sqlite execute + dialect SQL asserts |
| `BuildingBlocks.Telemetry.Tests` | `AddTelemetry` / `IntegrateMediator` / `IntegrateMcp` |
| `BuildingBlocks.Aspire.Hosting.SigNoz.Tests` | AppHost integration |
Expand Down
5 changes: 3 additions & 2 deletions docs/adr/0003-pagination-keyset.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
1. **One NuGet** for v1: `BuildingBlocks.Pagination.EntityFrameworkCore`. The **intermediate representation** (IR: `SortKey`, cursors, seek slots) is a non-packable sibling project whose DLL is **bundled** in that nupkg. Consumers do not install a second Pagination package.
2. **Dapper** stays an **in-repo project**, not a package, until we choose to ship it. A LinqToDB adapter is **not** in this repository; a future adapter could reuse the same IR. Same rule as keeping MCP off Mediator: adapters do not leak into the shipped EF package as extra nupkgs.
3. Shared **seek IR** in the core project: ordered slots `{ Direction, ClrType, Value, SqlIdentifier?, Expression }`. The EF package executes; core does not reference EF.
4. EF project layout follows **EF Core package structure**: `Extensions/` (public `ToCursorPageAsync`), `Query/Internal/` (OrderBy + seek trees), `Infrastructure/Internal/` (DbContext / shadow). Mapped members use expressions; shadow uses typed `ByShadow<TValue>` → `EF.Property<TValue>`.
4. EF project layout follows **EF Core package structure**: `Extensions/` (public `ToCursorPageAsync` / `HasKeysetIndex`), `Query/Internal/` (OrderBy + seek trees), `Infrastructure/Internal/` (DbContext / shadow / NULLS interceptor / soft Npgsql `HasNullSortOrder`). Mapped members use expressions; shadow uses typed `ByShadow<TValue>` → `EF.Property<TValue>`.
5. Cursors are opaque and versioned. No `pageIndex`. HMAC is optional in the API and **required for untrusted HTTP** (`SigningKey`). Slot decode failures are `InvalidCursor`. Unregistered `SortKeyRegistry.Get` is `InvalidOperationException`, not a bad cursor.
6. FeatureFusion is the showcase (Minimal API `GET /api/v2/products-page`, MVC `POST /api/v2/Product/products`, Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`). No extra sample apps. Benchmarks: `benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks` (file SQLite: FeatureFusion vs OFFSET vs MR.EntityFrameworkCore.KeysetPagination; cursor codec separate). Publish Default-job or `--probe` only — never Dry.
7. **Query hints are optional and allowlisted.** `PaginationOptions.Hint` defaults to `QueryHint.None` (zero extra SQL). `QueryHint.ReadUncommitted` is SQL Server session isolation (`READ UNCOMMITTED`), not table-hint `WITH (NOLOCK)`. EF begins one transaction around COUNT (if requested) and PAGE when there is no ambient transaction, then restores `READ COMMITTED` on the still-open connection; an ambient transaction is ignored (no nest). Dapper prefixes `SET TRANSACTION ISOLATION LEVEL` and restores `READ COMMITTED`. PostgreSQL/Sqlite ignore it. No raw SQL strings (injection). Host `AsNoTracking` / `TagWith` / Dapper `WITH (NOLOCK)` remain valid when `Hint` is omitted. Kitchen-sink lock/index hints stay host-owned.
8. **No `IEnumerable` adapter.** Host `OrderBy` is replaced by `CursorOrder`, not merged. Nullable value-type sort slots are rejected (`NullableSortUnsupported`) because `NullOrder` does not emit `NULLS FIRST/LAST`.
8. **No `IEnumerable` adapter.** Host `OrderBy` is replaced by `CursorOrder`, not merged. Nullable value-type sort slots are rejected (`NullableSortUnsupported`). On PostgreSQL/Sqlite, `NullOrder` drives seek and `ORDER BY … NULLS FIRST/LAST` when the host registers `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`. EF does **not** use `AsyncLocal`: `ToCursorPageAsync` tags the query `BuildingBlocks.Pagination:First|Last` (inverted on backward walks) and a `DbCommandInterceptor` rewrites only those tagged commands. Dapper emits NULLS without that registration. SQL Server does not emit `NULLS`.
9. **Npgsql row comparison** for uniform non-nullable multi-column keys of any width (soft dependency on the host’s Npgsql package; 9+ columns nest `ValueTuple` `TRest`). Mixed ASC/DESC and string slots keep the expanded OR seek. `HasKeysetIndex(sortKey, NullOrder)` optionally calls Npgsql `HasNullSortOrder`; the one-argument overload does not write that metadata.

## Consequences

Expand Down
Loading
Loading