Skip to content

Commit 7549462

Browse files
authored
fix(runtime): sort nulls last ascending on every engine (#468)
* feat: list and report sorts put nulls last ascending, first descending, on every engine Nulls sort as larger than any value (Postgres's default) in all five ports. SQLite and MySQL sorted output changes for columns with nulls. * test: pin null ordering at each port's sort builder * test: report corpus now has 31 scenarios (Java OMDB lane) * no-mistakes(review): Skip null-order key for DISTINCT queries in OMDB * no-mistakes(test): Exclude test/fixtures/__tmp__ from cli typecheck * no-mistakes(document): Docs updated for null ordering. Fixed one stale claim in fixtures/persistence-conformance/README.md: it said engines disagree on null sort order and pointed nowhere. It now says nulls sort the same in every port and links to docs/features/api-contract.md#null-ordering. CHANGELOG.md, docs/features/api-contract.md, docs/features/reporting.md and fixtures/api-contract-conformance/report/README.md were already updated by the change and checked. Remaining docs that mention NULLS LAST (migrations-and-drift.md, view DDL) describe projection view DDL, which this change did not alter. Edit left uncommitted.
1 parent 0ec0e45 commit 7549462

39 files changed

Lines changed: 317 additions & 78 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@ here.**
2626
every port at this version. A model that does not use it is unchanged. See
2727
[docs/features/reporting.md](docs/features/reporting.md#calendar-aligned-windows-startof).
2828

29+
### Changed
30+
31+
- **A list or report sort puts nulls last ascending and first descending, on every engine, in
32+
all five ports.** Nulls sort as larger than any value, which is Postgres's own default, so
33+
**Postgres output does not change; SQLite and MySQL sorted output does** for any column with
34+
nulls: those engines used to put a null first ascending. It applies to every `?sort=` the
35+
generated routes and the runtime `ObjectManager` drivers emit (TypeScript, C#, Java, Kotlin,
36+
Python), using native `NULLS LAST` / `NULLS FIRST` where the engine has it and a leading
37+
`col IS NULL` key where it does not (MySQL). Pinned by the api-contract `report/` scenario
38+
`sort-null-ordering`; see [api-contract.md](docs/features/api-contract.md#null-ordering).
39+
2940
### Fixed
3041

3142
- **A relative date with months or years now lands on the same day on SQLite and D1 as on

‎docs/features/api-contract.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,16 @@ each handler they register instead; an `HTTPException` is rethrown to your
242242
(`fixtures/api-contract-conformance/scenarios/`), whose three arms cover the
243243
declared field, an explicit order beating it, and an undeclared field falling
244244
back to `asc`.
245+
- <a id="null-ordering"></a>**Null ordering — nulls sort as larger than any value, on every
246+
engine.** `sort=<field>:asc` puts null rows last and `sort=<field>:desc` puts them first, in
247+
every port (TypeScript, C#, Java, Kotlin, Python), on every engine each supports
248+
(Postgres, SQLite and D1, MySQL), for entity lists, projections and served reports alike. That
249+
is Postgres's own default; a bare `ORDER BY col` put nulls first ascending on SQLite and
250+
MySQL, so the same request used to answer a different order per engine. Each port now pins
251+
it: native `NULLS LAST` / `NULLS FIRST` where the engine has the clause, a leading
252+
`col IS NULL` key (or the ORM's equivalent) where it does not. Guarded cross-port by the
253+
`sort-null-ordering` scenario (`fixtures/api-contract-conformance/report/scenarios/`), an
254+
ascending and a descending sort over a column with a null row.
245255
- **Known limit — enums sort by stored value, not declared order.** A
246256
string-backed `field.enum` sorts alphabetically (`high` < `low` < `medium`
247257
< `urgent`), not in `@values` order; an int-backed (`@intValueMap`) enum

‎docs/features/reporting.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ rating with no ratings is not `0`; null is the honest answer there.
460460
**Filter and sort.** The default is the value. `?filter[revenue][eq]=0` matches the empty
461461
rows, `isNull=true` matches nothing and `isNull=false` every row. A sort on a defaulted
462462
measure is the same on every engine, since the empty rows sort as their default. A measure
463-
without one sorts its null rows where the engine puts them: Postgres last ascending, SQLite
464-
and MySQL first.
463+
without one has null rows, and they sort as larger than any value: last ascending, first
464+
descending, the same on every engine (see [Null ordering](api-contract.md#null-ordering)).
465465

466466
### Boolean measures: `any` and `all`
467467

‎examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
121121
}
122122

123123
if (typeof opts.query.sort === "string") {
124-
result.orderBy = parseSort(opts.query.sort, opts.table, opts.sortAllowlist);
124+
result.orderBy = parseSort(opts.query.sort, opts.table, opts.sortAllowlist, opts.dialect);
125125
}
126126

127127
if (typeof opts.query.search === "string" && opts.query.search !== "") {
@@ -421,7 +421,7 @@ function ensureArray(v: unknown, key: string): unknown[] {
421421
throw new FilterParseError("filter.invalid_value", `Expected array for "${key}".`, { field: key });
422422
}
423423

424-
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist): SQLWrapper[] {
424+
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist, dialect: SqlDialect | undefined): SQLWrapper[] {
425425
const colonIdx = spec.indexOf(":");
426426
const field = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
427427
if (!sortAllowlist[field]) {
@@ -458,5 +458,21 @@ function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist):
458458
);
459459
}
460460
const col = table[field];
461-
return [order === "asc" ? asc(col as any) : desc(col as any)];
461+
return nullsAsLargest(col as any, order, dialect);
462+
}
463+
464+
/**
465+
* Order a column with nulls as LARGER than any value: ASC puts them last, DESC first, on
466+
* every engine. That is Postgres's own default; SQLite and MySQL put nulls first on ASC, so
467+
* a bare `ORDER BY col` answered the same list in a different order per engine. SQLite
468+
* (3.30+, D1 included) and Postgres take `NULLS FIRST/LAST`; MySQL has no such clause, so it
469+
* gets a leading `col IS NULL` key (0 for a value, 1 for null).
470+
*/
471+
function nullsAsLargest(col: SQLWrapper, order: "asc" | "desc", dialect: SqlDialect | undefined): SQLWrapper[] {
472+
if (dialect === "mysql") {
473+
return order === "asc"
474+
? [asc(sql`(${col} IS NULL)`), asc(col)]
475+
: [desc(sql`(${col} IS NULL)`), desc(col)];
476+
}
477+
return [order === "asc" ? sql`${col} ASC NULLS LAST` : sql`${col} DESC NULLS FIRST`];
462478
}

‎examples/showcase/.metaobjects/.gen-state/.hashes.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"generated/csharp/SubscriberBlurbPayload.g.cs": "6b1ce5b5a4018c647586d86125718c4764ae5d6eeb94e9545e8006a7ec2b2b6f",
55
"generated/csharp/SubscriberFilterAllowlist.g.cs": "7428d47f6ccb64bb315266ee0564fa3803736623a34ab491435c042848c5d472",
66
"generated/csharp/SubscriberNames.g.cs": "91f6eba78657b31fceef7021c6ca6bd799524dcb5120acbc8fcbe18786787174",
7-
"generated/csharp/SubscriberRoutes.g.cs": "e520084727642d242609cbf82b3598d7c996224c3d0aef250bd26c7540ab4423",
7+
"generated/csharp/SubscriberRoutes.g.cs": "acb153e2aac648a487d78d6d5321c0f250451f47b691f988e3b99ff410bcfa06",
88
"generated/python/Subscriber.py": "9dd4c0acea47f99d1daf79930f9de2e400f30780ebce6c63f023be528a72b762",
99
"generated/python/SubscriberBlurbPayload.py": "a5ef8a08e63efbe0f73e39a21a368cff5150c68cc8beeea6e8aab0c755f8bbdd",
1010
"generated/python/__init__.py": "daa04cec586768866d0996c779f62073e6773fe63750ff668327991fac2fd0f5",

‎examples/showcase/codegen/runtime/drizzle-fastify/filter-parser.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function parseFilterParams(opts: ParseFilterOpts): ParseFilterResult {
121121
}
122122

123123
if (typeof opts.query.sort === "string") {
124-
result.orderBy = parseSort(opts.query.sort, opts.table, opts.sortAllowlist);
124+
result.orderBy = parseSort(opts.query.sort, opts.table, opts.sortAllowlist, opts.dialect);
125125
}
126126

127127
if (typeof opts.query.search === "string" && opts.query.search !== "") {
@@ -421,7 +421,7 @@ function ensureArray(v: unknown, key: string): unknown[] {
421421
throw new FilterParseError("filter.invalid_value", `Expected array for "${key}".`, { field: key });
422422
}
423423

424-
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist): SQLWrapper[] {
424+
function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist, dialect: SqlDialect | undefined): SQLWrapper[] {
425425
const colonIdx = spec.indexOf(":");
426426
const field = colonIdx === -1 ? spec : spec.slice(0, colonIdx);
427427
if (!sortAllowlist[field]) {
@@ -458,5 +458,21 @@ function parseSort(spec: string, table: AnyTable, sortAllowlist: SortAllowlist):
458458
);
459459
}
460460
const col = table[field];
461-
return [order === "asc" ? asc(col as any) : desc(col as any)];
461+
return nullsAsLargest(col as any, order, dialect);
462+
}
463+
464+
/**
465+
* Order a column with nulls as LARGER than any value: ASC puts them last, DESC first, on
466+
* every engine. That is Postgres's own default; SQLite and MySQL put nulls first on ASC, so
467+
* a bare `ORDER BY col` answered the same list in a different order per engine. SQLite
468+
* (3.30+, D1 included) and Postgres take `NULLS FIRST/LAST`; MySQL has no such clause, so it
469+
* gets a leading `col IS NULL` key (0 for a value, 1 for null).
470+
*/
471+
function nullsAsLargest(col: SQLWrapper, order: "asc" | "desc", dialect: SqlDialect | undefined): SQLWrapper[] {
472+
if (dialect === "mysql") {
473+
return order === "asc"
474+
? [asc(sql`(${col} IS NULL)`), asc(col)]
475+
: [desc(sql`(${col} IS NULL)`), desc(col)];
476+
}
477+
return [order === "asc" ? sql`${col} ASC NULLS LAST` : sql`${col} DESC NULLS FIRST`];
462478
}

‎examples/showcase/generated/csharp/SubscriberRoutes.g.cs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,10 @@ private static IOrderedQueryable<Subscriber> ApplySortSubscriber(IQueryable<Subs
197197
{
198198
return field switch
199199
{
200-
"Id" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Id")) : q.OrderBy(x => EF.Property<object>(x!, "Id")),
201-
"Email" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Email")) : q.OrderBy(x => EF.Property<object>(x!, "Email")),
202-
"Name" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Name")) : q.OrderBy(x => EF.Property<object>(x!, "Name")),
203-
"CreatedAt" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "CreatedAt")) : q.OrderBy(x => EF.Property<object>(x!, "CreatedAt")),
200+
"Id" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Id") == null).ThenByDescending(x => EF.Property<object>(x!, "Id")) : q.OrderBy(x => EF.Property<object>(x!, "Id") == null).ThenBy(x => EF.Property<object>(x!, "Id")),
201+
"Email" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Email") == null).ThenByDescending(x => EF.Property<object>(x!, "Email")) : q.OrderBy(x => EF.Property<object>(x!, "Email") == null).ThenBy(x => EF.Property<object>(x!, "Email")),
202+
"Name" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "Name") == null).ThenByDescending(x => EF.Property<object>(x!, "Name")) : q.OrderBy(x => EF.Property<object>(x!, "Name") == null).ThenBy(x => EF.Property<object>(x!, "Name")),
203+
"CreatedAt" => desc ? q.OrderByDescending(x => EF.Property<object>(x!, "CreatedAt") == null).ThenByDescending(x => EF.Property<object>(x!, "CreatedAt")) : q.OrderBy(x => EF.Property<object>(x!, "CreatedAt") == null).ThenBy(x => EF.Property<object>(x!, "CreatedAt")),
204204
_ => (IOrderedQueryable<Subscriber>)q.OrderBy(x => 0),
205205
};
206206
}

‎fixtures/api-contract-conformance/report/README.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,12 @@ object uses (`InvoicesByMonth` is `/api/invoices_by_months`).
3939
(`revenueCents`) is `null` with the key present, and the sum with `@default: 0`
4040
(`revenueOrZero`) is `0`. `?filter[sales][gt]=0` removes the empty row at request time.
4141
- **A defaulted measure filters and sorts as its default.** `?filter[revenueOrZero][eq]=0`
42-
matches the empty row, and `?sort=revenueOrZero:asc` puts it first on every engine. The
43-
corpus never sorts a measure without a `@default` across a null row: where a null sorts is
44-
the engine's choice (Postgres last ascending, SQLite and MySQL first).
42+
matches the empty row, and `?sort=revenueOrZero:asc` puts it first on every engine.
43+
- **Nulls sort as larger than any value** (`sort-null-ordering`): ascending puts them last,
44+
descending first, on every engine. `?sort=revenueCents:asc` ends on `Cinder`'s null row and
45+
`:desc` starts on it. That is Postgres's own default; SQLite and MySQL would put a null first
46+
ascending, so each port pins the order (`NULLS LAST` / `NULLS FIRST`, or a leading
47+
`col IS NULL` key where the engine has no such clause).
4548
- **No grid or form is generated for a report in any port.** The read route and its row type
4649
are the surface in every port; TypeScript, the one port with a client tier, also generates
4750
the list hook. The corpus gates the route, not the hook.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: report-sort-null-ordering
2+
description: >
3+
Nulls sort as larger than any value, on every engine: ascending puts them last, descending
4+
puts them first. Cinder has no sale, so its revenueCents (a sum with no @default) is null.
5+
That is Postgres's own default; SQLite and MySQL put a null first ascending, so each port
6+
lowers the sort to `NULLS LAST` / `NULLS FIRST`, or to a leading `col IS NULL` key where
7+
the engine has no such clause, and the same request answers the same order everywhere.
8+
requests:
9+
- id: asc-nulls-last
10+
method: GET
11+
path: /api/product_revenues?sort=revenueCents:asc
12+
expect:
13+
status: 200
14+
body:
15+
equals:
16+
- { productId: 2, productName: "Beacon", sales: 1, revenueCents: 4500, revenueOrZero: 4500 }
17+
- { productId: 1, productName: "Atlas", sales: 2, revenueCents: 30000, revenueOrZero: 30000 }
18+
- { productId: 3, productName: "Cinder", sales: 0, revenueCents: null, revenueOrZero: 0 }
19+
- id: desc-nulls-first
20+
method: GET
21+
path: /api/product_revenues?sort=revenueCents:desc
22+
expect:
23+
status: 200
24+
body:
25+
equals:
26+
- { productId: 3, productName: "Cinder", sales: 0, revenueCents: null, revenueOrZero: 0 }
27+
- { productId: 1, productName: "Atlas", sales: 2, revenueCents: 30000, revenueOrZero: 30000 }
28+
- { productId: 2, productName: "Beacon", sales: 1, revenueCents: 4500, revenueOrZero: 4500 }

‎fixtures/api-contract-conformance/report/scenarios/sort-on-boolean-measure.yaml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ description: >
33
A sort on a defaulted boolean measure is deterministic on every engine: false sorts
44
before true, and the product with no sale sorts as its default, false, never as a null
55
whose place the engine chooses. Atlas is the only true row, so it is first descending.
6-
The corpus never sorts `hasRefund` (no @default) across Cinder's null row.
6+
A sort across `hasRefund` (no @default), where Cinder's row is null, is pinned by
7+
sort-null-ordering.
78
requests:
89
- id: desc
910
method: GET

0 commit comments

Comments
 (0)