Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,10 @@ The requested operation requires a contract capability the contract does not dec

A mutation payload or where-expression references a column that does not exist on the resolved table. Thrown while compiling the query plan or binding the where clause — by the ORM client and by the `sql()` builder DSL. Meta: `namespaceId`, `tableName`, `column`.

### ORM.CURSOR_ORDER_NULLS_UNSUPPORTED

Cursor pagination was requested on an `orderBy` that carries an explicit nulls placement (`asc('first')`, `desc('last')`, …). Keyset boundary predicates compare with `>`/`<`, which never match NULL sort keys, so the placement cannot be honored — remove the nulls option or paginate with `limit`/`offset`. Meta: `column`, `nulls`.

### ORM.CURSOR_VALUE_MISSING

Cursor pagination was requested but the cursor object lacks a value for one of the `orderBy` columns, so the position cannot be anchored. Meta: `column`.
Expand Down
30 changes: 20 additions & 10 deletions packages/2-sql/4-lanes/relational-core/src/ast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1077,37 +1077,47 @@ export class JsonObjectExpr extends Expression {
}
}

export type OrderByNulls = 'first' | 'last';

function flipNulls(nulls: OrderByNulls | undefined): OrderByNulls | undefined {
if (nulls === undefined) return undefined;
return nulls === 'first' ? 'last' : 'first';
}

export class OrderByItem extends AstNode {
readonly kind = 'order-by-item' as const;
readonly expr: AnyExpression;
readonly dir: Direction;
readonly nulls: OrderByNulls | undefined;

constructor(expr: AnyExpression, dir: Direction) {
constructor(expr: AnyExpression, dir: Direction, nulls?: OrderByNulls) {
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?: OrderByNulls): OrderByItem {
return new OrderByItem(expr, 'asc', nulls);
}

static desc(expr: AnyExpression): OrderByItem {
return new OrderByItem(expr, 'desc');
static desc(expr: AnyExpression, nulls?: OrderByNulls): 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.
* Integrations that own pagination (e.g. backward cursor pagination) use
* this to reverse a user's sort order without reaching into the AST.
* A new frozen item with the sort direction flipped and `expr` unchanged. An explicit nulls
* placement flips with it — reversing a scan reverses where its NULLs sit. Integrations that own
* pagination (e.g. backward cursor pagination) use this to reverse a user's sort order without
* reaching into the AST.
*/
reverse(): OrderByItem {
return new OrderByItem(this.expr, this.dir === 'asc' ? 'desc' : 'asc');
return new OrderByItem(this.expr, this.dir === 'asc' ? 'desc' : 'asc', flipNulls(this.nulls));
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/2-sql/4-lanes/relational-core/src/ast/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import type { AnyParamRef, AnyQueryAst } from './types';
import type { AnyParamRef, AnyQueryAst, OrderByItem } from './types';

/**
* The dialect-independent ` ASC`/` DESC` tail of a rendered order item,
* including ` NULLS FIRST`/` NULLS LAST` when the item carries an explicit
* placement. Shared by adapter renderers so the order-suffix grammar cannot
* drift between targets.
*/
export function renderOrderBySuffix(item: OrderByItem): string {
const nulls = item.nulls === undefined ? '' : ` NULLS ${item.nulls.toUpperCase()}`;
return ` ${item.dir.toUpperCase()}${nulls}`;
}

export function compact<T extends Record<string, unknown>>(o: T): T {
const out: Record<string, unknown> = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
NullCheckExpr,
OperationExpr,
OrderByItem,
type OrderByNulls,
OrExpr,
ParamRef,
ProjectionItem,
Expand Down Expand Up @@ -418,8 +419,8 @@ export class CfSelectQuery {
return new CfSelectQuery(this.src, this.projectionItems, expr, this.orderByItems);
}

orderBy(column: ColumnProxy, dir: 'asc' | 'desc' = 'asc'): CfSelectQuery {
const item = dir === 'asc' ? OrderByItem.asc(column.toRef()) : OrderByItem.desc(column.toRef());
orderBy(column: ColumnProxy, dir: 'asc' | 'desc' = 'asc', nulls?: OrderByNulls): CfSelectQuery {
const item = new OrderByItem(column.toRef(), dir, nulls);
return new CfSelectQuery(this.src, this.projectionItems, this.whereExpr, [
...this.orderByItems,
item,
Expand Down
37 changes: 37 additions & 0 deletions packages/2-sql/4-lanes/relational-core/test/ast/order.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { OrderByItem } from '../../src/ast/types';
import { renderOrderBySuffix } from '../../src/ast/util';
import { col, lowerExpr } from './test-helpers';

describe('ast/order', () => {
Expand Down Expand Up @@ -41,4 +42,40 @@ describe('ast/order', () => {
expect(roundTrip.dir).toBe('desc');
expect(roundTrip.expr).toBe(desc.expr);
});

it('carries nulls placement through the constructor and factories', () => {
const asc = OrderByItem.asc(col('user', 'id'), 'first');
const desc = OrderByItem.desc(col('user', 'id'), 'last');

expect(asc).toEqual(new OrderByItem(col('user', 'id'), 'asc', 'first'));
expect(desc).toEqual(new OrderByItem(col('user', 'id'), 'desc', 'last'));
expect(OrderByItem.asc(col('user', 'id')).nulls).toBeUndefined();
});

it('preserves nulls placement across rewrite', () => {
const item = new OrderByItem(col('post', 'title'), 'asc', 'last');
const rewritten = item.rewrite({
columnRef: (expr) => (expr.table === 'post' ? col('article', expr.column) : expr),
});

expect(rewritten.expr).toEqual(col('article', 'title'));
expect(rewritten.nulls).toBe('last');
});

it('renders the shared direction-plus-nulls suffix', () => {
expect(renderOrderBySuffix(OrderByItem.asc(col('user', 'id')))).toBe(' ASC');
expect(renderOrderBySuffix(new OrderByItem(col('user', 'id'), 'desc', 'first'))).toBe(
' DESC NULLS FIRST',
);
expect(renderOrderBySuffix(new OrderByItem(col('user', 'id'), 'asc', 'last'))).toBe(
' ASC NULLS LAST',
);
});

it('flips nulls placement along with direction on reverse', () => {
const reversed = new OrderByItem(col('user', 'id'), 'asc', 'last').reverse();

expect(reversed).toMatchObject({ dir: 'desc', nulls: 'first' });
expect(OrderByItem.asc(col('user', 'id')).reverse().nulls).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,14 @@ describe('table().select()', () => {
]);
});

it('.orderBy() carries an explicit nulls placement', () => {
const ast = tbl.select(tbl.id).orderBy(tbl.name, 'desc', 'last').build();

expect(ast.orderBy?.map((item) => ({ dir: item.dir, nulls: item.nulls }))).toEqual([
{ dir: 'desc', nulls: 'last' },
]);
});

it('.orderBy() composes with .where()', () => {
const ast = tbl.select(tbl.id).where(tbl.id.eq(5)).orderBy(tbl.id, 'desc').build();

Expand Down
4 changes: 2 additions & 2 deletions packages/2-sql/4-lanes/sql-builder/src/expression.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { QueryOperationTypesBase } from '@internal/sql-contract/types';
import type { Direction, OrderByNulls } from '@internal/sql-relational-core/ast';
import type {
CodecExpression,
Expression,
Expand Down Expand Up @@ -40,8 +41,7 @@ export type ExpressionBuilder<AvailableScope extends Scope, QC extends QueryCont
fns: Functions<QC>,
) => Expression<BooleanCodecType>;

export type OrderByDirection = 'asc' | 'desc';
export type OrderByNulls = 'first' | 'last';
export type OrderByDirection = Direction;

export type OrderByOptions = {
direction?: OrderByDirection;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,9 @@ export function resolveOrderBy(
},
);
const expr = IdentifierRef.of(arg);
return dir === 'asc' ? OrderByItem.asc(expr) : OrderByItem.desc(expr);
return dir === 'asc'
? OrderByItem.asc(expr, options?.nulls)
: OrderByItem.desc(expr, options?.nulls);
}

if (typeof arg === 'function') {
Expand All @@ -386,7 +388,9 @@ 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 dir === 'asc'
? OrderByItem.asc(result.buildAst(), options?.nulls)
: OrderByItem.desc(result.buildAst(), options?.nulls);
}

throw structuredError('ORM.ARGUMENT_INVALID', 'Invalid orderBy argument');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,27 @@ describe('orderBy', () => {
);
expect(ast.orderBy).toHaveLength(2);
});

it('orderBy string carries nulls placement to the AST', () => {
const ast = getAst(
db().public.users.select('id', 'name').orderBy('name', { direction: 'desc', nulls: 'last' }),
);
expect(ast.orderBy![0]).toMatchObject({ dir: 'desc', nulls: 'last' });
});

it('orderBy expression callback carries nulls placement to the AST', () => {
const ast = getAst(
db()
.public.users.select('id')
.orderBy((f) => f.id, { nulls: 'first' }),
);
expect(ast.orderBy![0]).toMatchObject({ dir: 'asc', nulls: 'first' });
});

it('orderBy leaves nulls placement undefined when not given', () => {
const ast = getAst(db().public.users.select('id').orderBy('id'));
expect(ast.orderBy![0]!.nulls).toBeUndefined();
});
});

describe('groupBy and having', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/3-extensions/sql-orm-client/src/orm-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type OrmSubcode =
| 'GROUP_BY_FIELD_MISSING'
| 'HAVING_EXPRESSION_UNSUPPORTED'
| 'CURSOR_VALUE_MISSING'
| 'CURSOR_ORDER_NULLS_UNSUPPORTED'
| 'MUTATION_DATA_MISSING'
| 'MUTATION_ROW_MISSING'
| 'ROW_IDENTITY_MISSING'
Expand Down
53 changes: 24 additions & 29 deletions packages/3-extensions/sql-orm-client/src/query-plan-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,18 +239,11 @@ function buildIncludeOrderArtifacts(
const hiddenOrderProjection = childOrderBy.map((orderItem, index) =>
ProjectionItem.of(`${relationName}__order_${index}`, orderItem.expr),
);
const aggregateOrderBy = hiddenOrderProjection.map((projection, index) => {
const orderItem = childOrderBy[index];
if (!orderItem) {
throw new InternalError(`Missing include order metadata at index ${index}`);
}
return new OrderByItem(ColumnRef.of(rowAlias, projection.alias), orderItem.dir);
});

return {
childOrderBy,
hiddenOrderProjection,
aggregateOrderBy,
aggregateOrderBy: remapOrderToHiddenAliases(childOrderBy, rowAlias, relationName),
};
}

Expand All @@ -264,6 +257,26 @@ function localColumnsForRowInclude(include: IncludeExpr): readonly string[] {
return include.through?.parentLocalColumns ?? [include.localColumn];
}

/**
* Remap user order items onto the hidden `<relation>__order_<i>` columns a
* wrapping SELECT forwards under `sourceAlias`, preserving each item's
* direction and nulls placement.
*/
function remapOrderToHiddenAliases(
items: readonly OrderByItem[],
sourceAlias: string,
relationName: string,
): OrderByItem[] {
return items.map(
(item, index) =>
new OrderByItem(
ColumnRef.of(sourceAlias, `${relationName}__order_${index}`),
item.dir,
item.nulls,
),
);
}

function resolveParentLocalRefs(
parentSource: IncludeParentSource,
include: IncludeExpr,
Expand Down Expand Up @@ -704,13 +717,7 @@ function buildIncludeChildRowsSelect(
});
if (childOrderBy) {
childRows = childRows.withOrderBy(
childOrderBy.map(
(item, index) =>
new OrderByItem(
ColumnRef.of(rankedAlias, `${include.relationName}__order_${index}`),
item.dir,
),
),
remapOrderToHiddenAliases(childOrderBy, rankedAlias, include.relationName),
);
}
} else if (childOrderBy) {
Expand Down Expand Up @@ -864,13 +871,7 @@ function buildDistinctNonLeafChildRowsSelect(options: {
// deterministic. Reference the hidden-order alias columns the
// wrapper forwarded under their original names from `rankedAlias`.
innerSelect = innerSelect.withOrderBy(
childOrderBy.map(
(item, index) =>
new OrderByItem(
ColumnRef.of(rankedAlias, `${include.relationName}__order_${index}`),
item.dir,
),
),
remapOrderToHiddenAliases(childOrderBy, rankedAlias, include.relationName),
);
}
if (childState.limit !== undefined) {
Expand Down Expand Up @@ -1142,13 +1143,7 @@ function buildIncludeChildScalarSelect(
});
if (remappedOrderBy !== undefined && remappedOrderBy.length > 0) {
inner = inner.withOrderBy(
remappedOrderBy.map(
(item, index) =>
new OrderByItem(
ColumnRef.of(rankedAlias, `${include.relationName}__order_${index}`),
item.dir,
),
),
remapOrderToHiddenAliases(remappedOrderBy, rankedAlias, include.relationName),
);
}
} else if (remappedOrderBy !== undefined && remappedOrderBy.length > 0) {
Expand Down
20 changes: 20 additions & 0 deletions packages/3-extensions/sql-orm-client/src/query-plan-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@internal/sql-relational-core/ast';
import { codecRefForStorageColumn } from '@internal/sql-relational-core/codec-descriptor-registry';
import { assertDefined } from '@internal/utils/assertions';
import { ifDefined } from '@internal/utils/defined';
import {
type PolymorphismInfo,
resolvePolymorphismInfo,
Expand Down Expand Up @@ -93,6 +94,25 @@ function buildCursorWhere(

const entries: CursorOrderEntry[] = [];
for (const order of orderBy) {
if (order.nulls !== undefined) {
const subject =
order.expr.kind === 'column-ref'
? `orderBy column "${order.expr.column}"`
: 'an expression orderBy entry';
throw ormError(
'ORM.CURSOR_ORDER_NULLS_UNSUPPORTED',
`Cursor pagination cannot express nulls placement: ${subject} uses nulls: '${order.nulls}'. Remove the nulls option or paginate with limit/offset.`,
{
meta: {
...ifDefined(
'column',
order.expr.kind === 'column-ref' ? order.expr.column : undefined,
),
nulls: order.nulls,
},
},
);
}
if (order.expr.kind !== 'column-ref') continue;
const column = order.expr.column;
const value = cursor[column];
Expand Down
Loading