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
28 changes: 20 additions & 8 deletions packages/2-sql/4-lanes/relational-core/src/ast/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
}
}

Expand Down
41 changes: 41 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
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/3-targets/6-adapters/postgres/src/core/sql-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(', ')}`
: '';
Expand Down Expand Up @@ -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<OrderByItem>,
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(', ');
}

Expand Down
48 changes: 48 additions & 0 deletions packages/3-targets/6-adapters/postgres/test/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down
Loading