From f058a201f079b40f76e6867eb02ec0843583f2d0 Mon Sep 17 00:00:00 2001 From: Abdul Wasay Date: Sat, 22 Aug 2026 03:59:54 +0500 Subject: [PATCH] fix(sql): render NULLS FIRST/LAST for the orderBy nulls option `OrderByOptions.nulls` typechecked at the call site and was dropped at build time: `OrderByItem` had nowhere to carry a NULL placement, so the builder had nothing to write to and no target ever emitted a NULLS clause. A sort that reads as explicitly NULL-ordered in the source compiled clean and returned rows in a different order, silently. Give `OrderByItem` a `nulls` slot, pass the option through `resolveOrderBy`, and render it in the Postgres and SQLite adapters (select lists, window ORDER BY, and aggregate ORDER BY alike). Omitting the option still emits no clause, so existing plans are byte-identical. `reverse()` flips the placement along with the direction, since inverting a total order has to move NULLs to the opposite end for a backward page to mirror the forward one. Fixes #29932 Signed-off-by: Abdul Wasay --- .../4-lanes/relational-core/src/ast/types.ts | 28 ++- .../relational-core/test/ast/order.test.ts | 41 +++++ .../sql-builder/src/runtime/builder-base.ts | 4 +- .../sql-builder/test/runtime/builders.test.ts | 23 +++ .../postgres/src/core/sql-renderer.ts | 12 +- .../6-adapters/postgres/test/adapter.test.ts | 48 +++++ .../order-by-nulls.integration.test.ts | 171 ++++++++++++++++++ .../6-adapters/sqlite/src/core/adapter.ts | 17 +- .../6-adapters/sqlite/test/adapter.test.ts | 31 ++++ .../prisma-8/references/queries-postgres.md | 2 + 10 files changed, 363 insertions(+), 14 deletions(-) create mode 100644 packages/3-targets/6-adapters/postgres/test/migrations/order-by-nulls.integration.test.ts 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 6c5a20123af9..89831a42fc03 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.