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
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,22 @@ const addresses = await client.address.find({
},
},
});

// Control NULL placement explicitly
const contacts = await client.contact.find({
orderBy: {
lastName: "ASC_NULLS_LAST",
firstName: "DESC_NULLS_FIRST",
},
});
```

Supported values: `"ASC"`, `"DESC"`, `"ASC_NULLS_FIRST"`, `"ASC_NULLS_LAST"`,
`"DESC_NULLS_FIRST"`, `"DESC_NULLS_LAST"`. Plain `"ASC"`/`"DESC"` use the
database default (PostgreSQL: `ASC` → NULLS LAST, `DESC` → NULLS FIRST); use
the explicit variants when you need to pin NULL placement independently of
sort direction.

#### Pagination

**Offset-based Pagination:**
Expand Down Expand Up @@ -613,6 +627,48 @@ const prevPage = await client.contact.find({
});
```

**Nested Collection Pagination:**

A `take`/`skip` inside a collection select paginates **each record's list
independently** — every parent gets its own window, regardless of how many
items its siblings have:

```javascript
// Each contact comes back with its own newest address
const contacts = await client.contact.find({
select: {
firstName: true,
addresses: {
select: { street: true },
orderBy: { id: "DESC" },
take: 1,
},
},
});

// Negative take works like the top level: the last N of each record's
// list, still returned in the requested order
const withOldest = await client.contact.find({
select: {
firstName: true,
addresses: {
select: { street: true },
orderBy: { createdOn: "ASC" },
take: -2,
},
},
});
```

When the parent page holds several records, a nested `cursor` is ignored if
a nested `take`/`skip` is present, and nested items carry no
`_count`/`_cursor` metadata. A page with a single record (e.g. `findOne`)
keeps the full top-level semantics for its collections — nested cursor
pagination and `_count`/`_cursor` metadata included — which is what GraphQL
nested connections rely on. When the nested `orderBy` reaches through
another collection, the lists are paginated in memory after fetching the
filtered items of the current page's parents.

#### Aggregations

```javascript
Expand Down
39 changes: 33 additions & 6 deletions src/client/parser/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ export function parseCursor(
return {};
}

const invert = (take ?? 0) < 0;
let count = 0;

const makeWhere = (items: CursorTuple[], invert: boolean): WhereResult => {
const makeWhere = (items: CursorTuple[]): WhereResult => {
const [first, ...rest] = items;
const [key, order, value] = first;

Expand All @@ -71,21 +72,47 @@ export function parseCursor(
params[p] = value;

const op = invert ? ORDER_OPS_INVERTED[order] : ORDER_OPS[order];
const isAsc = order.startsWith("ASC");
const nullsFirst = order.includes("NULLS_FIRST");
const nullsLast = order.includes("NULLS_LAST");

// Forward direction: where do NULLs sit?
// Explicit NULLS_FIRST/LAST wins; otherwise fall back to the DB
// (Postgres) default: ASC -> NULLS LAST, DESC -> NULLS FIRST.
let effectiveNullsFirst: boolean;
if (nullsFirst) effectiveNullsFirst = true;
else if (nullsLast) effectiveNullsFirst = false;
else effectiveNullsFirst = !isAsc;

if (invert) effectiveNullsFirst = !effectiveNullsFirst;

let comp: string;
if (value === null) {
// NULL at the start of our direction -> everything non-null is after it.
// NULL at the end -> nothing is after it.
comp = effectiveNullsFirst ? `${key} IS NOT NULL` : "FALSE";
} else if (!effectiveNullsFirst) {
// NULLs sit on the "after" side, so pull trailing NULL rows in too.
comp = `(${key} ${op} :${p} OR ${key} IS NULL)`;
} else {
comp = `${key} ${op} :${p}`;
}

const eq = `${key} IS NOT DISTINCT FROM :${p}`;

if (rest && rest.length) {
const next = makeWhere(rest, invert);
const next = makeWhere(rest);
if (rest.length > 1 && next.where) next.where = `(${next.where})`;
where = `${key} ${op} :${p} OR (${key} = :${p} AND ${next.where})`;
where = `${comp} OR (${eq} AND ${next.where})`;
params = { ...params, ...next.params };
} else {
where = `${key} ${op} :${p}`;
where = comp;
}

return { where, params, joins: {} };
};

const invert = (take ?? 0) < 0;
const { where, params } = makeWhere(cur, invert);
const { where, params } = makeWhere(cur);

if (invert) {
const order = Object.entries(orderBy).reduce(
Expand Down
6 changes: 4 additions & 2 deletions src/client/parser/processors/query-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ export class QueryProcessor {
const whereResult = this.whereProcessor.process(repo, conditions, "self");
const orderResult = this.orderProcessor.process(repo, orderBy, "self");

// Handle pagination ordering
// Any take/skip selects a window from an ordered list, so the ordering
// must be total for the result to be deterministic — not just for page
// queries, but also for skip-only windows and an explicit `skip: 0`
let finalOrder = orderResult?.order || {};
if (isPageQuery(query)) {
if (isPageQuery(query) || take || skip) {
const uniqueOrder = this.orderProcessor.ensureUniqueOrderBy(
repo,
orderBy,
Expand Down
12 changes: 12 additions & 0 deletions src/client/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,16 +73,28 @@ export const JSON_CAST_TYPES = {
export const ORDER_OPS = {
ASC: ">",
DESC: "<",
ASC_NULLS_FIRST: ">",
ASC_NULLS_LAST: ">",
DESC_NULLS_FIRST: "<",
DESC_NULLS_LAST: "<",
} as const;

export const ORDER_OPS_INVERTED = {
ASC: "<",
DESC: ">",
ASC_NULLS_FIRST: "<",
ASC_NULLS_LAST: "<",
DESC_NULLS_FIRST: ">",
DESC_NULLS_LAST: ">",
} as const;

export const ORDER_INVERTED = {
ASC: "DESC",
DESC: "ASC",
ASC_NULLS_FIRST: "DESC_NULLS_LAST",
ASC_NULLS_LAST: "DESC_NULLS_FIRST",
DESC_NULLS_FIRST: "ASC_NULLS_LAST",
DESC_NULLS_LAST: "ASC_NULLS_FIRST",
} as const;

export const ID_SELECT: Record<string, string> = {
Expand Down
Loading