Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 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,8 @@ 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 { OrderByNulls };

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
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ function buildCursorWhere(
for (const order of orderBy) {
if (order.expr.kind !== 'column-ref') continue;
const column = order.expr.column;
if (order.nulls !== undefined) {
throw ormError(
'ORM.CURSOR_ORDER_NULLS_UNSUPPORTED',
`Cursor pagination cannot express nulls placement: orderBy column "${column}" uses nulls: '${order.nulls}'. Remove the nulls option or paginate with skip/take.`,
{ meta: { column, nulls: order.nulls } },
);
}
const value = cursor[column];
if (value === undefined) {
throw ormError(
Expand Down
9 changes: 5 additions & 4 deletions packages/3-extensions/sql-orm-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ListExpression,
NullCheckExpr,
OrderByItem,
type OrderByNulls,
ParamRef,
type AggregateFn as SqlAggregateFn,
} from '@internal/sql-relational-core/ast';
Expand Down Expand Up @@ -197,8 +198,8 @@ export type ComparisonMethodFns<T> = {
notIn(values: readonly T[]): AnyExpression;
isNull(): AnyExpression;
isNotNull(): AnyExpression;
asc(): OrderByItem;
desc(): OrderByItem;
asc(nulls?: OrderByNulls): OrderByItem;
desc(nulls?: OrderByNulls): OrderByItem;
};

/**
Expand Down Expand Up @@ -395,11 +396,11 @@ export const COMPARISON_METHODS_META = {
},
asc: {
traits: ['order'],
create: (left) => () => OrderByItem.asc(left),
create: (left) => (nulls?: OrderByNulls) => OrderByItem.asc(left, nulls),
},
desc: {
traits: ['order'],
create: (left) => () => OrderByItem.desc(left),
create: (left) => (nulls?: OrderByNulls) => OrderByItem.desc(left, nulls),
},
isNull: {
traits: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function bindProjectionExpr(contract: Contract<SqlStorage>, expr: ProjectionExpr
}

function bindOrderByItem(contract: Contract<SqlStorage>, orderItem: OrderByItem): OrderByItem {
return new OrderByItem(bindExpression(contract, orderItem.expr), orderItem.dir);
return new OrderByItem(bindExpression(contract, orderItem.expr), orderItem.dir, orderItem.nulls);
}

function bindJoin(contract: Contract<SqlStorage>, join: JoinAst): JoinAst {
Expand Down
Loading