diff --git a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts index 06599cd8423a..e1f9eff04863 100644 --- a/packages/2-sql/4-lanes/relational-core/src/ast/types.ts +++ b/packages/2-sql/4-lanes/relational-core/src/ast/types.ts @@ -9,6 +9,9 @@ import type { AnyJsonValueProjection } from './json-value-projection'; export type Direction = 'asc' | 'desc'; +/** Where NULLs sort relative to non-NULL values in an ORDER BY item. Undefined leaves placement to the target's default, and those defaults disagree: PostgreSQL ranks NULLs highest (last under ASC), SQLite ranks them lowest (first under ASC). State it explicitly to sort the same way on both. */ +export type NullsPlacement = 'first' | 'last'; + export type BinaryOp = 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'like' | 'in' | 'notIn'; export type AggregateCountFn = 'count'; @@ -1081,33 +1084,42 @@ export class OrderByItem extends AstNode { readonly kind = 'order-by-item' as const; readonly expr: AnyExpression; readonly dir: Direction; + readonly nulls: NullsPlacement | undefined; - constructor(expr: AnyExpression, dir: Direction) { + constructor(expr: AnyExpression, dir: Direction, nulls?: NullsPlacement) { super(); this.expr = expr; this.dir = dir; + this.nulls = nulls; this.freeze(); } - static asc(expr: AnyExpression): OrderByItem { - return new OrderByItem(expr, 'asc'); + static asc(expr: AnyExpression, nulls?: NullsPlacement): OrderByItem { + return new OrderByItem(expr, 'asc', nulls); } - static desc(expr: AnyExpression): OrderByItem { - return new OrderByItem(expr, 'desc'); + static desc(expr: AnyExpression, nulls?: NullsPlacement): OrderByItem { + return new OrderByItem(expr, 'desc', nulls); } rewrite(rewriter: ExpressionRewriter): OrderByItem { - return new OrderByItem(this.expr.rewrite(rewriter), this.dir); + return new OrderByItem(this.expr.rewrite(rewriter), this.dir, this.nulls); } /** - * A new frozen item with the sort direction flipped and `expr` unchanged. + * A new frozen item with the sort order inverted and `expr` unchanged. * Integrations that own pagination (e.g. backward cursor pagination) use * this to reverse a user's sort order without reaching into the AST. + * + * An explicit NULL placement flips with the direction: inverting a total + * order has to move NULLs to the opposite end, or reversing a page would + * not read back as the mirror of the forward page. An absent placement + * stays absent, since each target's default already flips with `dir`. */ reverse(): OrderByItem { - return new OrderByItem(this.expr, this.dir === 'asc' ? 'desc' : 'asc'); + const dir = this.dir === 'asc' ? 'desc' : 'asc'; + if (this.nulls === undefined) return new OrderByItem(this.expr, dir); + return new OrderByItem(this.expr, dir, this.nulls === 'first' ? 'last' : 'first'); } } diff --git a/packages/2-sql/4-lanes/relational-core/test/ast/order.test.ts b/packages/2-sql/4-lanes/relational-core/test/ast/order.test.ts index 87d62a4e8caf..4dd6c4348a32 100644 --- a/packages/2-sql/4-lanes/relational-core/test/ast/order.test.ts +++ b/packages/2-sql/4-lanes/relational-core/test/ast/order.test.ts @@ -41,4 +41,45 @@ describe('ast/order', () => { expect(roundTrip.dir).toBe('desc'); expect(roundTrip.expr).toBe(desc.expr); }); + + it('leaves nulls undefined when no placement is given', () => { + expect(OrderByItem.asc(col('user', 'id')).nulls).toBeUndefined(); + expect(new OrderByItem(col('user', 'id'), 'desc').nulls).toBeUndefined(); + }); + + it('carries an explicit nulls placement', () => { + expect(OrderByItem.asc(col('user', 'id'), 'first').nulls).toBe('first'); + expect(OrderByItem.desc(col('user', 'id'), 'last').nulls).toBe('last'); + expect(new OrderByItem(col('user', 'id'), 'asc', 'last').nulls).toBe('last'); + }); + + it('preserves nulls placement across a rewrite', () => { + const rewritten = OrderByItem.desc(col('post', 'title'), 'last').rewrite({ + columnRef: (expr) => col('article', expr.column), + }); + + expect(rewritten.expr).toEqual(col('article', 'title')); + expect(rewritten.dir).toBe('desc'); + expect(rewritten.nulls).toBe('last'); + }); + + it('flips nulls placement alongside direction on reverse, inverting the total order', () => { + const reversed = OrderByItem.desc(col('user', 'id'), 'last').reverse(); + + expect(reversed.dir).toBe('asc'); + expect(reversed.nulls).toBe('first'); + expect(Object.isFrozen(reversed)).toBe(true); + }); + + it('leaves nulls undefined on reverse when no placement was set', () => { + expect(OrderByItem.asc(col('user', 'id')).reverse().nulls).toBeUndefined(); + }); + + it('round-trips a double reverse back to the original nulls placement', () => { + const item = OrderByItem.asc(col('post', 'title'), 'first'); + const roundTrip = item.reverse().reverse(); + + expect(roundTrip.dir).toBe('asc'); + expect(roundTrip.nulls).toBe('first'); + }); }); diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts index a5c51413a7ca..f8a174d86470 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/builder-base.ts @@ -377,7 +377,7 @@ export function resolveOrderBy( }, ); const expr = IdentifierRef.of(arg); - return dir === 'asc' ? OrderByItem.asc(expr) : OrderByItem.desc(expr); + return new OrderByItem(expr, dir, options?.nulls); } if (typeof arg === 'function') { @@ -386,7 +386,7 @@ export function resolveOrderBy( ? createAggregateFunctions(ctx.queryOperationTypes, ctx.rawCodecInferer, ctx.aggregates) : createFunctions(ctx.queryOperationTypes, ctx.rawCodecInferer); const result = (arg as ExprCallback)(createFieldProxy(combined), fns); - return dir === 'asc' ? OrderByItem.asc(result.buildAst()) : OrderByItem.desc(result.buildAst()); + return new OrderByItem(result.buildAst(), dir, options?.nulls); } throw structuredError('ORM.ARGUMENT_INVALID', 'Invalid orderBy argument'); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts index e5dcdd1b8757..ff62ca8b738d 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts @@ -297,6 +297,29 @@ describe('orderBy', () => { ); expect(ast.orderBy).toHaveLength(2); }); + + it('nulls option reaches the AST for the string overload', () => { + const ast = getAst( + db().public.users.select('id', 'name').orderBy('name', { direction: 'desc', nulls: 'last' }), + ); + expect(ast.orderBy![0]!.dir).toBe('desc'); + expect(ast.orderBy![0]!.nulls).toBe('last'); + }); + + it('nulls option reaches the AST for the expression-callback overload', () => { + const ast = getAst( + db() + .public.users.select('id') + .orderBy((f) => f.id, { nulls: 'first' }), + ); + expect(ast.orderBy![0]!.dir).toBe('asc'); + expect(ast.orderBy![0]!.nulls).toBe('first'); + }); + + it('omitting nulls leaves placement to the target default', () => { + const ast = getAst(db().public.users.select('id').orderBy('id', { direction: 'desc' })); + expect(ast.orderBy![0]!.nulls).toBeUndefined(); + }); }); describe('groupBy and having', () => { diff --git a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts index 24579d30b616..ed1ff8454158 100644 --- a/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts +++ b/packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts @@ -222,7 +222,7 @@ function renderSelect(ast: SelectAst, contract: PostgresContract, pim: ParamInde ? `ORDER BY ${ast.orderBy .map((order) => { const expr = renderOrderByExpr(order.expr, sourcesByRef, contract, pim); - return `${expr} ${order.dir.toUpperCase()}`; + return `${expr} ${order.dir.toUpperCase()}${renderNullsPlacement(order)}`; }) .join(', ')}` : ''; @@ -750,13 +750,21 @@ function renderJsonObjectExpr( return `json_build_object(${args})`; } +/** The `NULLS FIRST` / `NULLS LAST` suffix for an ORDER BY item, or empty when the item leaves NULL placement to PostgreSQL's default for the sort direction. */ +function renderNullsPlacement(item: OrderByItem): string { + return item.nulls === undefined ? '' : ` NULLS ${item.nulls.toUpperCase()}`; +} + function renderOrderByItems( items: ReadonlyArray, contract: PostgresContract, pim: ParamIndexMap, ): string { return items - .map((item) => `${renderExpr(item.expr, contract, pim)} ${item.dir.toUpperCase()}`) + .map( + (item) => + `${renderExpr(item.expr, contract, pim)} ${item.dir.toUpperCase()}${renderNullsPlacement(item)}`, + ) .join(', '); } diff --git a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts index 2bb836faec9b..df707d57d58b 100644 --- a/packages/3-targets/6-adapters/postgres/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/postgres/test/adapter.test.ts @@ -375,6 +375,54 @@ describe('Postgres adapter', () => { ); }); + it('renders NULLS LAST for an ORDER BY item carrying a nulls placement', () => { + const ast = SelectAst.from(TableSource.named('post')) + .withProjection([ProjectionItem.of('title', ColumnRef.of('post', 'title'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('post', 'title'), 'desc', 'last')]); + + const sql = adapter.lower(ast, { contract, params: [] }).sql; + expect(sql).toBe( + 'SELECT "post"."title" AS "title" FROM "post" ORDER BY "post"."title" DESC NULLS LAST', + ); + }); + + it('renders NULLS FIRST for an ORDER BY item carrying a nulls placement', () => { + const ast = SelectAst.from(TableSource.named('post')) + .withProjection([ProjectionItem.of('title', ColumnRef.of('post', 'title'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('post', 'title'), 'asc', 'first')]); + + const sql = adapter.lower(ast, { contract, params: [] }).sql; + expect(sql).toBe( + 'SELECT "post"."title" AS "title" FROM "post" ORDER BY "post"."title" ASC NULLS FIRST', + ); + }); + + it('omits the NULLS clause when an ORDER BY item carries no placement', () => { + const ast = SelectAst.from(TableSource.named('post')) + .withProjection([ProjectionItem.of('title', ColumnRef.of('post', 'title'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('post', 'title'), 'desc')]); + + const sql = adapter.lower(ast, { contract, params: [] }).sql; + expect(sql).toBe('SELECT "post"."title" AS "title" FROM "post" ORDER BY "post"."title" DESC'); + }); + + it('renders a nulls placement inside a window function ORDER BY', () => { + const ast = SelectAst.from(TableSource.named('post')).withProjection([ + ProjectionItem.of( + 'rn', + WindowFuncExpr.rowNumber({ + partitionBy: [ColumnRef.of('post', 'title')], + orderBy: [new OrderByItem(ColumnRef.of('post', 'views'), 'desc', 'last')], + }), + ), + ]); + + const sql = adapter.lower(ast, { contract, params: [] }).sql; + expect(sql).toBe( + 'SELECT ROW_NUMBER() OVER (PARTITION BY "post"."title" ORDER BY "post"."views" DESC NULLS LAST) AS "rn" FROM "post"', + ); + }); + it('renders ROW_NUMBER() OVER (PARTITION BY … ORDER BY …)', () => { const ast = SelectAst.from(TableSource.named('post')).withProjection([ ProjectionItem.of('title', ColumnRef.of('post', 'title')), diff --git a/packages/3-targets/6-adapters/postgres/test/migrations/order-by-nulls.integration.test.ts b/packages/3-targets/6-adapters/postgres/test/migrations/order-by-nulls.integration.test.ts new file mode 100644 index 000000000000..a9489b71cf1e --- /dev/null +++ b/packages/3-targets/6-adapters/postgres/test/migrations/order-by-nulls.integration.test.ts @@ -0,0 +1,171 @@ +import type { Contract } from '@internal/contract/types'; +import { INIT_ADDITIVE_POLICY } from '@internal/family-sql/control'; +import sqlFamilyPack from '@internal/family-sql/pack'; +import { APP_SPACE_ID } from '@internal/framework-components/control'; +import type { SqlStorage } from '@internal/sql-contract/types'; +import { buildBoundContract } from '@internal/sql-contract-ts/contract-builder'; +import { + ColumnRef, + OrderByItem, + ProjectionItem, + SelectAst, + TableSource, +} from '@internal/sql-relational-core/ast'; +import postgresPack from '@internal/target-postgres/pack'; +import { postgresCreateNamespace } from '@internal/target-postgres/types'; +import { timeouts } from '@repo/test-utils'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createPostgresAdapter } from '../../src/core/adapter'; +import type { PostgresContract } from '../../src/core/types'; +import { + controlAdapter, + createDriver, + createTestDatabase, + emptySchema, + familyInstance, + formatRunnerFailure, + frameworkComponents, + type PostgresControlDriver, + postgresTargetDescriptor, + resetDatabase, + synthEdges, +} from './fixtures/runner-fixtures'; + +function makeProbeContract(): PostgresContract { + return buildBoundContract( + sqlFamilyPack, + postgresPack, + { createNamespace: postgresCreateNamespace }, + ({ field: f, model: m }) => ({ + models: { + Probe: m('Probe', { + fields: { + id: f.text().id(), + nullable: f.text().optional(), + }, + }), + }, + }), + ) as Contract as PostgresContract; +} + +async function migrate(driver: PostgresControlDriver, contract: PostgresContract): Promise { + const planner = postgresTargetDescriptor.createPlanner(controlAdapter); + const runner = postgresTargetDescriptor.createRunner(familyInstance); + const result = planner.plan({ + contract, + schema: emptySchema, + policy: INIT_ADDITIVE_POLICY, + fromContract: null, + frameworkComponents, + spaceId: APP_SPACE_ID, + snapshotsImportPath: '../../snapshots', + }); + if (result.kind !== 'success') { + throw new Error(`Planner failed: ${JSON.stringify(result, null, 2)}`); + } + const executeResult = await runner.execute({ + driver, + perSpaceOptions: [ + { + space: result.plan.spaceId ?? APP_SPACE_ID, + plan: result.plan, + migrationEdges: synthEdges(result.plan), + driver, + destinationContract: contract, + policy: INIT_ADDITIVE_POLICY, + frameworkComponents, + }, + ], + }); + if (!executeResult.ok) { + throw new Error(`Runner failed:\n${formatRunnerFailure(executeResult.failure)}`); + } +} + +function probeOrderedBy(nulls: 'first' | 'last' | undefined, dir: 'asc' | 'desc'): SelectAst { + return SelectAst.from(TableSource.named('Probe', undefined, 'public')) + .withProjection([ProjectionItem.of('nullable', ColumnRef.of('Probe', 'nullable'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('Probe', 'nullable'), dir, nulls)]); +} + +describe('ORDER BY NULLS placement — PGlite', { concurrent: false }, () => { + let database: Awaited>; + let driver: PostgresControlDriver | undefined; + + beforeAll(async () => { + database = await createTestDatabase(); + }, timeouts.spinUpPpgDev); + + afterAll(async () => { + if (database) { + await database.close(); + } + }, timeouts.spinUpPpgDev); + + beforeEach(async () => { + driver = await createDriver(database.connectionString); + await resetDatabase(driver); + await migrate(driver, makeProbeContract()); + await driver.query(`INSERT INTO "Probe" (id, nullable) VALUES + ('1', 'b'), ('2', NULL), ('3', 'a'), ('4', NULL), ('5', 'c')`); + }, timeouts.spinUpPpgDev); + + afterEach(async () => { + if (driver) { + await driver.close(); + driver = undefined; + } + }, timeouts.spinUpPpgDev); + + async function orderedValues(ast: SelectAst): Promise> { + const contract = makeProbeContract(); + const lowered = createPostgresAdapter().lower(ast, { contract }); + const result = await driver!.query<{ nullable: string | null }>(lowered.sql); + return result.rows.map((row) => row.nullable); + } + + it('sorts NULLs last on a descending order, overriding the PostgreSQL default', { + timeout: timeouts.spinUpPpgDev, + }, async () => { + expect(await orderedValues(probeOrderedBy('last', 'desc'))).toEqual([ + 'c', + 'b', + 'a', + null, + null, + ]); + }); + + it('sorts NULLs first on an ascending order, overriding the PostgreSQL default', { + timeout: timeouts.spinUpPpgDev, + }, async () => { + expect(await orderedValues(probeOrderedBy('first', 'asc'))).toEqual([ + null, + null, + 'a', + 'b', + 'c', + ]); + }); + + it('keeps the PostgreSQL default placement when no nulls option is given', { + timeout: timeouts.spinUpPpgDev, + }, async () => { + // PostgreSQL ranks NULLs highest: last under ASC, first under DESC. + expect(await orderedValues(probeOrderedBy(undefined, 'asc'))).toEqual([ + 'a', + 'b', + 'c', + null, + null, + ]); + expect(await orderedValues(probeOrderedBy(undefined, 'desc'))).toEqual([ + null, + null, + 'c', + 'b', + 'a', + ]); + }); +}); diff --git a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts index 81f2cb537219..c0c790b5d0d5 100644 --- a/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts +++ b/packages/3-targets/6-adapters/sqlite/src/core/adapter.ts @@ -248,7 +248,10 @@ function renderSelect(ast: SelectAst, ctx: SqliteRenderContext): string { const havingClause = ast.having ? `HAVING ${renderExpr(ast.having, ctx)}` : ''; const orderClause = ast.orderBy?.length ? `ORDER BY ${ast.orderBy - .map((order) => `${renderExpr(order.expr, ctx)} ${order.dir.toUpperCase()}`) + .map( + (order) => + `${renderExpr(order.expr, ctx)} ${order.dir.toUpperCase()}${renderNullsPlacement(order)}`, + ) .join(', ')}` : ''; // SQLite has no standalone OFFSET clause, so an offset with no limit needs an explicit LIMIT -1. @@ -684,8 +687,18 @@ function renderJsonObjectExpr(expr: JsonObjectExpr, ctx: SqliteRenderContext): s return `json_object(${args})`; } +/** The `NULLS FIRST` / `NULLS LAST` suffix for an ORDER BY item, or empty when the item leaves NULL placement to SQLite's default for the sort direction. */ +function renderNullsPlacement(item: OrderByItem): string { + return item.nulls === undefined ? '' : ` NULLS ${item.nulls.toUpperCase()}`; +} + function renderOrderByItems(items: ReadonlyArray, ctx: SqliteRenderContext): string { - return items.map((item) => `${renderExpr(item.expr, ctx)} ${item.dir.toUpperCase()}`).join(', '); + return items + .map( + (item) => + `${renderExpr(item.expr, ctx)} ${item.dir.toUpperCase()}${renderNullsPlacement(item)}`, + ) + .join(', '); } function renderJsonArrayAggExpr(expr: JsonArrayAggExpr, ctx: SqliteRenderContext): string { diff --git a/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts b/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts index 9024b0144409..4df00025923d 100644 --- a/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts +++ b/packages/3-targets/6-adapters/sqlite/test/adapter.test.ts @@ -143,6 +143,37 @@ describe('SQLite adapter', () => { ); }); + it('renders NULLS LAST for an ORDER BY item carrying a nulls placement', () => { + const ast = SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('user', 'id'), 'desc', 'last')]); + + const { sql } = adapter.lower(ast, { contract }); + expect(sql).toBe( + 'SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."id" DESC NULLS LAST', + ); + }); + + it('renders NULLS FIRST for an ORDER BY item carrying a nulls placement', () => { + const ast = SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('user', 'id'), 'asc', 'first')]); + + const { sql } = adapter.lower(ast, { contract }); + expect(sql).toBe( + 'SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."id" ASC NULLS FIRST', + ); + }); + + it('omits the NULLS clause when an ORDER BY item carries no placement', () => { + const ast = SelectAst.from(TableSource.named('user')) + .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) + .withOrderBy([new OrderByItem(ColumnRef.of('user', 'id'), 'desc')]); + + const { sql } = adapter.lower(ast, { contract }); + expect(sql).toBe('SELECT "user"."id" AS "id" FROM "user" ORDER BY "user"."id" DESC'); + }); + it('renders ORDER BY, LIMIT, OFFSET unchanged when both are set', () => { const ast = SelectAst.from(TableSource.named('user')) .withProjection([ProjectionItem.of('id', ColumnRef.of('user', 'id'))]) diff --git a/skills/prisma-8/references/queries-postgres.md b/skills/prisma-8/references/queries-postgres.md index 268dcce8f777..048fbf29b7d1 100644 --- a/skills/prisma-8/references/queries-postgres.md +++ b/skills/prisma-8/references/queries-postgres.md @@ -339,6 +339,8 @@ db.sql.post .build(); ``` +`.orderBy(...)` takes `nulls: 'first' | 'last'` alongside `direction`, rendering `NULLS FIRST` / `NULLS LAST`. Omit it and each target applies its own default, and those defaults disagree — PostgreSQL ranks NULLs highest (last under `asc`), SQLite ranks them lowest (first under `asc`). State `nulls` explicitly when a nullable sort column has to order the same way on both. + ## Workflow — Transactions The concept: `db.transaction(fn)` opens a transaction and passes a `tx` context to the callback. `tx.orm` and `tx.sql` mirror `db.orm` / `db.sql` but ride the same transaction; `tx.execute(plan)` executes a SQL-builder plan within it. The transaction commits on the callback's successful return and rolls back on any thrown error.