diff --git a/.github/workflows/pagination.yml b/.github/workflows/pagination.yml index de47a27..8660144 100644 --- a/.github/workflows/pagination.yml +++ b/.github/workflows/pagination.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a482f20..1362956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48c7866..3ab853b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,7 @@ Release tags (must match the project ``): `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. diff --git a/README.md b/README.md index a592f10..6e5ff78 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. @@ -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/ @@ -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) @@ -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 | diff --git a/docs/adr/0003-pagination-keyset.md b/docs/adr/0003-pagination-keyset.md index 613da8f..b3d65f1 100644 --- a/docs/adr/0003-pagination-keyset.md +++ b/docs/adr/0003-pagination-keyset.md @@ -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` → `EF.Property`. +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` → `EF.Property`. 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 diff --git a/docs/building-blocks/PAGINATION_TEST_MATRIX.md b/docs/building-blocks/PAGINATION_TEST_MATRIX.md index 79bdd92..3884118 100644 --- a/docs/building-blocks/PAGINATION_TEST_MATRIX.md +++ b/docs/building-blocks/PAGINATION_TEST_MATRIX.md @@ -1,4 +1,4 @@ -# BuildingBlocks.Pagination v1 — test matrix +# BuildingBlocks.Pagination v1.1 — test matrix xUnit on **net8.0 / net9.0 / net10.0**. No coverlet gate. Fast CI: `.github/workflows/pagination.yml` (IR, EF Sqlite, Dapper SQL-gen). SQL Server QueryHint execute: `.github/workflows/pagination-queryhint-sqlserver.yml`. Release pack: `.github/workflows/pagination-release.yml` (tag `pagination-v*` must match the EF ``). **One nupkg:** `BuildingBlocks.Pagination.EntityFrameworkCore`. Dapper tests still run; that project is not packed. @@ -12,9 +12,9 @@ Each supported row has a matching test name prefix. |---------------|-----------| | `Enum_` | `ItemSortField` × `SortDirection` first page (ASC/DESC); exhaustive `SortKeyRegistry` (`Enum.GetValues`); next page no overlap; previous cursor restores first page; Price cursor + Name key → `CursorSortMismatch`; unknown enum is a registry miss (not string reflection) | | `Expression_` / `String_Seek_` / `Guid_Seek_` / `Shadow_` / `No_Public_OrderBy` | Mapped `By(p => p.Price)` query has **no** `EF.Property`; string/Guid seek has **no** `CompareTo` / `string.Compare` and uses `>`; shadow `ByShadow` **does** use `EF.Property`; unknown shadow fails before execute; nested `Vendor.Name` (EF); no `OrderBy(string)` API | -| `HasKeysetIndex_` | Composite index property order + DESC flags match `SortKey`; nested path → `UnsupportedKeysetIndex` | +| `HasKeysetIndex_` | Composite index property order + DESC flags match `SortKey`; nested path → `UnsupportedKeysetIndex`; `HasKeysetIndex(sortKey, NullOrder)` emits non-default `NULLS FIRST/LAST` in Npgsql `CREATE INDEX`; one-arg overload does not | | `Forward_` / `Previous_` / `Duplicate_` / `Empty_` / `Single_Page` / `Backward_` / `Replay_` | Empty table; single page `HasNext` false; `limit+1` HasNext; forward no overlap (**same ids as seed/EF**); backward restores first page; **empty cursor + `PageDirection.Backward` = last page**; **same next cursor replayed is stable**; composite Price+Id with duplicate prices; whitespace cursor = first page | -| `Postgres_` / `Mixed_Direction_` / `SqlServer_` / `Sqlite_` | Dapper: Postgres tuple SQL `(price, id) >` when directions match; mixed ASC/DESC is OR not tuple; SqlServer `FETCH NEXT`; Sqlite `LIMIT`. EF: OR-chain seek (provider-agnostic) + Sqlite `LIMIT` in `ToQueryString` | +| `Postgres_` / `Mixed_Direction_` / `SqlServer_` / `Sqlite_` / `Three_Column_` / `Four_Column_` / `Nine_Column_` | Dapper: Postgres tuple SQL when directions match (incl. 3–4 and 9 columns); mixed ASC/DESC is OR; `NULLS FIRST/LAST` on PG/Sqlite ORDER BY; SqlServer `FETCH NEXT` and no NULLS. EF Sqlite: OR-chain + multi-column execute (incl. 9-column). EF Npgsql (Testcontainers, required in `pagination.yml` CI): tuple seek for uniform non-null keys of any width; string/mixed stay OR; NULLS LAST via interceptor | | `Missing_Sql_` / `Shadow_Throws` / `Host_Where_` / `InvalidHostSql_` | Dapper missing `sql:` throws; Dapper rejects `ByShadow`; host WHERE params + `@ks0` no collision; host SQL with `ORDER BY` / `OFFSET` / `FETCH` / `LIMIT` → `InvalidHostSql`; `WITH (NOLOCK)` in host SQL is preserved inside the subquery | | `IncludeTotalCount_` / `Limit_` / `Invalid_` / `Hmac_` / `Codec_` | Count on/off; limit 0 / over max; invalid/truncated/tampered cursor; HMAC reject unsigned when key set; concurrent encode/decode; `PaginationOptions.Default` is not a shared mutable singleton; default `Hint` is `None` | | `Clr_` | `int`, `long`, `short`, `byte`, `float`, `double`, `bool`, `Guid`, `string`, `decimal` (EF InMemory + Dapper Sqlite), `DateTime` UTC, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, entity enum. **`T?` sort slots → `NullableSortUnsupported`** | @@ -23,10 +23,10 @@ Each supported row has a matching test name prefix. | `Cancellation` | `CancellationToken` on EF `ToListAsync` and Dapper `CommandDefinition` | | `Hmac_Signed_` / `Insert_And_Delete_` / `Mixed_Direction_` / `Registry_` | HMAC signed first/next/prev execute; insert+delete between pages keep unique keys stable; mixed ASC/DESC EF seek SQL is an OR-chain; unregistered `SortKeyRegistry.Get` is `InvalidOperationException` (not `InvalidCursor`); `FromCursorValue` bad JSON → `InvalidCursor` | | `SortColumn_` | EF Sqlite execute: raising a later `Price`/`CreatedAt` before the cursor vanishes from page 2; lowering a far-ahead row can appear later. Inherent keyset; no uniqueness beyond the unique slot | -| `Nullable_String_` | EF Sqlite execute: `string?` null / `""` / names; `NullOrder.Last` and `First`; forward and backward. Seek-only (no `NULLS FIRST/LAST`); first-page `ORDER BY` follows Sqlite (NULL first) even when `Nulls = Last` | +| `Nullable_String_` / `AddBuildingBlocksPagination_` | EF Sqlite with `UseBuildingBlocksPagination`: `NullOrder.Last`/`First` change first-page order; without interceptor, Sqlite NULL-first remains. Seek aligns with NULLS semantics | | `Cancellation_After_` | EF interceptor cancels after `ReaderExecuting` (command started). Existing `Cancellation()` is pre-cancelled | | `QueryHint_` | Default `None` emits no SET/NOLOCK; Dapper `ReadUncommitted` prefixes `SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;` on SqlServer only (Postgres/Sqlite SQL unchanged), including COUNT-shaped SQL via `QueryHintSql.Apply`; restore `READ COMMITTED` is a separate execute (not in `BuildSql`); host `WITH (NOLOCK)` still inside the subquery; EF/Dapper Sqlite execute with `ReadUncommitted` is a no-op; **SQL Server execute** (Aspire, separate project): dirty read, COUNT+PAGE same isolation, commit, exception rollback, cancellation cleanup, ambient txn ignored; **same open connection** isolation is 1 during RU paging and 2 after success/exception/cancellation (pooling on); Dapper execute restore on open SqlConnection; CI (`GITHUB_ACTIONS` / `PAGINATION_SQLSERVER_REQUIRED`) never skips; IntegrationTests Postgres execute is a no-op | -| Lab HTTP | `FeatureFusionApiTests` — `GET /api/v2/products-page` first/next/prev; empty cursor + `pageDirection=Backward` last page; GET matches POST; every `ProductSortField` × direction; JSON `"SortBy":"Price"`; invalid cursor 400; first-page `TotalCount > limit`; EF list uses `AsNoTracking` + `TagWith("products.list")` + SQL `Select` to `ProductDto`; MVC `POST /api/v2/Product/products` and GET Minimal API return the same ids; Dapper first page matches EF; Swagger v2 documents GET | +| Lab HTTP | `FeatureFusionApiTests` — `GET /api/v2/products-page` first/next/prev; empty cursor + `pageDirection=Backward` last page; GET matches POST; every `ProductSortField` × direction (incl. `NameThenPrice`); Price next-page seek SQL is Npgsql row comparison; JSON `"SortBy":"Price"`; invalid cursor 400; first-page `TotalCount > limit`; EF list uses `AsNoTracking` + `TagWith("products.list")` + SQL `Select` to `ProductDto`; MVC `POST /api/v2/Product/products` and GET Minimal API return the same ids; Dapper first page matches EF; Swagger v2 documents GET | ## Explicitly unsupported @@ -43,9 +43,9 @@ Each supported row has a matching test name prefix. | OData / filter DSL / Mongo | Out of v1 | | Mediator / MCP package refs | Pagination does not reference them | | Lab `Result` inside the library | Host maps `CursorPage` → `PagedResult` | -| Testcontainers Postgres in CI | Assert Postgres **tuple SQL text**; **execute** against Sqlite | +| Testcontainers Postgres execute | Required in Pagination CI (`PAGINATION_POSTGRES_REQUIRED` + Docker). Locally skipped when Docker is not on PATH | | Sort whole value objects / `byte[]` / Ulid structs | `UnsupportedSortType`; sort the mapped scalar | ## Edge -Null/empty/whitespace cursor = first page. Empty cursor + `PageDirection.Backward` = last page. Empty `SigningKey` + signing requested → `SigningKeyRequired`. Invalid SQL identifier (`Name; DROP TABLE`) → `InvalidIdentifier`. Slot JSON that cannot decode → `InvalidCursor`. Unregistered registry enum → `InvalidOperationException`. `NullOrder` is seek-side for reference types only and does **not** emit `NULLS FIRST/LAST`. Nullable value-type sort slots are rejected (`NullableSortUnsupported`). DateTime cursor values are UTC. Guid CLR order ≠ SQL Server `uniqueidentifier`. Host `OrderBy` is replaced. No `IEnumerable` API. +Null/empty/whitespace cursor = first page. Empty cursor + `PageDirection.Backward` = last page. Empty `SigningKey` + signing requested → `SigningKeyRequired`. Invalid SQL identifier (`Name; DROP TABLE`) → `InvalidIdentifier`. Slot JSON that cannot decode → `InvalidCursor`. Unregistered registry enum → `InvalidOperationException`. `NullOrder` drives seek and (on PG/Sqlite with EF interceptor or Dapper) `ORDER BY NULLS …`. Nullable value-type sort slots are rejected (`NullableSortUnsupported`). DateTime cursor values are UTC. Guid CLR order ≠ SQL Server `uniqueidentifier`. Host `OrderBy` is replaced. No `IEnumerable` API. diff --git a/docs/building-blocks/pagination.md b/docs/building-blocks/pagination.md index 253cf35..a10a805 100644 --- a/docs/building-blocks/pagination.md +++ b/docs/building-blocks/pagination.md @@ -1,6 +1,6 @@ # BuildingBlocks.Pagination -Typed keyset (cursor) pagination. **One NuGet:** `BuildingBlocks.Pagination.EntityFrameworkCore` (IR + EF Core adapter). Dapper is an in-repo project, not a package. +Typed keyset (cursor) pagination. **One NuGet:** `BuildingBlocks.Pagination.EntityFrameworkCore` **1.1.0** (IR + EF Core adapter). Dapper is an in-repo project, not a package. See ADR [0003](../adr/0003-pagination-keyset.md), the [test matrix](PAGINATION_TEST_MATRIX.md), and [`PACKAGE_README.md`](../../src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md). @@ -24,22 +24,28 @@ var key = SortKey.For() Map a host enum with `SortKeyRegistry` — do not parse `"Price"` into a property. An unregistered enum throws `InvalidOperationException` (not `InvalidCursor`). -**Sort the stored scalar.** Enums encode as their numeric underlying value. Value objects: `p => p.Money.Amount`, not `p => p.Money`. Nullable value types (`int?`, `DateTime?`, nullable enums) are rejected (`NullableSortUnsupported`); coalesce in the model. `NullOrder` does not emit `NULLS FIRST/LAST`. +**Sort the stored scalar.** Enums encode as their numeric underlying value. Value objects: `p => p.Money.Amount`, not `p => p.Money`. Nullable value types (`int?`, `DateTime?`, nullable enums) are rejected (`NullableSortUnsupported`); coalesce in the model. On PostgreSQL/Sqlite, `NullOrder` drives seek and `ORDER BY … NULLS FIRST/LAST` when the host calls `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination` (Dapper always emits NULLS on those dialects). SQL Server does not emit `NULLS`. ## EF Core ```csharp +builder.Services.AddBuildingBlocksPagination(); +// when configuring DbContext: +options.UseBuildingBlocksPagination(); + var page = await db.Products .AsNoTracking() .TagWith("products.list") .ToCursorPageAsync(new CursorRequest(cursor, 20), key); ``` -Source layout matches EF Core packages: `Extensions/` (public API), `Query/Internal/` (OrderBy + seek), `Infrastructure/Internal/` (DbContext / shadow). +Source layout matches EF Core packages: `Extensions/` (public API), `Query/Internal/` (OrderBy + seek), `Infrastructure/Internal/` (DbContext / NULLS interceptor / soft Npgsql `HasNullSortOrder`). `ToCursorPageAsync` tags queries `BuildingBlocks.Pagination:First|Last` so the NULLS interceptor can rewrite `ORDER BY` without `AsyncLocal`. Host `OrderBy` is **replaced** by the sort key, not merged. There is no `IEnumerable` overload. -Optional `PaginationOptions.Hint` defaults to `None` (no extra SQL or transaction). `QueryHint.ReadUncommitted` is SQL Server **session isolation** (`READ UNCOMMITTED`), not table-hint `WITH (NOLOCK)`. EF begins one `ReadUncommitted` 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). PostgreSQL and Sqlite no-op. Dapper uses a `SET TRANSACTION ISOLATION LEVEL` prefix, then restores `READ COMMITTED` on the open connection. Host `AsNoTracking` / `TagWith` / Dapper `WITH (NOLOCK)` still work when `Hint` is omitted. Prefer the `Select` overload so DTOs are projected in SQL. `HasKeysetIndex(sortKey)` creates the matching composite index (add a DESC variant when the first column is descending). +On **Npgsql**, uniform non-nullable multi-column keys of **any width** use SQL row comparison (`(a, b, …) >`); mixed ASC/DESC and `string` slots stay on the OR form. Prefer matching composite indexes via `HasKeysetIndex` (add a DESC variant and optional `NullOrder` when the NULLS placement is not the column's ASC/DESC default). + +Optional `PaginationOptions.Hint` defaults to `None` (no extra SQL or transaction). `QueryHint.ReadUncommitted` is SQL Server **session isolation** (`READ UNCOMMITTED`), not table-hint `WITH (NOLOCK)`. EF begins one `ReadUncommitted` 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). PostgreSQL and Sqlite no-op. Dapper uses a `SET TRANSACTION ISOLATION LEVEL` prefix, then restores `READ COMMITTED` on the open connection. Host `AsNoTracking` / `TagWith` / Dapper `WITH (NOLOCK)` still work when `Hint` is omitted. Prefer the `Select` overload so DTOs are projected in SQL. `HasKeysetIndex(sortKey)` creates the matching composite index (add a DESC variant when the first column is descending). `HasKeysetIndex(sortKey, NullOrder)` optionally calls Npgsql `HasNullSortOrder`; the one-argument form does not. Set `SigningKey` on public HTTP APIs so clients cannot forge `Walk` or key values. @@ -70,7 +76,7 @@ GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor=< GET /api/v2/products-page?limit=20&sortBy=Price&sortDirection=Ascending&cursor= ``` -Empty cursor + `pageDirection=Backward` starts at the last page (`GET /api/v2/products-page?limit=20&pageDirection=Backward`). `sortBy`: `Id` · `Name` · `Price` · `CreatedAt` (each composite key ends with unique `Id`). `sortDirection`: `Ascending` · `Descending`. +Empty cursor + `pageDirection=Backward` starts at the last page (`GET /api/v2/products-page?limit=20&pageDirection=Backward`). `sortBy`: `Id` · `Name` · `Price` · `CreatedAt` · `NameThenPrice` (each composite key ends with unique `Id`). `sortDirection`: `Ascending` · `Descending`. ![First page, next cursor, previous cursor, last page via pageDirection=Backward.](../medium/images/04b-cursor-flow.png) diff --git a/docs/lab/README.md b/docs/lab/README.md index 609f985..272a112 100644 --- a/docs/lab/README.md +++ b/docs/lab/README.md @@ -74,7 +74,7 @@ Do **not** extract: Scenario DSL, one-off test gates, fixed-permit rate limiters ## Package / versioning (current) -- Each packable project owns `` in its `.csproj` (e.g. `BuildingBlocks.Mcp` `1.0.0`). +- Each packable project owns `` in its `.csproj` (e.g. `BuildingBlocks.Pagination.EntityFrameworkCore` `1.1.0`, `BuildingBlocks.Mcp` `1.0.0`). - Release tags are package-prefixed (`mcp-v*`, `mediator-v*`, `telemetry-v*`, …) and must match ``. - Per-package GitHub Actions: `*-yml` CI + `*-release.yml` pack / Trusted Publishing to nuget.org (`idempotency.yml` / `idempotency-release.yml` for this package). - IntegrationTests / Exp 1–18 are **not** in CI (local Aspire/Docker). diff --git a/docs/linkedin-posts.md b/docs/linkedin-posts.md index c3525f2..63e72f9 100644 --- a/docs/linkedin-posts.md +++ b/docs/linkedin-posts.md @@ -12,7 +12,7 @@ Umbrella narrative (lab → packages): [Medium](https://medium.com/@m2hweb86/fea | `building-blocks-overview` | BuildingBlocks + NuGet packages (Mediator 1.1.0, keyset, MCP, Telemetry, SigNoz) | published | https://lnkd.in/p/e4-FrM44 | `src/BuildingBlocks/*` | `medium-engineering-lab`, `mediator-building-blocks`, `cursor-pagination`, `mcp-message-tools` | Umbrella post: extract only when the contract earned a boundary. Not a SigNoz SDK. Dapper pagination stays in-repo. | | `mediator` | Manual Mediator + pipeline behaviors | published | https://www.linkedin.com/feed/update/urn:li:activity:7311311587372367873/ | was `Infrastructure/CQRS`; now `src/BuildingBlocks/Mediator` | `mediator-building-blocks` | Custom CQRS Mediator with cached wrappers, void via `ICommand : ICommand`, OTel-friendly behaviors. Follow-up NuGet post → `mediator-building-blocks`. | | `mediator-building-blocks` | BuildingBlocks.Mediator NuGet (v1.0.1 launch; current 1.1.0) | published | https://lnkd.in/p/eU5TsuR4 | `src/BuildingBlocks/Mediator` + `docs/building-blocks/mediator.md` | `mediator`, `building-blocks-overview` | Packaged CQRS Send + ordered pipeline (void via `ICommand : ICommand`). Publish/notifications out of v1. 1.1.0 adds typed command/query behaviors + Send metrics. Prior deep-dive → `mediator`. | -| `cursor-pagination` | Reusable generic bidirectional keyset (cursor) pagination | published | https://www.linkedin.com/feed/update/urn:li:activity:7325068550614708225/ | `src/BuildingBlocks/Pagination.EntityFrameworkCore` + lab `ProductService` | `building-blocks-overview` | Typed `SortKey`, opaque cursors, one EF Core nupkg. Optional `QueryHint` (default none). Lab: MVC `POST /api/v2/Product/products`, Minimal API `GET /api/v2/products-page` (POST kept), Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`. | +| `cursor-pagination` | Reusable generic bidirectional keyset (cursor) pagination | published | https://www.linkedin.com/feed/update/urn:li:activity:7325068550614708225/ | `src/BuildingBlocks/Pagination.EntityFrameworkCore` + lab `ProductService` | `building-blocks-overview` | Typed `SortKey`, opaque cursors, one EF Core nupkg (**1.1.0**: Npgsql row comparison, NULLS interceptor). Optional `QueryHint` (default none). Lab: MVC `POST /api/v2/Product/products`, Minimal API `GET /api/v2/products-page` (POST kept), Dapper `products-dapper`, MCP `products.list` — one `GetProductsQuery`. | | `mcp-message-tools` | Message types as MCP tools | published | https://lnkd.in/p/e4-FrM44 | `src/BuildingBlocks/Mcp` + `docs/building-blocks/mcp.md` | `building-blocks-overview` | Deny-by-default `[McpTool]` / `WithMcp` / `MapTool` on the official SDK. Same logic as HTTP. Interim permalink is the umbrella post until a dedicated MCP post ships. Not OpenAPI, not a SOLID linter. | | `idempotency-cqrs` | Idempotency with CQRS commands | published | https://www.linkedin.com/feed/update/urn:li:activity:7303686809891356676/ | (pattern lab) | `idempotentfusion` | **Lab only, not NuGet.** Command-level idempotency via reusable `IdentifiedCommand` + ULID `Idempotency-Key`, intercepting before handlers. Complementary to Mediator — not core v1. | | `idempotentfusion` | IdempotentFusion (REST API) | published | https://www.linkedin.com/feed/update/urn:li:activity:7309149985307029504/ | `src/BuildingBlocks/Idempotency` + Lab `OrderController` | `idempotency-cqrs` | HTTP Idempotency-Key via `BuildingBlocks.Idempotency` (Redis cache host-owned, optional SET NX lock). Lab sets `UserIdFallback = "123"`. | diff --git a/src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj b/src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj index 6454cb4..c7b1a3b 100644 --- a/src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj +++ b/src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj @@ -23,6 +23,7 @@ https://github.com/Maxofpower/FeatureFusion git PACKAGE_README.md + idempotency-icon.png 1.0.0: IdempotencyGate; MVC + Minimal API; 2xx envelope replay; ProblemDetails errors; ProcessingTtl / EntryTtl + per-endpoint overrides; optional Redis lock; opt-in method+path+body fingerprint; fluent UseRedisLock/UseTelemetry. Host owns IDistributedCache. https://github.com/Maxofpower/FeatureFusion/blob/main/CHANGELOG.md true true @@ -34,6 +35,7 @@ + diff --git a/src/BuildingBlocks/Idempotency/PACKAGE_README.md b/src/BuildingBlocks/Idempotency/PACKAGE_README.md index 4726a06..b10130e 100644 --- a/src/BuildingBlocks/Idempotency/PACKAGE_README.md +++ b/src/BuildingBlocks/Idempotency/PACKAGE_README.md @@ -2,6 +2,10 @@ ASP.NET Core **Idempotency-Key** for MVC and Minimal API. Host-owned `IDistributedCache`, optional Redis lock, opt-in request fingerprint, RFC 9457 ProblemDetails on conflicts, optional ActivitySource. +[![NuGet](https://img.shields.io/nuget/v/BuildingBlocks.Idempotency.svg)](https://www.nuget.org/packages/BuildingBlocks.Idempotency) +[![.NET](https://img.shields.io/badge/.NET-8%20%7C%209%20%7C%2010-512BD4)](https://dotnet.microsoft.com/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + Requires **.NET 8**, **.NET 9**, or **.NET 10**. ## Install diff --git a/src/BuildingBlocks/Idempotency/idempotency-icon.png b/src/BuildingBlocks/Idempotency/idempotency-icon.png new file mode 100644 index 0000000..ed7e8b0 Binary files /dev/null and b/src/BuildingBlocks/Idempotency/idempotency-icon.png differ diff --git a/src/BuildingBlocks/Pagination.Dapper/AGENTS.md b/src/BuildingBlocks/Pagination.Dapper/AGENTS.md index 0c9978b..14aaeb5 100644 --- a/src/BuildingBlocks/Pagination.Dapper/AGENTS.md +++ b/src/BuildingBlocks/Pagination.Dapper/AGENTS.md @@ -2,6 +2,6 @@ **Not a NuGet.** `IDbConnection.QueryCursorPageAsync(...)`. Project-reference `BuildingBlocks.Pagination`. **No** `IQueryable`. -Host SQL: `SELECT … FROM … WHERE …` with no `ORDER BY`/`OFFSET`/`FETCH`/`LIMIT` (`InvalidHostSql`). Hints such as `WITH (NOLOCK)` belong in host SQL. `PaginationOptions.Hint` default `None`; `ReadUncommitted` prefixes `SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;` on SQL Server only (`QueryHintSql`) — session isolation, not a table hint — then restores `READ COMMITTED` on the still-open connection; EF uses a transaction around COUNT+PAGE, not this SET prefix. Every slot needs `sql:`. Library wraps as `SELECT * FROM ({sql}) AS _bbp` and appends seek + `ORDER BY` + limit. Params: host + `@ks0`… + `@take`. Nullable value-type slots are rejected (`NullableSortUnsupported`); `NullOrder` is unused in Dapper SQL. +Host SQL: `SELECT … FROM … WHERE …` with no `ORDER BY`/`OFFSET`/`FETCH`/`LIMIT` (`InvalidHostSql`). Hints such as `WITH (NOLOCK)` belong in host SQL. `PaginationOptions.Hint` default `None`; `ReadUncommitted` prefixes `SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;` on SQL Server only (`QueryHintSql`) — session isolation, not a table hint — then restores `READ COMMITTED` on the still-open connection; EF uses a transaction around COUNT+PAGE, not this SET prefix. Every slot needs `sql:`. Library wraps as `SELECT * FROM ({sql}) AS _bbp` and appends seek + `ORDER BY` + limit. Params: host + `@ks0`… + `@take`. Nullable value-type slots are rejected (`NullableSortUnsupported`). `NullOrder` emits `NULLS FIRST/LAST` on PostgreSQL and Sqlite ORDER BY (inverted on backward walks); unused on SQL Server. Postgres row comparison (`(a, b, …) >`) when all directions match — any column count. Mixed ASC/DESC stays OR. `SqlDialect.PostgreSql` (tuple when directions match), `SqlServer` (`FETCH NEXT`), `Sqlite` (`LIMIT`). `ByShadow` is rejected. diff --git a/src/BuildingBlocks/Pagination.Dapper/DapperCursorExtensions.cs b/src/BuildingBlocks/Pagination.Dapper/DapperCursorExtensions.cs index 5f72916..a84819d 100644 --- a/src/BuildingBlocks/Pagination.Dapper/DapperCursorExtensions.cs +++ b/src/BuildingBlocks/Pagination.Dapper/DapperCursorExtensions.cs @@ -53,7 +53,8 @@ public static async ValueTask> QueryCursorPageAsync( .ConfigureAwait(false); } - var command = SeekSql.Build(sql, sortKey, values, walkBackward, request.Limit + 1, dialect, param); + var command = SeekSql.Build( + sql, sortKey, values, walkBackward, request.Limit + 1, dialect, param, options.Nulls); var pageSql = QueryHintSql.Apply(options.Hint, dialect, command.Sql); var fetched = (await connection.QueryAsync( new CommandDefinition(pageSql, command.Parameters, cancellationToken: cancellationToken)) @@ -130,7 +131,8 @@ public static string BuildSql( HostSql.EnsureFilterOnly(sql); sortKey.EnsureNoShadow(); sortKey.EnsureSqlIdentifiers(); - var built = SeekSql.Build(sql, sortKey, values, walkBackward, take, dialect, param: null).Sql; + var built = SeekSql.Build( + sql, sortKey, values, walkBackward, take, dialect, param: null, options?.Nulls ?? NullOrder.Last).Sql; return QueryHintSql.Apply(options?.Hint ?? QueryHint.None, dialect, built); } } diff --git a/src/BuildingBlocks/Pagination.Dapper/README.md b/src/BuildingBlocks/Pagination.Dapper/README.md index 1b261b3..bcfd160 100644 --- a/src/BuildingBlocks/Pagination.Dapper/README.md +++ b/src/BuildingBlocks/Pagination.Dapper/README.md @@ -2,7 +2,7 @@ In-repo **project only** — not a NuGet package. FeatureFusion showcases it at `POST /api/v2/Product/products-dapper`. -Uses the Pagination IR project (`IsPackable=false`). Hosts that want NuGet pagination should use **BuildingBlocks.Pagination.EntityFrameworkCore**. +Uses the Pagination IR project (`IsPackable=false`). Hosts that want NuGet pagination should use **BuildingBlocks.Pagination.EntityFrameworkCore** (1.1.0: Npgsql row comparison of any width, `NULLS FIRST/LAST` on PG/Sqlite). ## Usage (lab / local project reference) diff --git a/src/BuildingBlocks/Pagination.Dapper/SeekSql.cs b/src/BuildingBlocks/Pagination.Dapper/SeekSql.cs index 4e2ff5f..5a08139 100644 --- a/src/BuildingBlocks/Pagination.Dapper/SeekSql.cs +++ b/src/BuildingBlocks/Pagination.Dapper/SeekSql.cs @@ -15,7 +15,8 @@ public static SeekCommand Build( bool walkBackward, int take, SqlDialect dialect, - object? param) + object? param, + NullOrder nulls = NullOrder.Last) { var parameters = new DynamicParameters(param); parameters.Add("take", take); @@ -30,7 +31,7 @@ public static SeekCommand Build( } sb.Append(" ORDER BY "); - AppendOrder(sb, sortKey, walkBackward, dialect); + AppendOrder(sb, sortKey, walkBackward, dialect, nulls); AppendLimit(sb, dialect); return new SeekCommand(sb.ToString(), parameters); @@ -85,8 +86,18 @@ private static void AppendSeek( sb.Append(')'); } - private static void AppendOrder(StringBuilder sb, SortKey sortKey, bool walkBackward, SqlDialect dialect) + private static void AppendOrder( + StringBuilder sb, + SortKey sortKey, + bool walkBackward, + SqlDialect dialect, + NullOrder nulls) { + var emitNulls = dialect is SqlDialect.PostgreSql or SqlDialect.Sqlite; + var effective = walkBackward + ? (nulls == NullOrder.Last ? NullOrder.First : NullOrder.Last) + : nulls; + var nullsSql = effective == NullOrder.First ? " NULLS FIRST" : " NULLS LAST"; for (var i = 0; i < sortKey.Slots.Count; i++) { if (i > 0) @@ -97,6 +108,10 @@ private static void AppendOrder(StringBuilder sb, SortKey sortKey, bool wa var dir = SeekOps.OrderDirection(sortKey.Slots[i], walkBackward); sb.Append(Quote(sortKey.Slots[i].SqlIdentifier!, dialect)) .Append(dir == SortDirection.Ascending ? " ASC" : " DESC"); + if (emitNulls) + { + sb.Append(nullsSql); + } } } diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/AGENTS.md b/src/BuildingBlocks/Pagination.EntityFrameworkCore/AGENTS.md index 7ef675d..18d3d33 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/AGENTS.md +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/AGENTS.md @@ -6,14 +6,21 @@ Layout matches EF Core packages (`src/EFCore/Extensions`, `Query/Internal`, `Inf ```text Extensions/EntityFrameworkCursorExtensions.cs # ToCursorPageAsync / ToCursorPageMappedAsync -Query/Internal/ # CursorOrder, CursorSeek, CursorSlot +Extensions/PaginationServiceCollectionExtensions.cs # AddBuildingBlocksPagination / UseBuildingBlocksPagination +Extensions/KeysetIndexExtensions.cs # HasKeysetIndex (+ optional NullOrder) +Query/Internal/ # CursorOrder, CursorSeek, CursorSeekTuple, CursorSlot Infrastructure/Internal/CursorDbContext.cs # ICurrentDbContext + shadow Infrastructure/Internal/QueryHintExecutor.cs # SQL Server ReadUncommitted txn wrap; else no-op +Infrastructure/Internal/PaginationNullsInterceptor.cs # ORDER BY NULLS FIRST/LAST (tagged commands) +Infrastructure/Internal/CursorProvider.cs # soft Npgsql/Sqlite/SqlServer detection +Infrastructure/Internal/NpgsqlIndexNullSort.cs # soft HasNullSortOrder (HasKeysetIndex NullOrder) ``` Mapped properties: expressions. Shadow: `ByShadow("Name")` → `EF.Property`. No `OrderBy(string)`. -`ToCursorPageAsync` + `Expression>` projects **after** seek (prefer this). In-memory map: `ToCursorPageMappedAsync`. `HasKeysetIndex` builds the matching composite index from the same `SortKey`. +`ToCursorPageAsync` + `Expression>` projects **after** seek (prefer this). In-memory map: `ToCursorPageMappedAsync`. `HasKeysetIndex` builds the matching composite index from the same `SortKey` (optional `NullOrder` for Npgsql null-sort metadata). + +Npgsql hosts: register `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination` for `ORDER BY NULLS …` (LINQ cannot emit NULLS; a tagged interceptor `BuildingBlocks.Pagination:First|Last` is index-honest — not `AsyncLocal`, do not CASE-wrap OrderBy). Uniform non-nullable multi-column keys use row-comparison seek when Npgsql is loaded (any width: 2–8 via `ValueTuple.Create`, 9+ nested `TRest`; mixed ASC/DESC and `string` stay OR). `Take(limit+1)`; reverse in memory when walking backward. `IncludeTotalCount` runs `CountAsync` **before** seek. Host applies `AsNoTracking`/`TagWith` before the extension. Host `OrderBy` is **replaced** by `CursorOrder.Apply`, not merged. `PaginationOptions.Hint` default `None`; `ReadUncommitted` is session isolation (not `WITH (NOLOCK)`): EF begins one SQL Server transaction around count+fetch when there is no ambient transaction; after commit/rollback the still-open connection is restored to `READ COMMITTED`; ambient is ignored; PostgreSQL/Sqlite no-op. There is no `IEnumerable` adapter. Nullable `T?` sort slots are rejected (`NullableSortUnsupported`). Guid CLR order ≠ SQL Server `uniqueidentifier` order. Bool/enum seek converts to the underlying numeric type. Guid seek uses `>` / `<`; string seek uses `string.Compare` (EF translates to SQL `>`). OrderBy lambdas are cached per fingerprint. diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj b/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj index 0b82748..2ce477a 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/BuildingBlocks.Pagination.EntityFrameworkCore.csproj @@ -11,7 +11,7 @@ true BuildingBlocks.Pagination.EntityFrameworkCore BuildingBlocks.Pagination.EntityFrameworkCore - 1.0.0 + 1.1.0 Mohammad Hasan Hosseini Mohammad Hasan Hosseini Copyright (c) 2026 Mohammad Hasan Hosseini @@ -23,7 +23,7 @@ git PACKAGE_README.md pagination-icon.png - 1.0.0: ToCursorPageAsync for IQueryable; SortKey/cursors ship in this package. https://github.com/Maxofpower/FeatureFusion/blob/main/CHANGELOG.md + 1.1.0: Npgsql row-comparison seek for multi-column keys; ORDER BY NULLS FIRST/LAST (AddBuildingBlocksPagination); HasKeysetIndex NullOrder overload. https://github.com/Maxofpower/FeatureFusion/blob/main/CHANGELOG.md true true true @@ -65,6 +65,7 @@ + diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/EntityFrameworkCursorExtensions.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/EntityFrameworkCursorExtensions.cs index 0520d86..45c7470 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/EntityFrameworkCursorExtensions.cs +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/EntityFrameworkCursorExtensions.cs @@ -105,12 +105,7 @@ private static async Task> ExecuteAsync( total = await query.CountAsync(cancellationToken).ConfigureAwait(false); } - var ordered = CursorOrder.Apply(query, sortKey, walkBackward); - if (values is not null) - { - ordered = ordered.Where(CursorSeek.Build(sortKey, values, walkBackward, options.Nulls)); - } - + var ordered = PreparePageQuery(query, sortKey, values, walkBackward, options.Nulls); var fetched = await ordered.Take(request.Limit + 1).ToListAsync(cancellationToken).ConfigureAwait(false); var ctx = CursorDbContext.TryGet(query); var keys = new List(fetched.Count); @@ -139,12 +134,7 @@ private static async Task> ExecuteProjectedAsync total = await query.CountAsync(cancellationToken).ConfigureAwait(false); } - var ordered = CursorOrder.Apply(query, sortKey, walkBackward); - if (values is not null) - { - ordered = ordered.Where(CursorSeek.Build(sortKey, values, walkBackward, options.Nulls)); - } - + var ordered = PreparePageQuery(query, sortKey, values, walkBackward, options.Nulls); var dtos = await ordered.Take(request.Limit + 1).Select(selector).ToListAsync(cancellationToken).ConfigureAwait(false); var projectedKey = new SortKey(sortKey.Slots); var keys = new List(dtos.Count); @@ -156,9 +146,49 @@ private static async Task> ExecuteProjectedAsync return PageAssembler.Assemble(dtos, keys, projectedKey, request.Limit, walkBackward, fromCursor, options, total); } - internal static string DebugQueryString(IQueryable query, SortKey sortKey, bool walkBackward, int? take = null) + private static IQueryable PreparePageQuery( + IQueryable query, + SortKey sortKey, + object?[]? values, + bool walkBackward, + NullOrder nulls) + { + var ordered = CursorOrder.Apply(query, sortKey, walkBackward) + .TagWith(PaginationNullsInterceptor.TagFor(nulls, walkBackward)); + if (values is not null) + { + ordered = ordered.Where(BuildSeek(query, sortKey, values, walkBackward, nulls)); + } + + return ordered; + } + + private static Expression> BuildSeek( + IQueryable query, + SortKey sortKey, + object?[] values, + bool walkBackward, + NullOrder nulls) + { + var ctx = CursorDbContext.TryGet(query); + if (CursorProvider.IsNpgsql(ctx) + && CursorSeekTuple.TryBuild(sortKey, values, walkBackward, out var tuple)) + { + return tuple; + } + + return CursorSeek.Build(sortKey, values, walkBackward, nulls); + } + + internal static string DebugQueryString( + IQueryable query, + SortKey sortKey, + bool walkBackward, + int? take = null, + NullOrder nulls = NullOrder.Last) { - IQueryable ordered = CursorOrder.Apply(query, sortKey, walkBackward); + IQueryable ordered = CursorOrder.Apply(query, sortKey, walkBackward) + .TagWith(PaginationNullsInterceptor.TagFor(nulls, walkBackward)); if (take is int n) { ordered = ordered.Take(n); @@ -176,7 +206,5 @@ internal static string DebugSeekQueryString( object?[] values, bool walkBackward, NullOrder nulls = NullOrder.Last) - => CursorOrder.Apply(query, sortKey, walkBackward) - .Where(CursorSeek.Build(sortKey, values, walkBackward, nulls)) - .ToQueryString(); + => PreparePageQuery(query, sortKey, values, walkBackward, nulls).ToQueryString(); } diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/KeysetIndexExtensions.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/KeysetIndexExtensions.cs index be37673..44c426c 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/KeysetIndexExtensions.cs +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/KeysetIndexExtensions.cs @@ -1,4 +1,5 @@ using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore.Infrastructure.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -26,6 +27,31 @@ public static IndexBuilder HasKeysetIndex( this EntityTypeBuilder builder, SortKey sortKey) where TEntity : class + => CreateKeysetIndex(builder, sortKey, nulls: null); + + /// + /// Same as , + /// and when the Npgsql provider is present, calls HasNullSortOrder so index NULLS + /// placement matches . Pass this overload only when you want that + /// metadata; the one-argument form does not change existing index null-sort annotations. + /// Npgsql omits NULLS from DDL when they match the column's ASC/DESC default. + /// + /// Entity type. + /// Entity builder. + /// Prebuilt sort key. + /// Null placement to store on the Npgsql index metadata. + public static IndexBuilder HasKeysetIndex( + this EntityTypeBuilder builder, + SortKey sortKey, + NullOrder nulls) + where TEntity : class + => CreateKeysetIndex(builder, sortKey, nulls); + + private static IndexBuilder CreateKeysetIndex( + EntityTypeBuilder builder, + SortKey sortKey, + NullOrder? nulls) + where TEntity : class { ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(sortKey); @@ -48,6 +74,11 @@ public static IndexBuilder HasKeysetIndex( index.IsDescending(descending); } + if (nulls is { } order) + { + NpgsqlIndexNullSort.TryApply(index, sortKey.Slots.Count, order); + } + return index; } diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/PaginationServiceCollectionExtensions.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/PaginationServiceCollectionExtensions.cs new file mode 100644 index 0000000..58f4e98 --- /dev/null +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Extensions/PaginationServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore; + +/// +/// DI and registration for pagination infrastructure +/// (ORDER BY NULLS FIRST/LAST command interceptor). +/// +/// +/// LINQ has no NULLS FIRST/LAST. The interceptor is the index-honest path on Npgsql +/// and Sqlite. Pair with +/// on every context. +/// +public static class PaginationServiceCollectionExtensions +{ + /// + /// Registers the shared NULLS FIRST/LAST interceptor singleton. Hosts must also call + /// on the context options + /// so EF attaches it. Without that, remains seek-predicate only for EF. + /// + /// Service collection. + /// The same collection. + public static IServiceCollection AddBuildingBlocksPagination(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(PaginationNullsInterceptor.Instance); + return services; + } + + /// + /// Adds the pagination NULLS FIRST/LAST interceptor to this context options builder. + /// Pair with . + /// + /// EF options. + /// The same builder. + public static DbContextOptionsBuilder UseBuildingBlocksPagination(this DbContextOptionsBuilder optionsBuilder) + { + ArgumentNullException.ThrowIfNull(optionsBuilder); + optionsBuilder.AddInterceptors(PaginationNullsInterceptor.Instance); + return optionsBuilder; + } + + /// + public static DbContextOptionsBuilder UseBuildingBlocksPagination( + this DbContextOptionsBuilder optionsBuilder) + where TContext : DbContext + { + ((DbContextOptionsBuilder)optionsBuilder).UseBuildingBlocksPagination(); + return optionsBuilder; + } +} diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/CursorProvider.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/CursorProvider.cs new file mode 100644 index 0000000..9f43dcc --- /dev/null +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/CursorProvider.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Infrastructure.Internal; + +/// Soft provider-name checks (no PackageReference on Npgsql/Sqlite/SqlServer). +internal static class CursorProvider +{ + internal const string Npgsql = "Npgsql.EntityFrameworkCore.PostgreSQL"; + internal const string Sqlite = "Microsoft.EntityFrameworkCore.Sqlite"; + internal const string SqlServer = "Microsoft.EntityFrameworkCore.SqlServer"; + + public static bool IsNpgsql(DbContext? ctx) + => ctx is not null + && string.Equals(ctx.Database.ProviderName, Npgsql, StringComparison.Ordinal); + + public static bool IsSqlite(DbContext? ctx) + => ctx is not null + && string.Equals(ctx.Database.ProviderName, Sqlite, StringComparison.Ordinal); + + public static bool SupportsOrderByNulls(DbContext? ctx) + => IsNpgsql(ctx) || IsSqlite(ctx); + + public static bool IsNpgsqlProviderName(string? providerName) + => string.Equals(providerName, Npgsql, StringComparison.Ordinal); +} diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/NpgsqlIndexNullSort.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/NpgsqlIndexNullSort.cs new file mode 100644 index 0000000..0770d88 --- /dev/null +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/NpgsqlIndexNullSort.cs @@ -0,0 +1,88 @@ +using System.Reflection; +using BuildingBlocks.Pagination; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Infrastructure.Internal; + +/// +/// Soft-calls Npgsql HasNullSortOrder when that assembly is already loaded. +/// Same resolution style as row-comparison seek (no PackageReference). +/// +internal static class NpgsqlIndexNullSort +{ + private static readonly object Gate = new(); + private static MethodInfo? _hasNullSortOrder; + private static Type? _nullSortOrderEnum; + + internal static void TryApply(IndexBuilder index, int columnCount, NullOrder nulls) + { + EnsureResolved(); + if (_hasNullSortOrder is null || _nullSortOrderEnum is null) + { + return; + } + + var enumName = nulls == NullOrder.First ? "NullsFirst" : "NullsLast"; + if (Enum.GetNames(_nullSortOrderEnum).All(n => n != enumName)) + { + return; + } + + var value = Enum.Parse(_nullSortOrderEnum, enumName); + var values = Array.CreateInstance(_nullSortOrderEnum, columnCount); + for (var i = 0; i < columnCount; i++) + { + values.SetValue(value, i); + } + + _hasNullSortOrder.Invoke(null, [index, values]); + } + + private static void EnsureResolved() + { + if (_hasNullSortOrder is not null && _nullSortOrderEnum is not null) + { + return; + } + + lock (Gate) + { + if (_hasNullSortOrder is not null && _nullSortOrderEnum is not null) + { + return; + } + + var extensions = Type.GetType( + "Microsoft.EntityFrameworkCore.NpgsqlIndexBuilderExtensions, " + CursorProvider.Npgsql, + throwOnError: false); + _nullSortOrderEnum = Type.GetType( + "Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NullSortOrder, " + CursorProvider.Npgsql, + throwOnError: false); + if (extensions is not null) + { + _hasNullSortOrder = FindHasNullSortOrder(extensions); + } + } + } + + private static MethodInfo? FindHasNullSortOrder(Type extensions) + { + foreach (var method in extensions.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.Name != "HasNullSortOrder" || method.IsGenericMethod) + { + continue; + } + + var parameters = method.GetParameters(); + if (parameters.Length == 2 + && parameters[0].ParameterType == typeof(IndexBuilder) + && parameters[1].ParameterType.IsArray) + { + return method; + } + } + + return null; + } +} diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/PaginationNullsInterceptor.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/PaginationNullsInterceptor.cs new file mode 100644 index 0000000..582d8ee --- /dev/null +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Infrastructure/Internal/PaginationNullsInterceptor.cs @@ -0,0 +1,160 @@ +using BuildingBlocks.Pagination; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Data.Common; +using System.Text; +using System.Text.RegularExpressions; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Infrastructure.Internal; + +/// +/// Rewrites ORDER BY on tagged pagination commands to append NULLS FIRST/LAST +/// for Npgsql and Sqlite. Null placement is encoded in the query tag +/// (BuildingBlocks.Pagination:First|Last). +/// +/// +/// Stock LINQ cannot emit SQL NULLS FIRST/LAST. Emitting CASE / IS NULL +/// in OrderBy would change null placement but typically prevents a matching btree +/// from being used. This interceptor only touches commands tagged by +/// ToCursorPageAsync, so host SQL is unchanged. ToQueryString does not run +/// interceptors — assert executed command text (or Dapper SQL) for NULLS, not the +/// LINQ string. SQL Server has no portable index-friendly NULLS syntax; we do not rewrite it. +/// +internal sealed class PaginationNullsInterceptor : DbCommandInterceptor +{ + internal const string QueryTag = "BuildingBlocks.Pagination"; + + internal static PaginationNullsInterceptor Instance { get; } = new(); + + private static readonly Regex OrderByClause = new( + @"\bORDER\s+BY\s+(?.+?)(?=\s+(?:LIMIT|OFFSET|FETCH|FOR)\b|\s*$)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline | RegexOptions.Compiled); + + private static readonly Regex TagNulls = new( + @"BuildingBlocks\.Pagination:(?First|Last)", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Rewrite(command, eventData); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Rewrite(command, eventData); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult ScalarExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Rewrite(command, eventData); + return base.ScalarExecuting(command, eventData, result); + } + + public override ValueTask> ScalarExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Rewrite(command, eventData); + return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); + } + + internal static string TagFor(NullOrder nulls, bool walkBackward = false) + { + var effective = walkBackward + ? (nulls == NullOrder.Last ? NullOrder.First : NullOrder.Last) + : nulls; + return QueryTag + ":" + (effective == NullOrder.First ? "First" : "Last"); + } + + private static void Rewrite(DbCommand command, CommandEventData eventData) + { + var sql = command.CommandText; + var match = TagNulls.Match(sql); + if (!match.Success) + { + return; + } + + var provider = eventData.Context?.Database.ProviderName; + if (!CursorProvider.IsNpgsqlProviderName(provider) + && !string.Equals(provider, CursorProvider.Sqlite, StringComparison.Ordinal)) + { + return; + } + + var suffix = match.Groups["nulls"].Value.Equals("First", StringComparison.Ordinal) + ? " NULLS FIRST" + : " NULLS LAST"; + command.CommandText = OrderByClause.Replace( + sql, + m => "ORDER BY " + RewriteOrderByBody(m.Groups["body"].Value, suffix), + count: 1); + } + + internal static string RewriteOrderByBody(string body, string nullsSuffix) + { + var parts = SplitOrderTerms(body); + var sb = new StringBuilder(body.Length + parts.Count * nullsSuffix.Length); + for (var i = 0; i < parts.Count; i++) + { + if (i > 0) + { + sb.Append(", "); + } + + var term = parts[i].Trim(); + if (term.Length == 0) + { + continue; + } + + sb.Append(term); + if (!term.Contains("NULLS", StringComparison.OrdinalIgnoreCase)) + { + sb.Append(nullsSuffix); + } + } + + return sb.ToString(); + } + + private static List SplitOrderTerms(string body) + { + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < body.Length; i++) + { + var c = body[i]; + if (c is '(') + { + depth++; + } + else if (c is ')') + { + depth = Math.Max(0, depth - 1); + } + else if (c is ',' && depth == 0) + { + parts.Add(body[start..i]); + start = i + 1; + } + } + + parts.Add(body[start..]); + return parts; + } +} diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md index 2e28151..99148b0 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PACKAGE_README.md @@ -12,13 +12,20 @@ Typed **keyset (cursor)** pagination for EF Core: `SortKey`, opaque versioned cu Dapper is an **in-repo project** (not a NuGet package). A LinqToDB adapter is not shipped. +## What's new in 1.1.0 + +- **Npgsql row comparison** for uniform non-nullable multi-column keys of **any width** (`(a, b, …) >`). 2–8 slots use `ValueTuple.Create`; 9+ nest `TRest`. Mixed ASC/DESC and `string` slots stay on the expanded OR seek. Sqlite and SQL Server always use OR. +- **`ORDER BY NULLS FIRST/LAST`** on Npgsql/Sqlite when the host registers `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`. Stock LINQ cannot emit NULLS; a tagged command interceptor is the index-honest path (do not `CASE`/`IS NULL` in `OrderBy`). Dapper emits NULLS on those dialects without extra registration. SQL Server does not emit `NULLS`. +- **`HasKeysetIndex(sortKey, NullOrder)`** — optional Npgsql `HasNullSortOrder` on the composite index (soft API). The one-argument `HasKeysetIndex(sortKey)` does not write null-sort metadata. Npgsql omits `NULLS` from `CREATE INDEX` when it matches the column's ASC/DESC default. + ## What you get - **Keyset (cursor) pagination** — seek SQL instead of `OFFSET` / `SKIP` - **Forward and backward** — `HasNext` / `HasPrevious`; empty cursor + `PageDirection.Backward` is the last page - **Typed composite `SortKey`** — expressions ending in a unique column; enum registry, no `"Price"` reflection - **EF Core** in this nupkg (`ToCursorPageAsync`); **Dapper** in-repo only (`QueryCursorPageAsync`, not packed) -- **`NullOrder` for strings** — seek-side null placement; does not emit SQL `NULLS FIRST/LAST`. Nullable value types (`int?`, `DateTime?`, …) are rejected +- **`NullOrder` for strings** — seek + `ORDER BY … NULLS FIRST/LAST` on Npgsql/Sqlite when the host registers `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`. Does not emit NULLS on SQL Server. Nullable value types (`int?`, `DateTime?`, …) are rejected +- **Npgsql row comparison** — uniform non-null keys of any width use `(a,b,…) > …` when Npgsql is loaded; otherwise OR-chain - **Optional total count** — `IncludeTotalCount` runs COUNT with the page (same QueryHint scope on SQL Server) - **Cancellation** — `CancellationToken` on EF and Dapper page APIs - **`QueryHint`** — allowlist `{ None, ReadUncommitted }`. `ReadUncommitted` is SQL Server session isolation only (not `WITH (NOLOCK)`); PG/Sqlite no-op @@ -51,10 +58,16 @@ Map `CursorPage` to your HTTP DTO (`HasNext` → `HasMore`, `Next` → `NextC ```csharp builder.HasKeysetIndex(priceKey).HasDatabaseName("IX_products_price_id"); builder.HasKeysetIndex(priceDescKey).HasDatabaseName("IX_products_price_id_desc"); +// Optional: match ORDER BY NULLS FIRST on an ASC string key (non-default for ASC). +builder.HasKeysetIndex(nameKey, NullOrder.First); ``` `(Price DESC, Id ASC)` is not a reverse scan of `(Price ASC, Id ASC)` — add both when you expose both directions. Nested paths (`Vendor.Name`) are not mapped; index those columns yourself. +**Npgsql multi-column seek:** when every sort slot is a non-nullable value type and directions are uniform, EF emits a Postgres row comparison `(a, b, …) > (@0, @1, …)` for **n columns** (same idea as the in-repo Dapper adapter). Mixed ASC/DESC, `string` keys, Sqlite, and SQL Server use the expanded OR form. + +**NULLS FIRST/LAST:** call `services.AddBuildingBlocksPagination()` and `options.UseBuildingBlocksPagination()` so EF appends `NULLS FIRST/LAST` on Npgsql/Sqlite at **execute** time (a `DbCommandInterceptor` on queries tagged `BuildingBlocks.Pagination:First|Last`). LINQ has no NULLS API; wrapping `OrderBy` in `CASE` would typically prevent a matching btree from being used. `ToQueryString()` does not run interceptors — assert executed SQL. Without registration, `NullOrder` is seek-predicate only for EF. Dapper always emits NULLS on PG/Sqlite from `PaginationOptions.Nulls`. SQL Server does not emit `NULLS`. + Empty cursor + `PageDirection.Backward` is the last page. ![First page, next cursor, previous cursor, last page via pageDirection=Backward.](https://raw.githubusercontent.com/Maxofpower/FeatureFusion/main/docs/medium/images/04b-cursor-flow.png) @@ -65,7 +78,7 @@ There is **no** `IEnumerable` / in-memory adapter. Relational providers execute - **HMAC on public HTTP.** Unsigned cursors are forgeable (`Walk` and key values). Set `PaginationOptions.SigningKey` for untrusted clients. Omit the key only for trusted internal callers. - **Host `OrderBy` is replaced**, not merged. Put filters/`AsNoTracking`/`TagWith` on the query; the sort comes from `SortKey`. -- **Nullable value types are unsupported.** `int?` / `DateTime?` / `bool?` / nullable enums throw `NullableSortUnsupported` at `SortKey` construction. Coalesce in the model. `NullOrder` does **not** emit SQL `NULLS FIRST/LAST` (provider `ORDER BY` nulls stay as-is). `string` remains allowed. +- **Nullable value types are unsupported.** `int?` / `DateTime?` / `bool?` / nullable enums throw `NullableSortUnsupported` at `SortKey` construction. Coalesce in the model. On PostgreSQL and Sqlite, `NullOrder` drives seek **and** `ORDER BY … NULLS FIRST/LAST` when the host registers `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination` (Dapper emits NULLS without that). SQL Server does not emit `NULLS`. `string` remains allowed. - **Guid vs SQL Server.** CLR `Guid` ordinal comparison matches SQLite/PostgreSQL. SQL Server `uniqueidentifier` order is different for some sets — do not assume CLR `>` equals SQL Server order. - **`QueryHint.ReadUncommitted`:** 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 READ UNCOMMITTED;` on both COUNT and page SQL, then restores `READ COMMITTED` on the open connection. PostgreSQL and Sqlite no-op. Host `WITH (NOLOCK)` in Dapper SQL is still allowed when `Hint` is `None`. - **Updates to a sort column** can make a row reappear or vanish (inherent keyset). Inserts after the cursor show up on later pages; that is expected. @@ -179,9 +192,9 @@ Methodology, competitor notes, and limitations: [benchmarks README](https://gith ## Layout (EF Core style) ```text -Extensions/ public ToCursorPageAsync (like EntityFrameworkQueryableExtensions) +Extensions/ public ToCursorPageAsync / HasKeysetIndex (like EntityFrameworkQueryableExtensions) Query/Internal/ OrderBy / seek expression trees -Infrastructure/Internal/ DbContext + shadow property access +Infrastructure/Internal/ DbContext, QueryHint, NULLS interceptor, soft Npgsql HasNullSortOrder ``` The IR (`SortKey`, `CursorCodec`, `CursorPage`) is a non-packable sibling project, bundled into this nupkg as `BuildingBlocks.Pagination.dll`. diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PublicAPI.Unshipped.txt b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PublicAPI.Unshipped.txt index bda435f..f89320b 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/PublicAPI.Unshipped.txt +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/PublicAPI.Unshipped.txt @@ -4,5 +4,12 @@ namespace BuildingBlocks.Pagination.EntityFrameworkCore public static partial class KeysetIndexExtensions { public static Microsoft.EntityFrameworkCore.Metadata.Builders.IndexBuilder HasKeysetIndex(this Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder, BuildingBlocks.Pagination.SortKey sortKey) where TEntity : class { throw null!; } + public static Microsoft.EntityFrameworkCore.Metadata.Builders.IndexBuilder HasKeysetIndex(this Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder builder, BuildingBlocks.Pagination.SortKey sortKey, BuildingBlocks.Pagination.NullOrder nulls) where TEntity : class { throw null!; } + } + public static partial class PaginationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddBuildingBlocksPagination(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null!; } + public static Microsoft.EntityFrameworkCore.DbContextOptionsBuilder UseBuildingBlocksPagination(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder optionsBuilder) { throw null!; } + public static Microsoft.EntityFrameworkCore.DbContextOptionsBuilder UseBuildingBlocksPagination(this Microsoft.EntityFrameworkCore.DbContextOptionsBuilder optionsBuilder) where TContext : Microsoft.EntityFrameworkCore.DbContext { throw null!; } } } diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeek.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeek.cs index 77f5dfa..1630a45 100644 --- a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeek.cs +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeek.cs @@ -72,15 +72,17 @@ private static Expression Compare( Expression nullCmp; if (nulls == NullOrder.Last) { + // Null is largest: row > cursor includes null rows when cursor is non-null. nullCmp = greater - ? Expression.AndAlso(Expression.Not(leftNull), rightNull) - : Expression.AndAlso(leftNull, Expression.Not(rightNull)); + ? Expression.AndAlso(leftNull, Expression.Not(rightNull)) + : Expression.AndAlso(Expression.Not(leftNull), rightNull); } else { + // Null is smallest: row > cursor includes non-null rows when cursor is null. nullCmp = greater - ? Expression.AndAlso(leftNull, Expression.Not(rightNull)) - : Expression.AndAlso(Expression.Not(leftNull), rightNull); + ? Expression.AndAlso(Expression.Not(leftNull), rightNull) + : Expression.AndAlso(leftNull, Expression.Not(rightNull)); } return Expression.OrElse(nonNullCmp, nullCmp); diff --git a/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeekTuple.cs b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeekTuple.cs new file mode 100644 index 0000000..a908ef0 --- /dev/null +++ b/src/BuildingBlocks/Pagination.EntityFrameworkCore/Query/Internal/CursorSeekTuple.cs @@ -0,0 +1,168 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using BuildingBlocks.Pagination; +using Microsoft.EntityFrameworkCore; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Query.Internal; + +/// +/// Builds Npgsql EF.Functions.GreaterThan/LessThan(ValueTuple, ValueTuple) seek predicates +/// when the provider can translate them to SQL row comparison. +/// +internal static class CursorSeekTuple +{ + private static readonly object Gate = new(); + private static MethodInfo? _greaterThan; + private static MethodInfo? _lessThan; + private static bool _resolved; + + public static bool TryBuild( + SortKey sortKey, + object?[] values, + bool walkBackward, + out Expression> predicate) + { + predicate = null!; + if (!CanUse(sortKey, walkBackward)) + { + return false; + } + + EnsureResolved(); + var method = SeekOps.UseGreater(sortKey.Slots[0], walkBackward) ? _greaterThan : _lessThan; + if (method is null) + { + return false; + } + + var parameter = Expression.Parameter(typeof(T), "x"); + var leftArgs = new Expression[sortKey.Slots.Count]; + var rightArgs = new Expression[sortKey.Slots.Count]; + for (var i = 0; i < sortKey.Slots.Count; i++) + { + var slot = sortKey.Slots[i]; + var body = CursorSlot.Body(parameter, slot); + if (body.Type != slot.DeclaredType) + { + body = Expression.Convert(body, slot.DeclaredType); + } + + leftArgs[i] = body; + rightArgs[i] = Constant(values[i], slot.DeclaredType); + } + + var leftTuple = Expression.Convert(CreateValueTuple(leftArgs), typeof(ITuple)); + var rightTuple = Expression.Convert(CreateValueTuple(rightArgs), typeof(ITuple)); + var call = Expression.Call( + method, + Expression.Property(null, typeof(EF), nameof(EF.Functions)), + leftTuple, + rightTuple); + + predicate = Expression.Lambda>(call, parameter); + return true; + } + + /// + /// Row comparison needs at least two slots. There is no upper bound: 2–8 use + /// ; 9+ nest the remainder as TRest (same shape as C# tuples). + /// One-column keys stay on the simple comparison in . + /// + public static bool CanUse(SortKey sortKey, bool walkBackward) + => sortKey.Slots.Count >= 2 + && SeekOps.TupleEligible(sortKey, walkBackward) + && SeekOps.TupleSlotsNonNull(sortKey); + + private static void EnsureResolved() + { + if (_resolved) + { + return; + } + + lock (Gate) + { + if (_resolved) + { + return; + } + + var type = Type.GetType( + "Microsoft.EntityFrameworkCore.NpgsqlDbFunctionsExtensions, Npgsql.EntityFrameworkCore.PostgreSQL", + throwOnError: false); + if (type is not null) + { + _greaterThan = FindCompare(type, "GreaterThan"); + _lessThan = FindCompare(type, "LessThan"); + } + + _resolved = true; + } + } + + private static MethodInfo? FindCompare(Type extensions, string name) + { + foreach (var method in extensions.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (method.Name != name) + { + continue; + } + + var parameters = method.GetParameters(); + if (parameters.Length != 3 + || parameters[0].ParameterType != typeof(DbFunctions) + || !typeof(ITuple).IsAssignableFrom(parameters[1].ParameterType) + || !typeof(ITuple).IsAssignableFrom(parameters[2].ParameterType)) + { + continue; + } + + return method; + } + + return null; + } + + private static Expression CreateValueTuple(Expression[] args) + { + if (args.Length is < 1) + { + throw new ArgumentOutOfRangeException(nameof(args)); + } + + if (args.Length <= 8) + { + var types = args.Select(a => a.Type).ToArray(); + var create = typeof(ValueTuple).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.Name == nameof(ValueTuple.Create) && m.IsGenericMethodDefinition) + .First(m => m.GetParameters().Length == args.Length) + .MakeGenericMethod(types); + return Expression.Call(create, args); + } + + // 9+: ValueTuple. Do not use Create(8) — it wraps the 8th argument in ValueTuple. + var rest = CreateValueTuple(args[7..]); + var typeArgs = args.Take(7).Select(a => a.Type).Append(rest.Type).ToArray(); + var tupleType = typeof(ValueTuple<,,,,,,,>).MakeGenericType(typeArgs); + var ctor = tupleType.GetConstructors().Single(c => c.GetParameters().Length == 8); + var ctorArgs = new Expression[8]; + Array.Copy(args, ctorArgs, 7); + ctorArgs[7] = rest; + return Expression.New(ctor, ctorArgs); + } + + private static Expression Constant(object? value, Type declared) + { + if (value is null) + { + return Expression.Constant(null, declared); + } + + var converted = declared.IsInstanceOfType(value) + ? value + : Convert.ChangeType(value, Nullable.GetUnderlyingType(declared) ?? declared); + return Expression.Constant(converted, declared); + } +} diff --git a/src/BuildingBlocks/Pagination/AGENTS.md b/src/BuildingBlocks/Pagination/AGENTS.md index 95d75d0..1508601 100644 --- a/src/BuildingBlocks/Pagination/AGENTS.md +++ b/src/BuildingBlocks/Pagination/AGENTS.md @@ -12,4 +12,6 @@ Consumers install `BuildingBlocks.Pagination.EntityFrameworkCore`. Dapper is a s - Hosts map `PaginationException.Code` to HTTP 400. - No `pageIndex`, no `Skip`/`OFFSET` API. - Sort a mapped scalar. Do not `By` a value object, `byte[]`, or navigation. +- Composite keys are n-column: codec and seek have no slot cap. EF Npgsql row comparison requires uniform directions and non-nullable value-type slots; otherwise OR-chain. +- `NullOrder` is seek + ORDER BY NULLS on PG/Sqlite (EF needs `AddBuildingBlocksPagination` + `UseBuildingBlocksPagination`). - Do not put `NOLOCK` / isolation on `PaginationOptions`. diff --git a/src/BuildingBlocks/Pagination/Internal/SeekOps.cs b/src/BuildingBlocks/Pagination/Internal/SeekOps.cs index d8d9228..4b1fdad 100644 --- a/src/BuildingBlocks/Pagination/Internal/SeekOps.cs +++ b/src/BuildingBlocks/Pagination/Internal/SeekOps.cs @@ -28,4 +28,23 @@ public static bool TupleEligible(SortKey key, bool walkBackward) return true; } + + /// + /// True when every slot is a non-nullable value type (no string / reference nullables). + /// Used by EF Npgsql row comparison for any slot count. Postgres tuple comparison with SQL NULL + /// is not equivalent to seek logic. + /// + public static bool TupleSlotsNonNull(SortKey key) + { + for (var i = 0; i < key.Slots.Count; i++) + { + var type = key.Slots[i].DeclaredType; + if (!type.IsValueType || Nullable.GetUnderlyingType(type) is not null) + { + return false; + } + } + + return true; + } } diff --git a/src/BuildingBlocks/Pagination/PaginationEnums.cs b/src/BuildingBlocks/Pagination/PaginationEnums.cs index ca82b13..81f9464 100644 --- a/src/BuildingBlocks/Pagination/PaginationEnums.cs +++ b/src/BuildingBlocks/Pagination/PaginationEnums.cs @@ -21,16 +21,20 @@ public enum PageDirection } /// -/// Seek-predicate null placement for string (and other reference) slots. -/// Does not emit SQL NULLS FIRST/LAST. Nullable value types -/// (int?, DateTime?, …) are rejected at construction. +/// Null placement for string (and other reference) sort slots. +/// Drives the seek predicate and, where supported, SQL ORDER BY … NULLS FIRST/LAST +/// (Dapper: PostgreSQL and Sqlite; EF: same providers when the host registers +/// AddBuildingBlocksPagination + UseBuildingBlocksPagination). +/// SQL Server does not emit NULLS (no portable index-friendly syntax). +/// Nullable value types (int?, DateTime?, …) are rejected at +/// construction. /// public enum NullOrder { - /// Nulls after non-nulls when seeking greater-than (default). + /// Nulls after non-nulls (default). Last = 0, - /// Nulls before non-nulls when seeking greater-than. + /// Nulls before non-nulls. First = 1 } diff --git a/src/BuildingBlocks/Pagination/PaginationOptions.cs b/src/BuildingBlocks/Pagination/PaginationOptions.cs index 2169026..41ea5dd 100644 --- a/src/BuildingBlocks/Pagination/PaginationOptions.cs +++ b/src/BuildingBlocks/Pagination/PaginationOptions.cs @@ -17,8 +17,10 @@ public sealed class PaginationOptions public byte[]? SigningKey { get; set; } /// - /// Seek-side null placement for reference types (default last). Does not change - /// ORDER BY nulls. Nullable value-type slots are rejected. + /// Null placement for reference-type sort slots (default last). Applies to the seek + /// predicate and to ORDER BY … NULLS FIRST/LAST on PostgreSQL and Sqlite + /// (EF requires AddBuildingBlocksPagination + UseBuildingBlocksPagination). + /// Nullable value-type slots are rejected. /// public NullOrder Nulls { get; set; } = NullOrder.Last; diff --git a/src/Lab/FeatureFusion/Features/Products/Queries/GetProductsQuery.cs b/src/Lab/FeatureFusion/Features/Products/Queries/GetProductsQuery.cs index fa5de4e..7abea41 100644 --- a/src/Lab/FeatureFusion/Features/Products/Queries/GetProductsQuery.cs +++ b/src/Lab/FeatureFusion/Features/Products/Queries/GetProductsQuery.cs @@ -18,7 +18,7 @@ public sealed record GetProductsQuery : IQuery>> [SwaggerParameter(Required = false, Description = "Opaque cursor from a previous response (NextCursor or PreviousCursor). Pass it back unchanged. Empty = first page, or last page when pageDirection is Backward.")] public string Cursor { get; init; } = string.Empty; - [SwaggerParameter(Description = "Sort field: Id, Name, Price, or CreatedAt. Composite keys always include unique Id as the tie-breaker.")] + [SwaggerParameter(Description = "Sort field: Id, Name, Price, CreatedAt, or NameThenPrice. Composite keys always include unique Id as the tie-breaker.")] public ProductSortField SortBy { get; init; } = ProductSortField.Id; [SwaggerParameter(Description = "Sort direction: Ascending or Descending.")] @@ -34,7 +34,9 @@ public enum ProductSortField Id, Name, Price, - CreatedAt + CreatedAt, + /// Composite: Name, then Price, then unique Id (3-column keyset showcase). + NameThenPrice } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs index 7b0a16c..711ab72 100644 --- a/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs +++ b/src/Lab/FeatureFusion/Infrastructure/EntitiyConfiguration/ProductEntityTypeConfiguration.cs @@ -49,5 +49,9 @@ public void Configure(EntityTypeBuilder builder) .HasDatabaseName("IX_products_name_id"); builder.HasKeysetIndex(ProductSortKeys.NameDesc) .HasDatabaseName("IX_products_name_id_desc"); + builder.HasKeysetIndex(ProductSortKeys.NameThenPriceAsc) + .HasDatabaseName("IX_products_name_price_id"); + builder.HasKeysetIndex(ProductSortKeys.NameThenPriceDesc) + .HasDatabaseName("IX_products_name_price_id_desc"); } } diff --git a/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs b/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs index 6bbe542..f1686ca 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Extensions/BuilderExtensions.cs @@ -2,6 +2,7 @@ using Asp.Versioning.ApiExplorer; using Asp.Versioning.Conventions; using BuildingBlocks.Idempotency.DependencyInjection; +using BuildingBlocks.Pagination.EntityFrameworkCore; using FeatureFusion.Features.Order.IntegrationEvents; using FeatureFusion.Features.Order.IntegrationEvents.EventHandling; using FeatureFusion.Features.Order.IntegrationEvents.Events; @@ -292,6 +293,7 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) builder.AddNpgsqlDbContext("catalogdb", configureDbContextOptions: dbContextOptionsBuilder => { + dbContextOptionsBuilder.UseBuildingBlocksPagination(); dbContextOptionsBuilder.UseNpgsql( npgsqlOptions => { @@ -303,6 +305,7 @@ public static void AddApplicationServices(this IHostApplicationBuilder builder) }); // Migrations must register before OutboxWorker / AppInitializer so // MigrationHostedService.StartAsync completes before they begin work. + builder.Services.AddBuildingBlocksPagination(); builder.Services.AddMigration(); // Resolve catalogdb from config at runtime (Aspire/WAF injects ConnectionStrings__catalogdb). diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904180000_ProductsNameThenPriceIndexes.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904180000_ProductsNameThenPriceIndexes.cs new file mode 100644 index 0000000..00c8c28 --- /dev/null +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/20260904180000_ProductsNameThenPriceIndexes.cs @@ -0,0 +1,40 @@ +using FeatureFusion.Infrastructure.Context; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FeatureFusion.Infrastructure.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260904180000_ProductsNameThenPriceIndexes")] + public partial class ProductsNameThenPriceIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_products_name_price_id", + table: "products", + columns: new[] { "Name", "Price", "Id" }); + + migrationBuilder.CreateIndex( + name: "IX_products_name_price_id_desc", + table: "products", + columns: new[] { "Name", "Price", "Id" }, + descending: new[] { true, true, true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_products_name_price_id", + table: "products"); + + migrationBuilder.DropIndex( + name: "IX_products_name_price_id_desc", + table: "products"); + } + } +} diff --git a/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs b/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs index 9e147e1..0c3a08d 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Migrations/CatalogDbContextModelSnapshot.cs @@ -261,6 +261,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsDescending(true, false) .HasDatabaseName("IX_products_price_id_desc"); + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_AAA") + .HasDatabaseName("IX_products_name_price_id"); + + b.HasIndex(new[] { "Name", "Price", "Id" }, "IX_Product_Name_Price_Id_DDD") + .IsDescending(true, true, true) + .HasDatabaseName("IX_products_name_price_id_desc"); + b.ToTable("products", (string)null); }); diff --git a/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs b/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs index d1acaeb..cd4dbd5 100644 --- a/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs +++ b/src/Lab/FeatureFusion/Infrastructure/Pagination/ProductSortKeys.cs @@ -35,11 +35,24 @@ public static class ProductSortKeys public static readonly SortKey CreatedAtDesc = SortKey.For().ByDescending(p => p.CreatedAt, sql: "CreatedAt").ThenByUnique(p => p.Id, sql: "Id"); + public static readonly SortKey NameThenPriceAsc = + SortKey.For() + .By(p => p.Name, sql: "Name") + .ThenBy(p => p.Price, sql: "Price") + .ThenByUnique(p => p.Id, sql: "Id"); + + public static readonly SortKey NameThenPriceDesc = + SortKey.For() + .ByDescending(p => p.Name, sql: "Name") + .ThenByDescending(p => p.Price, sql: "Price") + .ThenByUniqueDescending(p => p.Id, sql: "Id"); + public static readonly SortKeyRegistry Ascending = new SortKeyRegistry() .Add(ProductSortField.Id, IdAsc) .Add(ProductSortField.Name, NameAsc) .Add(ProductSortField.Price, PriceAsc) - .Add(ProductSortField.CreatedAt, CreatedAtAsc); + .Add(ProductSortField.CreatedAt, CreatedAtAsc) + .Add(ProductSortField.NameThenPrice, NameThenPriceAsc); public static SortKey Resolve(ProductSortField field, SortDirection direction) { @@ -50,6 +63,7 @@ public static SortKey Resolve(ProductSortField field, SortDirection dir (ProductSortField.Name, SortDirection.Descending) => NameDesc, (ProductSortField.Price, SortDirection.Descending) => PriceDesc, (ProductSortField.CreatedAt, SortDirection.Descending) => CreatedAtDesc, + (ProductSortField.NameThenPrice, SortDirection.Descending) => NameThenPriceDesc, _ => Ascending.Get(field) }; } diff --git a/tests/BuildingBlocks/Pagination.Dapper.Tests/DapperPagingTests.cs b/tests/BuildingBlocks/Pagination.Dapper.Tests/DapperPagingTests.cs index 7fd0423..d5b4231 100644 --- a/tests/BuildingBlocks/Pagination.Dapper.Tests/DapperPagingTests.cs +++ b/tests/BuildingBlocks/Pagination.Dapper.Tests/DapperPagingTests.cs @@ -173,6 +173,92 @@ public void Postgres_Tuple_Sql_When_Directions_Match() Assert.Contains("LIMIT @take", sql, StringComparison.Ordinal); } + [Fact] + public void Postgres_Tuple_Three_Column_Sql() + { + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByPriceCreatedAt, + FilterSql, + SqlDialect.PostgreSql, + [10d, CatalogSeed.T0.AddDays(1), 1], + walkBackward: false, + take: 6); + Assert.Contains("(\"Price\", \"CreatedAt\", \"Id\") > (@ks0, @ks1, @ks2)", sql, StringComparison.Ordinal); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + } + + [Fact] + public void Postgres_Tuple_Four_Column_Sql() + { + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByNamePriceCreatedAt, + FilterSql, + SqlDialect.PostgreSql, + ["Item-A", 10d, CatalogSeed.T0.AddDays(1), 1], + walkBackward: false, + take: 6); + // Name is a string slot — Dapper still emits tuple when directions match (host SQL assumes non-null). + Assert.Contains("(\"Name\", \"Price\", \"CreatedAt\", \"Id\") > (@ks0, @ks1, @ks2, @ks3)", sql, StringComparison.Ordinal); + } + + [Fact] + public void Postgres_Tuple_Nine_Column_Sql() + { + var first = CatalogSeed.Items[0]; + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByNineValueTypes, + FilterSql, + SqlDialect.PostgreSql, + [first.Price, first.CreatedAt, first.LongId, first.Kind, first.ExternalId, first.VendorId, first.Flag, first.Rank, first.Id], + walkBackward: false, + take: 6); + Assert.Contains("(\"Price\", \"CreatedAt\", \"LongId\", \"Kind\", \"ExternalId\", \"VendorId\", \"Flag\", \"Rank\", \"Id\") >", sql, StringComparison.Ordinal); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + } + + [Fact] + public void Postgres_OrderBy_Nulls_Last_In_Sql() + { + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByName, + FilterSql, + SqlDialect.PostgreSql, + values: null, + walkBackward: false, + take: 6, + new PaginationOptions { Nulls = NullOrder.Last }); + Assert.Contains("\"Name\" ASC NULLS LAST", sql, StringComparison.Ordinal); + Assert.Contains("\"Id\" ASC NULLS LAST", sql, StringComparison.Ordinal); + } + + [Fact] + public void Sqlite_OrderBy_Nulls_First_In_Sql() + { + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByName, + FilterSql, + SqlDialect.Sqlite, + values: null, + walkBackward: false, + take: 6, + new PaginationOptions { Nulls = NullOrder.First }); + Assert.Contains("\"Name\" ASC NULLS FIRST", sql, StringComparison.Ordinal); + } + + [Fact] + public void SqlServer_OrderBy_Has_No_Nulls_Keyword() + { + var sql = DapperCursorExtensions.BuildSql( + CatalogSeed.ByName, + FilterSql, + SqlDialect.SqlServer, + values: null, + walkBackward: false, + take: 6, + new PaginationOptions { Nulls = NullOrder.Last }); + Assert.DoesNotContain("NULLS", sql, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void Mixed_Direction_Is_Or_Not_Tuple() { diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj index 6c14de9..781fbb8 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/BuildingBlocks.Pagination.EntityFrameworkCore.Tests.csproj @@ -11,20 +11,26 @@ + + + + + + diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/EfPagingTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/EfPagingTests.cs index e5d61b7..fef16a6 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/EfPagingTests.cs +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/EfPagingTests.cs @@ -65,7 +65,9 @@ public async Task InitializeAsync() ExternalId = item.ExternalId, LongId = item.LongId, OptionalAt = item.OptionalAt, - VendorId = item.VendorId + VendorId = item.VendorId, + Flag = item.Flag, + Rank = item.Rank }); } diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/HasKeysetIndexNullSortEfTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/HasKeysetIndexNullSortEfTests.cs new file mode 100644 index 0000000..a3cd8ce --- /dev/null +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/HasKeysetIndexNullSortEfTests.cs @@ -0,0 +1,71 @@ +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using BuildingBlocks.Pagination.TestSupport; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Tests; + +/// +/// Model-only (no container). Asserts migration SQL, which is what HasNullSortOrder is for. +/// Separate context types so EF does not reuse a compiled model across the with/without cases. +/// +public sealed class HasKeysetIndexNullSortEfTests +{ + [Fact] + public void HasKeysetIndex_NullOrder_Emits_Npgsql_Nulls_In_Create_Script() + { + using var withNulls = new WithNullSortContext(); + using var withoutNulls = new WithoutNullSortContext(); + + var withScript = withNulls.Database.GenerateCreateScript(); + var withoutScript = withoutNulls.Database.GenerateCreateScript(); + + Assert.Contains(NullSortIndexModel.IndexName, withScript, StringComparison.Ordinal); + Assert.Contains("NULLS FIRST", withScript, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("NULLS FIRST", withoutScript, StringComparison.OrdinalIgnoreCase); + } +} + +file static class NullSortIndexModel +{ + public const string IndexName = "IX_catalog_price_id"; + + public static DbContextOptions Options() + where TContext : DbContext + => new DbContextOptionsBuilder() + .UseNpgsql("Host=127.0.0.1;Database=pagination_nullsort_smoke") + .Options; + + public static void Catalog(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(i => i.Id); + modelBuilder.Entity().Property(i => i.Id).ValueGeneratedNever(); + } +} + +file sealed class WithNullSortContext : DbContext +{ + public WithNullSortContext() : base(NullSortIndexModel.Options()) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + NullSortIndexModel.Catalog(modelBuilder); + modelBuilder.Entity() + .HasKeysetIndex(CatalogSeed.ByPrice, NullOrder.First) + .HasDatabaseName(NullSortIndexModel.IndexName); + } +} + +file sealed class WithoutNullSortContext : DbContext +{ + public WithoutNullSortContext() : base(NullSortIndexModel.Options()) { } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + NullSortIndexModel.Catalog(modelBuilder); + modelBuilder.Entity() + .HasKeysetIndex(CatalogSeed.ByPrice) + .HasDatabaseName(NullSortIndexModel.IndexName); + } +} diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MultiColumnEfTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MultiColumnEfTests.cs new file mode 100644 index 0000000..3c028bb --- /dev/null +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MultiColumnEfTests.cs @@ -0,0 +1,125 @@ +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using BuildingBlocks.Pagination.EntityFrameworkCore.Query.Internal; +using BuildingBlocks.Pagination.TestSupport; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Tests; + +[Collection("sqlite")] +public sealed class MultiColumnEfTests(SqliteFixture fixture) +{ + [Fact] + public async Task Three_Column_Forward_Next_Previous() + { + await using var db = fixture.CreateContext(); + var key = CatalogSeed.ByPriceCreatedAt; + var expected = CatalogSeed.Items + .OrderBy(i => i.Price) + .ThenBy(i => i.CreatedAt) + .ThenBy(i => i.Id) + .Select(i => i.Id) + .ToList(); + + var first = await db.Items.ToCursorPageAsync(new CursorRequest(null, 4), key); + Assert.Equal(expected.Take(4), first.Items.Select(i => i.Id)); + Assert.True(first.HasNext); + + var second = await db.Items.ToCursorPageAsync(new CursorRequest(first.Next, 4), key); + Assert.Equal(expected.Skip(4).Take(4), second.Items.Select(i => i.Id)); + Assert.Empty(first.Items.Select(i => i.Id).Intersect(second.Items.Select(i => i.Id))); + + var restored = await db.Items.ToCursorPageAsync(new CursorRequest(second.Previous, 4), key); + Assert.Equal(first.Items.Select(i => i.Id), restored.Items.Select(i => i.Id)); + } + + [Fact] + public async Task Four_Column_Forward_Next_No_Overlap() + { + await using var db = fixture.CreateContext(); + var key = CatalogSeed.ByNamePriceCreatedAt; + var expected = CatalogSeed.Items + .OrderBy(i => i.Name, StringComparer.Ordinal) + .ThenBy(i => i.Price) + .ThenBy(i => i.CreatedAt) + .ThenBy(i => i.Id) + .Select(i => i.Id) + .ToList(); + + var first = await db.Items.ToCursorPageAsync(new CursorRequest(null, 5), key); + Assert.Equal(expected.Take(5), first.Items.Select(i => i.Id)); + var second = await db.Items.ToCursorPageAsync(new CursorRequest(first.Next, 5), key); + Assert.Equal(expected.Skip(5).Take(5), second.Items.Select(i => i.Id)); + Assert.Empty(first.Items.Select(i => i.Id).Intersect(second.Items.Select(i => i.Id))); + } + + [Fact] + public async Task Three_Column_Seek_Query_Is_Or_Chain_On_Sqlite() + { + await using var db = fixture.CreateContext(); + var key = CatalogSeed.ByPriceCreatedAt; + var first = CatalogSeed.Items.OrderBy(i => i.Price).ThenBy(i => i.CreatedAt).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + key, + [first.Price, first.CreatedAt, first.Id], + walkBackward: false); + Assert.Contains(" OR ", sql, StringComparison.Ordinal); + Assert.Contains("BuildingBlocks.Pagination:", sql, StringComparison.Ordinal); + } + + [Fact] + public void Mixed_Direction_Three_Column_Not_Tuple_Eligible() + { + var key = SortKey.For() + .ByDescending(x => x.Price) + .ThenBy(x => x.CreatedAt) + .ThenByUnique(x => x.Id); + Assert.False(SeekOps.TupleEligible(key, walkBackward: false)); + Assert.True(SeekOps.TupleSlotsNonNull(key)); + } + + [Fact] + public void TupleSlotsNonNull_Rejects_String() + { + Assert.False(SeekOps.TupleSlotsNonNull(CatalogSeed.ByName)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByPrice)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByPriceCreatedAt)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByNineValueTypes)); + } + + [Fact] + public async Task Nine_Column_Forward_Next_No_Overlap() + { + await using var db = fixture.CreateContext(); + var key = CatalogSeed.ByNineValueTypes; + var expected = CatalogSeed.Items + .OrderBy(i => i.Price) + .ThenBy(i => i.CreatedAt) + .ThenBy(i => i.LongId) + .ThenBy(i => i.Kind) + .ThenBy(i => i.ExternalId) + .ThenBy(i => i.VendorId) + .ThenBy(i => i.Flag) + .ThenBy(i => i.Rank) + .ThenBy(i => i.Id) + .Select(i => i.Id) + .ToList(); + + var first = await db.Items.ToCursorPageAsync(new CursorRequest(null, 4), key); + Assert.Equal(expected.Take(4), first.Items.Select(i => i.Id)); + var second = await db.Items.ToCursorPageAsync(new CursorRequest(first.Next, 4), key); + Assert.Equal(expected.Skip(4).Take(4), second.Items.Select(i => i.Id)); + Assert.Empty(first.Items.Select(i => i.Id).Intersect(second.Items.Select(i => i.Id))); + } + + [Fact] + public void Nine_Column_Value_Types_Are_Tuple_Eligible() + { + Assert.True(SeekOps.TupleEligible(CatalogSeed.ByNineValueTypes, walkBackward: false)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByNineValueTypes)); + Assert.True(CursorSeekTuple.CanUse(CatalogSeed.ByNineValueTypes, walkBackward: false)); + Assert.Equal(9, CatalogSeed.ByNineValueTypes.Slots.Count); + } +} diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MutationNullCancelEfTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MutationNullCancelEfTests.cs index f461489..42abf10 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MutationNullCancelEfTests.cs +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/MutationNullCancelEfTests.cs @@ -66,34 +66,38 @@ public async Task SortColumn_DateTime_Raise_Vanishes_From_Next_Page() [Fact] public async Task Nullable_String_Forward_And_Backward_Last_And_First() { - await using var db = await SeedLabelsAsync(); + await using var db = await SeedLabelsAsync(useNullsInterceptor: true); var key = SortKey.For().By(x => x.Label).ThenByUnique(x => x.Id); - // Sqlite ASC places NULL first. NullOrder is seek-predicate only (no NULLS FIRST/LAST). + // With UseBuildingBlocksPagination, Sqlite ORDER BY honors Nulls. var lastFwd = await db.Labels.ToCursorPageAsync( new CursorRequest(null, 10), key, new PaginationOptions { Nulls = NullOrder.Last }); var firstFwd = await db.Labels.ToCursorPageAsync( new CursorRequest(null, 10), key, new PaginationOptions { Nulls = NullOrder.First }); - Assert.Equal(new[] { 2, 3, 1, 4 }, lastFwd.Items.Select(r => r.Id)); - Assert.Equal(lastFwd.Items.Select(r => r.Id), firstFwd.Items.Select(r => r.Id)); - Assert.Null(lastFwd.Items[0].Label); - Assert.Equal("", lastFwd.Items[1].Label); + Assert.Equal(new[] { 3, 1, 4, 2 }, lastFwd.Items.Select(r => r.Id)); + Assert.Equal(new[] { 2, 3, 1, 4 }, firstFwd.Items.Select(r => r.Id)); + Assert.Equal("", lastFwd.Items[0].Label); + Assert.Null(lastFwd.Items[^1].Label); + Assert.Null(firstFwd.Items[0].Label); var lastBack = await db.Labels.ToCursorPageAsync( new CursorRequest(null, 10, PageDirection.Backward), key, new PaginationOptions { Nulls = NullOrder.Last }); Assert.Equal(lastFwd.Items.Select(r => r.Id), lastBack.Items.Select(r => r.Id)); - var afterNull = await db.Labels.ToCursorPageAsync( + var afterEmpty = await db.Labels.ToCursorPageAsync( new CursorRequest(null, 1), key, new PaginationOptions { Nulls = NullOrder.Last }); - Assert.Equal(2, afterNull.Items[0].Id); + Assert.Equal(3, afterEmpty.Items[0].Id); var lastSeek = await db.Labels.ToCursorPageAsync( - new CursorRequest(afterNull.Next, 10), key, new PaginationOptions { Nulls = NullOrder.Last }); - Assert.Equal(new[] { 3, 1, 4 }, lastSeek.Items.Select(r => r.Id)); + new CursorRequest(afterEmpty.Next, 10), key, new PaginationOptions { Nulls = NullOrder.Last }); + Assert.Equal(new[] { 1, 4, 2 }, lastSeek.Items.Select(r => r.Id)); + var afterNull = await db.Labels.ToCursorPageAsync( + new CursorRequest(null, 1), key, new PaginationOptions { Nulls = NullOrder.First }); + Assert.Equal(2, afterNull.Items[0].Id); var firstSeek = await db.Labels.ToCursorPageAsync( new CursorRequest(afterNull.Next, 10), key, new PaginationOptions { Nulls = NullOrder.First }); - Assert.Empty(firstSeek.Items); + Assert.Equal(new[] { 3, 1, 4 }, firstSeek.Items.Select(r => r.Id)); var paged = await db.Labels.ToCursorPageAsync( new CursorRequest(null, 2), key, new PaginationOptions { Nulls = NullOrder.Last }); @@ -102,6 +106,34 @@ public async Task Nullable_String_Forward_And_Backward_Last_And_First() Assert.Empty(paged.Items.Select(r => r.Id).Intersect(next.Items.Select(r => r.Id))); } + [Fact] + public async Task Nullable_String_OrderBy_Emits_Nulls_Last_In_Sql() + { + await using var db = await SeedLabelsAsync(useNullsInterceptor: true); + var key = SortKey.For().By(x => x.Label).ThenByUnique(x => x.Id); + var sql = EntityFrameworkCursorExtensions.DebugQueryString( + db.Labels.AsQueryable(), key, walkBackward: false, take: 10, NullOrder.Last); + // Tag encodes NullOrder for the interceptor (BuildingBlocks.Pagination:Last). + Assert.Contains("BuildingBlocks.Pagination:Last", sql, StringComparison.Ordinal); + + var capture = new CaptureSqlInterceptor(); + await using var wired = await SeedLabelsAsync(useNullsInterceptor: true, extra: capture); + await wired.Labels.ToCursorPageAsync( + new CursorRequest(null, 10), key, new PaginationOptions { Nulls = NullOrder.Last }); + Assert.Contains(capture.Commands, c => c.Contains("NULLS LAST", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Nullable_String_Without_Interceptor_Keeps_Provider_Order() + { + await using var db = await SeedLabelsAsync(useNullsInterceptor: false); + var key = SortKey.For().By(x => x.Label).ThenByUnique(x => x.Id); + var page = await db.Labels.ToCursorPageAsync( + new CursorRequest(null, 10), key, new PaginationOptions { Nulls = NullOrder.Last }); + // Sqlite default ASC: NULL first even when Nulls = Last (seek-only without interceptor). + Assert.Equal(new[] { 2, 3, 1, 4 }, page.Items.Select(r => r.Id)); + } + [Fact] public async Task Cancellation_After_Command_Starts() { @@ -162,12 +194,24 @@ private static async Task SeedPriceAsync() return db; } - private static async Task SeedLabelsAsync() + private static async Task SeedLabelsAsync( + bool useNullsInterceptor, + DbCommandInterceptor? extra = null) { var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); - var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; - var db = new LabelContext(options); + var builder = new DbContextOptionsBuilder().UseSqlite(connection); + if (useNullsInterceptor) + { + builder.UseBuildingBlocksPagination(); + } + + if (extra is not null) + { + builder.AddInterceptors(extra); + } + + var db = new LabelContext(builder.Options); await db.Database.EnsureCreatedAsync(); db.Labels.AddRange( new LabelRow { Id = 1, Label = "alpha" }, @@ -198,6 +242,30 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } } +file sealed class CaptureSqlInterceptor : DbCommandInterceptor +{ + public List Commands { get; } = []; + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Commands.Add(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Commands.Add(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } +} + file sealed class CancelOnReaderInterceptor : DbCommandInterceptor { private readonly CancellationTokenSource _cts; diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/PostgresTupleEfTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/PostgresTupleEfTests.cs new file mode 100644 index 0000000..e5cb906 --- /dev/null +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/PostgresTupleEfTests.cs @@ -0,0 +1,350 @@ +using System.Diagnostics; +using BuildingBlocks.Pagination; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using BuildingBlocks.Pagination.TestSupport; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using System.Data.Common; +using Testcontainers.PostgreSql; +using Xunit; + +namespace BuildingBlocks.Pagination.EntityFrameworkCore.Tests; + +public sealed class PostgresFixture : IAsyncLifetime +{ + private PostgreSqlContainer? _container; + + public bool Ready { get; private set; } + + public string ConnectionString => _container?.GetConnectionString() + ?? throw new InvalidOperationException("PostgreSQL Testcontainer is not started."); + + /// GitHub Actions or an explicit required flag. Skips become failures. + public static bool IsRequiredEnvironment => + string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(Environment.GetEnvironmentVariable("PAGINATION_POSTGRES_REQUIRED"), "true", StringComparison.OrdinalIgnoreCase) + || Environment.GetEnvironmentVariable("PAGINATION_POSTGRES_REQUIRED") == "1"; + + public static bool CanRun => DockerAvailable(); + + public async Task InitializeAsync() + { + if (IsRequiredEnvironment && !DockerAvailable()) + { + throw new InvalidOperationException( + "PostgreSQL CI requires Docker so Testcontainers can start postgres:16-alpine. Skipped tests are a CI failure."); + } + + if (!CanRun) + { + Ready = false; + return; + } + + try + { + _container = new PostgreSqlBuilder() + .WithImage("postgres:16-alpine") + .Build(); + await _container.StartAsync(); + await using var db = CreateContextCore(); + await db.Database.EnsureCreatedAsync(); + db.Vendors.AddRange( + new Vendor { Id = 1, Name = "Vendor-X" }, + new Vendor { Id = 2, Name = "Vendor-Y" }, + new Vendor { Id = 3, Name = "Vendor-Z" }); + foreach (var item in CatalogSeed.Items) + { + db.Items.Add(new CatalogItem + { + Id = item.Id, + Name = item.Name, + Price = item.Price, + CreatedAt = item.CreatedAt, + Kind = item.Kind, + ExternalId = item.ExternalId, + LongId = item.LongId, + OptionalAt = item.OptionalAt, + VendorId = item.VendorId, + Flag = item.Flag, + Rank = item.Rank + }); + } + + await db.SaveChangesAsync(); + Ready = true; + } + catch (Exception) when (!IsRequiredEnvironment) + { + Ready = false; + } + } + + public async Task DisposeAsync() + { + if (Ready && _container is not null) + { + await _container.DisposeAsync(); + } + } + + public CatalogContext CreateContext(DbCommandInterceptor? extra = null) + { + if (!Ready) + { + throw new InvalidOperationException( + "PostgreSQL Testcontainer is not ready. Use [PostgresFact] so tests skip when Docker is unavailable."); + } + + return CreateContextCore(extra); + } + + private CatalogContext CreateContextCore(DbCommandInterceptor? extra = null) + { + var builder = new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString) + .UseBuildingBlocksPagination(); + if (extra is not null) + { + builder.AddInterceptors(extra); + } + + return new CatalogContext(builder.Options); + } + + private static bool DockerAvailable() + { + try + { + EnsureDockerOnPath(); + var psi = new ProcessStartInfo + { + FileName = "docker", + Arguments = "info", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = Process.Start(psi); + if (process is null) + { + return false; + } + + if (!process.WaitForExit(8000)) + { + try { process.Kill(entireProcessTree: true); } catch { /* ignore */ } + return false; + } + + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + private static void EnsureDockerOnPath() + { + const string dockerBin = @"C:\Program Files\Docker\Docker\resources\bin"; + if (OperatingSystem.IsWindows() && Directory.Exists(dockerBin)) + { + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + if (!path.Contains(dockerBin, StringComparison.OrdinalIgnoreCase)) + { + Environment.SetEnvironmentVariable("PATH", dockerBin + Path.PathSeparator + path); + } + } + } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class PostgresFactAttribute : FactAttribute +{ + public PostgresFactAttribute() + { + if (PostgresFixture.IsRequiredEnvironment) + { + return; + } + + if (!PostgresFixture.CanRun) + { + Skip = "PostgreSQL EF tests require Docker (Testcontainers)."; + } + } +} + +[CollectionDefinition("postgres")] +public sealed class PostgresCollection : ICollectionFixture; + +public sealed class PostgresCiGuardTests +{ + [Fact] + public void Ci_Requires_Postgres_Tuple_Tests_To_Execute() + { + if (!PostgresFixture.IsRequiredEnvironment) + { + return; + } + + Assert.True( + PostgresFixture.CanRun, + "PostgreSQL EF CI requires Docker so Testcontainers can start postgres:16-alpine. Skipped tests are a CI failure."); + } +} + +[Collection("postgres")] +public sealed class PostgresTupleEfTests(PostgresFixture fixture) +{ + [PostgresFact] + public void Postgres_Tuple_Seek_Query_Uses_Row_Comparison() + { + using var db = fixture.CreateContext(); + var first = CatalogSeed.Items.OrderBy(i => i.Price).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + CatalogSeed.ByPrice, + [first.Price, first.Id], + walkBackward: false); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + Assert.Contains(">", sql, StringComparison.Ordinal); + Assert.True( + sql.Contains("Price", StringComparison.OrdinalIgnoreCase) + && sql.Contains("Id", StringComparison.OrdinalIgnoreCase)); + Assert.True( + sql.Contains(">", StringComparison.Ordinal) || sql.Contains("GreaterThan", StringComparison.Ordinal)); + } + + [PostgresFact] + public void Postgres_Tuple_Four_Column_Seek_Uses_Row_Comparison() + { + using var db = fixture.CreateContext(); + var key = SortKey.For() + .By(x => x.Price) + .ThenBy(x => x.CreatedAt) + .ThenBy(x => x.LongId) + .ThenByUnique(x => x.Id); + var first = CatalogSeed.Items.OrderBy(i => i.Price).ThenBy(i => i.CreatedAt).ThenBy(i => i.LongId).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + key, + [first.Price, first.CreatedAt, first.LongId, first.Id], + walkBackward: false); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + Assert.True( + sql.Contains(">", StringComparison.Ordinal) || sql.Contains("GreaterThan", StringComparison.Ordinal), + "Expected Postgres row comparison (SQL > or LINQ GreaterThan). SQL: " + sql); + } + + [PostgresFact] + public void Postgres_Tuple_Three_Column_Seek_Uses_Row_Comparison() + { + using var db = fixture.CreateContext(); + var first = CatalogSeed.Items.OrderBy(i => i.Price).ThenBy(i => i.CreatedAt).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + CatalogSeed.ByPriceCreatedAt, + [first.Price, first.CreatedAt, first.Id], + walkBackward: false); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + Assert.True( + sql.Contains(">", StringComparison.Ordinal) || sql.Contains("GreaterThan", StringComparison.Ordinal), + "Expected Postgres row comparison for Price+CreatedAt+Id. SQL: " + sql); + Assert.True( + sql.Contains("Price", StringComparison.OrdinalIgnoreCase) + && sql.Contains("CreatedAt", StringComparison.OrdinalIgnoreCase) + && sql.Contains("Id", StringComparison.OrdinalIgnoreCase)); + } + + [PostgresFact] + public void Postgres_Tuple_Nine_Column_Seek_Uses_Row_Comparison() + { + using var db = fixture.CreateContext(); + var first = CatalogSeed.Items + .OrderBy(i => i.Price).ThenBy(i => i.CreatedAt).ThenBy(i => i.LongId).ThenBy(i => i.Kind) + .ThenBy(i => i.ExternalId).ThenBy(i => i.VendorId).ThenBy(i => i.Flag).ThenBy(i => i.Rank).ThenBy(i => i.Id) + .First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + CatalogSeed.ByNineValueTypes, + [first.Price, first.CreatedAt, first.LongId, first.Kind, first.ExternalId, first.VendorId, first.Flag, first.Rank, first.Id], + walkBackward: false); + Assert.DoesNotContain(" OR ", sql, StringComparison.Ordinal); + } + + [PostgresFact] + public void Postgres_Tuple_Skipped_When_Mixed_Direction() + { + using var db = fixture.CreateContext(); + var key = SortKey.For().ByDescending(x => x.Price).ThenByUnique(x => x.Id); + var first = CatalogSeed.Items.OrderByDescending(i => i.Price).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + key, + [first.Price, first.Id], + walkBackward: false); + Assert.Contains(" OR ", sql, StringComparison.Ordinal); + } + + [PostgresFact] + public void Postgres_Tuple_Skipped_When_String_Slot() + { + using var db = fixture.CreateContext(); + var first = CatalogSeed.Items.OrderBy(i => i.Name, StringComparer.Ordinal).ThenBy(i => i.Id).First(); + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Items.AsQueryable(), + CatalogSeed.ByName, + [first.Name, first.Id], + walkBackward: false); + Assert.Contains(" OR ", sql, StringComparison.Ordinal); + } + + [PostgresFact] + public async Task Postgres_Two_Column_Execute_Next_Page() + { + await using var db = fixture.CreateContext(); + var expected = CatalogSeed.Items.OrderBy(i => i.Price).ThenBy(i => i.Id).Select(i => i.Id).ToList(); + var first = await db.Items.ToCursorPageAsync(new CursorRequest(null, 4), CatalogSeed.ByPrice); + Assert.Equal(expected.Take(4), first.Items.Select(i => i.Id)); + var second = await db.Items.ToCursorPageAsync(new CursorRequest(first.Next, 4), CatalogSeed.ByPrice); + Assert.Equal(expected.Skip(4).Take(4), second.Items.Select(i => i.Id)); + } + + [PostgresFact] + public async Task Postgres_OrderBy_Nulls_Last_When_Interceptor_Registered() + { + var capture = new CaptureCommands(); + await using var db = fixture.CreateContext(capture); + var key = SortKey.For().By(x => x.Name).ThenByUnique(x => x.Id); + await db.Items.ToCursorPageAsync( + new CursorRequest(null, 5), key, new PaginationOptions { Nulls = NullOrder.Last }); + Assert.Contains(capture.Commands, c => c.Contains("NULLS LAST", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class CaptureCommands : DbCommandInterceptor + { + public List Commands { get; } = []; + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Commands.Add(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Commands.Add(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + } +} diff --git a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/TypesAndSeekEfTests.cs b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/TypesAndSeekEfTests.cs index cec3703..2d0a491 100644 --- a/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/TypesAndSeekEfTests.cs +++ b/tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests/TypesAndSeekEfTests.cs @@ -177,7 +177,9 @@ private static CatalogContext fixtureContext() ExternalId = item.ExternalId, LongId = item.LongId, OptionalAt = item.OptionalAt, - VendorId = item.VendorId + VendorId = item.VendorId, + Flag = item.Flag, + Rank = item.Rank }); } diff --git a/tests/BuildingBlocks/Pagination.TestSupport/CatalogItem.cs b/tests/BuildingBlocks/Pagination.TestSupport/CatalogItem.cs index d885e76..2f87eef 100644 --- a/tests/BuildingBlocks/Pagination.TestSupport/CatalogItem.cs +++ b/tests/BuildingBlocks/Pagination.TestSupport/CatalogItem.cs @@ -33,6 +33,10 @@ public sealed class CatalogItem public long LongId { get; set; } public DateTime? OptionalAt { get; set; } public int VendorId { get; set; } + /// Extra value-type slot for 8–9 column keyset tests. + public byte Flag { get; set; } + /// Extra value-type slot for 8–9 column keyset tests. + public short Rank { get; set; } public Vendor? Vendor { get; set; } } diff --git a/tests/BuildingBlocks/Pagination.TestSupport/CatalogSeed.cs b/tests/BuildingBlocks/Pagination.TestSupport/CatalogSeed.cs index b922012..7d0edd2 100644 --- a/tests/BuildingBlocks/Pagination.TestSupport/CatalogSeed.cs +++ b/tests/BuildingBlocks/Pagination.TestSupport/CatalogSeed.cs @@ -24,6 +24,32 @@ public static class CatalogSeed public static SortKey ByCreatedDescId { get; } = SortKey.For().ByDescending(x => x.CreatedAt, sql: "CreatedAt").ThenByUnique(x => x.Id, sql: "Id"); + public static SortKey ByPriceCreatedAt { get; } = + SortKey.For() + .By(x => x.Price, sql: "Price") + .ThenBy(x => x.CreatedAt, sql: "CreatedAt") + .ThenByUnique(x => x.Id, sql: "Id"); + + public static SortKey ByNamePriceCreatedAt { get; } = + SortKey.For() + .By(x => x.Name, sql: "Name") + .ThenBy(x => x.Price, sql: "Price") + .ThenBy(x => x.CreatedAt, sql: "CreatedAt") + .ThenByUnique(x => x.Id, sql: "Id"); + + /// Nine non-nullable value-type slots (Postgres row comparison + codec). + public static SortKey ByNineValueTypes { get; } = + SortKey.For() + .By(x => x.Price, sql: "Price") + .ThenBy(x => x.CreatedAt, sql: "CreatedAt") + .ThenBy(x => x.LongId, sql: "LongId") + .ThenBy(x => x.Kind, sql: "Kind") + .ThenBy(x => x.ExternalId, sql: "ExternalId") + .ThenBy(x => x.VendorId, sql: "VendorId") + .ThenBy(x => x.Flag, sql: "Flag") + .ThenBy(x => x.Rank, sql: "Rank") + .ThenByUnique(x => x.Id, sql: "Id"); + public static SortKeyRegistry Registry { get; } = new SortKeyRegistry() .Add(ItemSortField.Id, ById) .Add(ItemSortField.Name, ByName) @@ -71,6 +97,8 @@ private static List Create() LongId = 1000 + i, OptionalAt = i % 5 == 0 ? null : T0.AddHours(i), VendorId = (i % 3) + 1, + Flag = (byte)(i % 3), + Rank = (short)(i * 10), Vendor = new Vendor { Id = (i % 3) + 1, Name = "Vendor-" + (char)('X' + (i % 3)) } }); } diff --git a/tests/BuildingBlocks/Pagination.Tests/CodecAndRegistryTests.cs b/tests/BuildingBlocks/Pagination.Tests/CodecAndRegistryTests.cs index 89d2d24..0353221 100644 --- a/tests/BuildingBlocks/Pagination.Tests/CodecAndRegistryTests.cs +++ b/tests/BuildingBlocks/Pagination.Tests/CodecAndRegistryTests.cs @@ -1,3 +1,4 @@ +using BuildingBlocks.Pagination; using BuildingBlocks.Pagination.TestSupport; using Xunit; @@ -84,6 +85,82 @@ public void Codec_FromCursorValue_Bad_Json_Is_InvalidCursor() Assert.Equal(PaginationErrorCode.InvalidCursor, ex.Code); Assert.NotNull(ex.InnerException); } + [Fact] + public void Codec_Roundtrip_Three_And_Four_Column_Keys() + { + var options = PaginationOptions.Default; + var three = CursorCodec.Encode( + CatalogSeed.ByPriceCreatedAt, + [15d, CatalogSeed.T0.AddDays(2), 2], + PageDirection.Forward, + options); + var threeDecoded = CursorCodec.Decode(three, CatalogSeed.ByPriceCreatedAt, options); + Assert.Equal(3, threeDecoded.Values.Length); + Assert.Equal(15d, threeDecoded.Values[0]); + Assert.Equal(2, Convert.ToInt32(threeDecoded.Values[2])); + + var four = CursorCodec.Encode( + CatalogSeed.ByNamePriceCreatedAt, + ["Item-A", 10d, CatalogSeed.T0.AddDays(1), 1], + PageDirection.Backward, + options); + var fourDecoded = CursorCodec.Decode(four, CatalogSeed.ByNamePriceCreatedAt, options); + Assert.Equal(PageDirection.Backward, fourDecoded.Walk); + Assert.Equal(4, fourDecoded.Values.Length); + Assert.Equal("Item-A", fourDecoded.Values[0]); + } + + [Fact] + public void Codec_Roundtrip_Nine_Column_Key() + { + var first = CatalogSeed.Items[0]; + var encoded = CursorCodec.Encode( + CatalogSeed.ByNineValueTypes, + [first.Price, first.CreatedAt, first.LongId, first.Kind, first.ExternalId, first.VendorId, first.Flag, first.Rank, first.Id], + PageDirection.Forward, + PaginationOptions.Default); + var decoded = CursorCodec.Decode(encoded, CatalogSeed.ByNineValueTypes, PaginationOptions.Default); + Assert.Equal(9, decoded.Values.Length); + Assert.Equal(first.Id, Convert.ToInt32(decoded.Values[8])); + Assert.Equal(first.Flag, Convert.ToByte(decoded.Values[6])); + } +} + +public sealed class SeekOpsTests +{ + [Fact] + public void TupleEligible_True_For_Uniform_Directions() + { + Assert.True(SeekOps.TupleEligible(CatalogSeed.ByPrice, walkBackward: false)); + Assert.True(SeekOps.TupleEligible(CatalogSeed.ByPriceCreatedAt, walkBackward: false)); + Assert.True(SeekOps.TupleEligible( + SortKey.For().ByDescending(x => x.Price).ThenByUniqueDescending(x => x.Id), + walkBackward: false)); + } + + [Fact] + public void TupleEligible_False_For_Mixed_Directions() + { + // Price DESC + Id ASC (lab PriceDesc pattern) is mixed — not a single row comparison. + Assert.False(SeekOps.TupleEligible(CatalogSeed.ByPriceDesc, walkBackward: false)); + var mixed = SortKey.For() + .ByDescending(x => x.Price) + .ThenBy(x => x.CreatedAt) + .ThenByUnique(x => x.Id); + Assert.False(SeekOps.TupleEligible(mixed, walkBackward: false)); + } + + [Fact] + public void TupleSlotsNonNull_Matrix() + { + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByPrice)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByPriceCreatedAt)); + Assert.False(SeekOps.TupleSlotsNonNull(CatalogSeed.ByName)); + Assert.False(SeekOps.TupleSlotsNonNull(CatalogSeed.ByNamePriceCreatedAt)); + Assert.True(SeekOps.TupleSlotsNonNull(CatalogSeed.ByNineValueTypes)); + Assert.Equal(9, CatalogSeed.ByNineValueTypes.Slots.Count); + Assert.True(SeekOps.TupleEligible(CatalogSeed.ByNineValueTypes, walkBackward: false)); + } } public sealed class SortKeyAndRegistryTests diff --git a/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs b/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs index 42012f8..78525eb 100644 --- a/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs +++ b/tests/Lab/IntegrationTests/Api/FeatureFusionApiTests.cs @@ -3,9 +3,13 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using BuildingBlocks.Pagination.EntityFrameworkCore; +using FeatureFusion.Infrastructure.Context; +using FeatureFusion.Infrastructure.Pagination; using FluentAssertions; using IntegrationTests.Aspire; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; namespace IntegrationTests.Api; @@ -21,9 +25,11 @@ public sealed class FeatureFusionApiTests }; private readonly HttpClient _client; + private readonly AspireFixture _fixture; public FeatureFusionApiTests(AspireFixture fixture) { + _fixture = fixture; _client = fixture.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false @@ -136,6 +142,8 @@ public async Task Product_Products_Previous_Cursor_Returns_Earlier_Page() [InlineData("Price", "Descending")] [InlineData("CreatedAt", "Ascending")] [InlineData("CreatedAt", "Descending")] + [InlineData("NameThenPrice", "Ascending")] + [InlineData("NameThenPrice", "Descending")] public async Task Product_Products_First_Page_Each_Sort_Field(string sortBy, string sortDirection) { var page = await GetProductsAsync(limit: 5, sortBy: sortBy, sortDirection: sortDirection); @@ -158,6 +166,43 @@ public async Task Product_Products_Dapper_First_Page_Matches_Ef_Ids() dapper.TotalCount.Should().BeGreaterThan(5); } + [Fact] + public async Task Product_Products_NameThenPrice_Next_Page_No_Overlap() + { + var first = await GetProductsAsync(limit: 5, sortBy: "NameThenPrice", sortDirection: "Ascending"); + first.NextCursor.Should().NotBeNullOrWhiteSpace(); + + var second = await GetProductsAsync( + limit: 5, + cursor: first.NextCursor, + sortBy: "NameThenPrice", + sortDirection: "Ascending"); + + second.Items.Should().HaveCount(5); + second.Items.Select(i => i.Id).Should().NotIntersectWith(first.Items.Select(i => i.Id)); + second.HasPrevious.Should().BeTrue(); + } + + [Fact] + public async Task Product_Products_Price_Next_Page_Ef_Seek_Uses_Row_Comparison() + { + var first = await GetProductsAsync(limit: 5, sortBy: "Price", sortDirection: "Ascending"); + first.NextCursor.Should().NotBeNullOrWhiteSpace(); + + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var boundary = first.Items[^1]; + var sql = EntityFrameworkCursorExtensions.DebugSeekQueryString( + db.Product.AsQueryable(), + ProductSortKeys.PriceAsc, + [boundary.Price, boundary.Id], + walkBackward: false); + + sql.Should().NotContain(" OR ", because: "Npgsql should emit a row comparison for Price+Id ASC"); + sql.Should().ContainEquivalentOf("Price"); + sql.Should().ContainEquivalentOf("Id"); + } + [Fact] public async Task Product_Products_MinimalApi_Get_First_Page_Matches_Controller() { diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..e20331c --- /dev/null +++ b/web/README.md @@ -0,0 +1,15 @@ +# web/ + +Reserved **Next.js** project root for a FeatureFusion frontend showcase. This folder is **not** in `FeatureFusion.sln` (the solution stays .NET-only). + +The lab API, Aspire AppHost, and BuildingBlocks packages live under `src/`. This directory is the place to add a TypeScript UI later that calls the same HTTP surfaces (for example `GET /api/v2/products-page` keyset pagination). + +## Status + +Not scaffolded yet. Do not assume `package.json` or `npm` scripts exist. + +When an app is added here: + +1. Keep it out of the .NET solution unless there is a deliberate reason to bind them. +2. Point the UI at the Aspire-assigned FeatureFusion HTTP endpoint (dynamic ports locally). +3. Do not duplicate BuildingBlocks contracts in the client — consume the public HTTP/MCP APIs.