diff --git a/README.md b/README.md index 9f98678..2598001 100644 --- a/README.md +++ b/README.md @@ -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:** @@ -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 diff --git a/src/client/parser/cursor.ts b/src/client/parser/cursor.ts index c6f3646..fb41683 100644 --- a/src/client/parser/cursor.ts +++ b/src/client/parser/cursor.ts @@ -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; @@ -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( diff --git a/src/client/parser/processors/query-processor.ts b/src/client/parser/processors/query-processor.ts index 93d96d6..768dbcc 100644 --- a/src/client/parser/processors/query-processor.ts +++ b/src/client/parser/processors/query-processor.ts @@ -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, diff --git a/src/client/parser/types.ts b/src/client/parser/types.ts index b28cd9a..750b159 100644 --- a/src/client/parser/types.ts +++ b/src/client/parser/types.ts @@ -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 = { diff --git a/src/client/repository/query/query.ts b/src/client/repository/query/query.ts index 9dae56d..15e356a 100644 --- a/src/client/repository/query/query.ts +++ b/src/client/repository/query/query.ts @@ -7,7 +7,13 @@ import { parseCursor, } from "../../parser"; import { OrmRepository } from "../types"; -import { createSelectQuery, relationQuery } from "./utils"; +import { + createSelectQuery, + nestedPaginationFilter, + orderReferencesOnlySelf, + relationQuery, + toSqlOrder, +} from "./utils"; export const runQuery = async ( repo: OrmRepository, @@ -22,6 +28,52 @@ type QueryResult = { raw: Record[]; }; +/** + * The single take/skip rule: the index window [start, end) selected from a + * list of `count` items. A negative take selects from the end, and skip then + * counts from the end too, clamped at the list start. The top-level query, + * the in-memory fallback, and — using each partition's `count(*)` as `count` + * — the ranked-subquery filter all realize this same window. + */ +const takeSkipBounds = (count: number, take: number, skip: number) => { + const start = Math.max(0, take >= 0 ? skip : count - skip + take); + const end = take === 0 ? count : start + Math.abs(take); + return [start, end] as const; +}; + +// In-memory realization of the rule; fallback for when the ranked-subquery +// filter cannot be used. +const applyTakeSkip = (items: any[], take: number, skip: number) => { + const [start, end] = takeSkipBounds(items.length, take, skip); + return items.slice(start, end); +}; + +/** + * Whether any join in the chain traverses a collection (one-to-many / + * many-to-many) — such joins multiply the fetched rows. Joins are recorded + * parent-first (`self.country` before `self_country.regions`), so aliases + * resolve in one pass; anything unresolvable is treated as multi-valued to + * stay on the safe path. + */ +const hasMultiValuedJoin = ( + repo: OrmRepository, + joins: Record = {}, +) => { + const repoByAlias: Record> = { self: repo }; + for (const [name, alias] of Object.entries(joins)) { + const [parentAlias, property] = name.split("."); + const parentRepo = repoByAlias[parentAlias]; + const relation = + parentRepo?.metadata.findRelationWithPropertyPath(property); + if (!relation) return true; + if (relation.isOneToMany || relation.isManyToMany) return true; + repoByAlias[alias] = parentRepo.manager.getRepository( + relation.inverseEntityMetadata.target, + ); + } + return false; +}; + const doQuery = async ( repo: OrmRepository, builder: QueryBuilder, @@ -38,11 +90,19 @@ const doQuery = async ( const sq = createSelectQuery(builder, opts); let count = -1; - let { take = 0, skip = 0, cursor } = options; + const { cursor } = options; + let take = options.take ?? 0; + let skip = options.skip ?? 0; if (isPageQuery(options)) { count = await sq.getCount(); - skip = take >= 0 || cursor ? skip : count - skip + take; - take = Math.abs(take); + if (cursor) { + // with a cursor, direction is handled by the cursor ordering itself + take = Math.abs(take); + } else { + const [start, end] = takeSkipBounds(count, take, skip); + skip = start; + take = end - start; + } } const noResult: QueryResult = { @@ -66,7 +126,7 @@ const doQuery = async ( // when fetching previous page with a cursor we have to invert // the original ordering first to get required data and finally // return the result with the requested order. - const sub = new SelectQueryBuilder(sq).orderBy(cur.order); + const sub = new SelectQueryBuilder(sq).orderBy(toSqlOrder(cur.order)); const res = await sub.getMany(); const ids = res.map((x) => x.id); if (ids.length === 0) return noResult; @@ -94,34 +154,71 @@ const doQuery = async ( ); } - const rr = repo.manager.getRepository(relation.inverseEntityMetadata.target); + const rr = repo.manager.getRepository( + relation.inverseEntityMetadata.target, + ); + + const isCollection = property in collections; + + // A nested take/skip must paginate each parent's list, but the related + // records are fetched with a single batched query for the whole page of + // parents — a LIMIT there would cap the page-wide batch, not each list. + // So strip them from the child query; a ranked-subquery filter (or, see + // below, an in-memory slice) applies them per parent instead. A nested + // cursor cannot combine with the per-record window, so it is dropped + // along with them. With a single parent (e.g. findOne, or the GraphQL + // nested connection resolver) the batch IS that record's list, so the + // options pass through untouched and keep their top-level semantics — + // including the cursor and the _count/_cursor page metadata. + const { take: nestedTake, skip: nestedSkip, ...childOpts } = opts; + const childTake = nestedTake ?? 0; + const childSkip = Math.max(0, nestedSkip ?? 0); + const childPaginated = + isCollection && ids.length > 1 && (childTake !== 0 || childSkip > 0); + if (childPaginated) { + delete childOpts.cursor; + } + const effectiveOpts = childPaginated ? childOpts : opts; + + // nested joins through another collection multiply the child rows + // (handled with distinct below); the ranked filter dedupes them too, but + // only per (parent, item, order values) — so when the ORDER itself + // reaches through a collection (ambiguous per item), fetch the full + // lists and slice in memory instead + const hasCollectionJoin = hasMultiValuedJoin(rr, opts.joins); + const sliceInMemory = + childPaginated && hasCollectionJoin && !orderReferencesOnlySelf(opts); + const nq = relationQuery(repo.manager, relation) - .clone() + .query.clone() .setParameter("__parents", ids); + if (childPaginated && !sliceInMemory) { + const filter = nestedPaginationFilter( + repo.manager, + relation, + opts, + childTake, + childSkip, + hasCollectionJoin, + ); + nq.andWhere(filter.condition, filter.params); + } + // Add `self.id` and `self.version` to prevent `createSelectQuery` // to start selection from scratch const oo = { - ...opts, + ...effectiveOpts, select: { - ...opts.select, + ...effectiveOpts.select, "self.id": "self_id", "self.version": "self.version", }, }; - // enable distinct for nested relations with multi-value joins - if (oo.joins && Object.keys(oo.joins).length > 0) { - const hasCollectionJoin = Object.keys(oo.joins).some((x) => { - const related = x.replace(/^self\./, ""); - const relatedRepo = rr.metadata.findRelationWithPropertyPath(related); - return relatedRepo?.isOneToMany || relatedRepo?.isManyToMany; - }); - - // we need distinct - if (hasCollectionJoin) { - oo.distinct = true; - } + // we need distinct for nested relations with multi-value joins + if (hasCollectionJoin) { + oo.distinct = true; } const { entities: items, raw: rawItems } = await doQuery(rr, nq, oo); @@ -142,7 +239,10 @@ const doQuery = async ( for (const record of records) { const relatedIds: any[] = itemsByParent[record.id] || []; - const related = relatedIds.map((x) => itemsById[x]); + let related = relatedIds.map((x) => itemsById[x]); + if (sliceInMemory) { + related = applyTakeSkip(related, childTake, childSkip); + } record[property] = property in references ? (related.length ? related[0] : null) : related; } @@ -170,12 +270,20 @@ const doQuery = async ( const end = records[records.length - 1]; if (start && end) { - // _hasPrev: true if we have a skip value (not at beginning) or if cursor indicates we're not at start - start._hasPrev = skip > 0 || (cursor && take > 0); + const backward = !!cursor && (options.take ?? 0) < 0; + + // _hasPrev: rows exist before this page — a skip offset, or the row a + // forward cursor points at; going backward, a short page means the + // list start was reached + start._hasPrev = cursor ? !backward || records.length === take : skip > 0; - // _hasNext: true if we fetched exactly 'take' records and there might be more - // This is determined by: (skip + records.length) < count - end._hasNext = count > skip + records.length; + // _hasNext: rows exist after this page. The count locates a plain + // page exactly, but not a cursor page (the count ignores the cursor + // filter) — there a short page signals the end, while the row a + // backward cursor points at is always next + end._hasNext = cursor + ? backward || records.length === take + : count > skip + records.length; } } diff --git a/src/client/repository/query/utils.ts b/src/client/repository/query/utils.ts index eff3ef5..390f7e3 100644 --- a/src/client/repository/query/utils.ts +++ b/src/client/repository/query/utils.ts @@ -1,9 +1,35 @@ -import { EntityManager, QueryBuilder } from "typeorm"; +import { EntityManager, OrderByCondition, QueryBuilder } from "typeorm"; import { RelationMetadata } from "typeorm/metadata/RelationMetadata.js"; import { parseQuery, ParseResult } from "../../parser"; -import type { Entity, QueryClient, WhereOptions } from "../../types"; +import type { Entity, OrderBy, QueryClient, WhereOptions } from "../../types"; import { OrmRepository } from "../types"; +type OrderByValue = OrderByCondition[string]; + +const SQL_ORDER: Record = { + ASC: "ASC", + DESC: "DESC", + ASC_NULLS_FIRST: { order: "ASC", nulls: "NULLS FIRST" }, + ASC_NULLS_LAST: { order: "ASC", nulls: "NULLS LAST" }, + DESC_NULLS_FIRST: { order: "DESC", nulls: "NULLS FIRST" }, + DESC_NULLS_LAST: { order: "DESC", nulls: "NULLS LAST" }, +}; + +export const toSqlOrder = (order: Record): OrderByCondition => + Object.entries(order).reduce( + (prev, [k, v]) => ({ ...prev, [k]: SQL_ORDER[v] }), + {} as OrderByCondition, + ); + +// the same mapping as raw SQL text, for places TypeORM's OrderByCondition +// cannot reach (e.g. inside a window's OVER clause) +const sqlOrderText = (order: string) => { + const mapped = SQL_ORDER[order] ?? "ASC"; + return typeof mapped === "string" + ? mapped + : `${mapped.order} ${mapped.nulls}`; +}; + export const relationQuery = ( manager: EntityManager, relation: RelationMetadata, @@ -18,50 +44,66 @@ export const relationQuery = ( const inverseJoinColumn = relation.inverseJoinColumns?.[0]?.databaseName; if (relation.isManyToOne || (relation.isOneToOne && joinColumn)) { - return manager - .createQueryBuilder() - .from(targetTable, "self") - .select("self.id") - .addSelect("self.version") - .addSelect("joined.id as __parent") - .innerJoin(entityTable, "joined", `joined.${joinColumn} = self.id`) - .where("joined.id IN (:...__parents)"); + const parentColumn = "joined.id"; + return { + parentColumn, + query: manager + .createQueryBuilder() + .from(targetTable, "self") + .select("self.id") + .addSelect("self.version") + .addSelect(`${parentColumn} as __parent`) + .innerJoin(entityTable, "joined", `joined.${joinColumn} = self.id`) + .where("joined.id IN (:...__parents)"), + }; } if (relation.isOneToOne && !joinColumn) { - return manager - .createQueryBuilder() - .from(targetTable, "self") - .select("self.id") - .addSelect("self.version") - .addSelect("joined.id as __parent") - .innerJoin(entityTable, "joined", `joined.id = self.${mappedBy}`) - .where("joined.id IN (:...__parents)"); + const parentColumn = "joined.id"; + return { + parentColumn, + query: manager + .createQueryBuilder() + .from(targetTable, "self") + .select("self.id") + .addSelect("self.version") + .addSelect(`${parentColumn} as __parent`) + .innerJoin(entityTable, "joined", `joined.id = self.${mappedBy}`) + .where("joined.id IN (:...__parents)"), + }; } if (relation.isOneToMany) { - return manager - .createQueryBuilder() - .from(targetTable, "self") - .select("self.id") - .addSelect("self.version") - .addSelect(`self.${mappedBy} as __parent`) - .where(`self.${mappedBy} IN (:...__parents)`); + const parentColumn = `self.${mappedBy}`; + return { + parentColumn, + query: manager + .createQueryBuilder() + .from(targetTable, "self") + .select("self.id") + .addSelect("self.version") + .addSelect(`${parentColumn} as __parent`) + .where(`self.${mappedBy} IN (:...__parents)`), + }; } // owner side many-to-many if (relation.isManyToMany && joinColumn) { - return manager - .createQueryBuilder() - .from(targetTable, "self") - .select("self.id") - .addSelect("self.version") - .addSelect(`joined.${joinColumn} as __parent`) - .innerJoin( - joinTable, - "joined", - `joined.${joinColumn} IN (:...__parents) AND joined.${inverseJoinColumn} = self.id`, - ); + const parentColumn = `joined.${joinColumn}`; + return { + parentColumn, + query: manager + .createQueryBuilder() + .from(targetTable, "self") + .select("self.id") + .addSelect("self.version") + .addSelect(`${parentColumn} as __parent`) + .innerJoin( + joinTable, + "joined", + `joined.${joinColumn} IN (:...__parents) AND joined.${inverseJoinColumn} = self.id`, + ), + }; } // non-owning side many-to-many @@ -69,17 +111,21 @@ export const relationQuery = ( const inverse = relation.inverseRelation; const joinTable = inverse.joinTableName; const inverseJoinColumn = inverse.inverseJoinColumns?.[0].databaseName; - return manager - .createQueryBuilder() - .from(targetTable, "self") - .select("self.id") - .addSelect("self.version") - .addSelect(`joined.${inverseJoinColumn} as __parent`) - .innerJoin( - joinTable, - "joined", - `joined.${mappedBy} = self.id AND joined.${inverseJoinColumn} IN (:...__parents)`, - ); + const parentColumn = `joined.${inverseJoinColumn}`; + return { + parentColumn, + query: manager + .createQueryBuilder() + .from(targetTable, "self") + .select("self.id") + .addSelect("self.version") + .addSelect(`${parentColumn} as __parent`) + .innerJoin( + joinTable, + "joined", + `joined.${mappedBy} = self.id AND joined.${inverseJoinColumn} IN (:...__parents)`, + ), + }; } throw new Error(`Invalid relation: ${relation.propertyName}`); @@ -122,11 +168,121 @@ export const createSelectQuery = ( } if (where) sq.andWhere(where, params); - if (order) sq.orderBy(order); + if (order) sq.orderBy(toSqlOrder(order)); return sq; }; +// select maps expressions to their aliases; this is the reverse lookup, for +// places that must reference the expression itself (e.g. inside a subquery) +export const selectExpressionByAlias = ( + select: Record, +): Record => + Object.entries(select).reduce( + (prev, [expression, alias]) => ({ ...prev, [alias]: expression }), + {}, + ); + +/** + * Whether every order key resolves to an expression that references only the + * `self` alias. Order keys that reach through joins (or that cannot be + * resolved) return false. + */ +export const orderReferencesOnlySelf = (options: ParseResult) => { + const { order = {}, select = {} } = options; + const expressionByAlias = selectExpressionByAlias(select); + return Object.keys(order).every((key) => { + const expression = expressionByAlias[key] ?? key; + const aliases = Array.from( + expression.matchAll(/\b([A-Za-z_]\w*)\.\w/g), + (m) => m[1], + ); + return aliases.length > 0 && aliases.every((alias) => alias === "self"); + }); +}; + +/** + * A nested take/skip promises per-record pagination of a collection, but the + * related records are fetched with one batched query for the whole page of + * parents — a plain LIMIT there would cap the combined batch. This builds a + * filter on a ranked subquery instead: `row_number()` partitioned by the + * parent ranks each parent's items by the nested ordering, and only the ids + * inside the takeSkipBounds window survive. A negative take anchors the + * window at the end of each list, using the partition's `count(*)` to find + * the same clamped start as the in-memory rule. With `dedupe`, the ranking + * runs over the distinct (parent, item, order values) tuples, so rows + * multiplied by a collection join in the nested where are ranked once. + */ +export const nestedPaginationFilter = ( + manager: EntityManager, + relation: RelationMetadata, + options: ParseResult, + take: number, + skip: number, + dedupe = false, +) => { + const { where, params, joins, order = {}, select = {} } = options; + + const { query: rq, parentColumn } = relationQuery(manager, relation); + + const inner = createSelectQuery(rq, { where, params, joins }); + + // a window ORDER BY cannot reference select aliases, so order keys that are + // aliases (e.g. normalized ones) are mapped back to their expressions + const expressionByAlias = selectExpressionByAlias(select); + + inner.select(parentColumn, "__parent").addSelect("self.id", "__item"); + if (dedupe) { + inner.distinct(true); + } + + const orderings: [string, OrderBy][] = Object.entries(order); + const ranking = ( + orderings.length > 0 ? orderings : ([["self.id", "ASC"]] as const) + ) + .map(([key, direction], i) => { + inner.addSelect(expressionByAlias[key] ?? key, `__order${i}`); + return `d.__order${i} ${sqlOrderText(direction)}`; + }) + .join(", "); + + const partition = "PARTITION BY d.__parent"; + const rankedSelects = [ + "d.__parent", + "d.__item", + `ROW_NUMBER() OVER (${partition} ORDER BY ${ranking}) AS __rank`, + ]; + + const fromEnd = take < 0; + if (fromEnd) { + rankedSelects.push(`COUNT(*) OVER (${partition}) AS __total`); + } + const ranked = `SELECT ${rankedSelects.join(", ")} FROM (${inner.getQuery()}) d`; + + // the same [start, end) window as takeSkipBounds: ranks are 1-based, so + // `rank > start` and `rank <= start + |take|`; for a negative take the + // start counts back from the partition's total, clamped at the list start + const start = fromEnd + ? "GREATEST(0, w.__total - :__nestedSkip - :__nestedTake)" + : ":__nestedSkip"; + const window = [`w.__rank > ${start}`]; + if (take !== 0) { + window.push(`w.__rank <= ${start} + :__nestedTake`); + } + + // The filter must match on the (parent, item) pair, not the item alone: a + // many-to-many item shared by several parents can be inside one parent's + // window and outside another's. + return { + condition: `(${parentColumn}, self.id) IN (SELECT w.__parent, w.__item FROM (${ranked}) w WHERE ${window.join(" AND ")})`, + params: { + ...inner.getParameters(), + __nestedSkip: Math.max(0, skip), + __nestedTake: Math.abs(take), + }, + }; +}; + export const createBulkQuery = ( client: QueryClient, repo: OrmRepository, diff --git a/src/client/types/base.ts b/src/client/types/base.ts index 109351f..c3f40e4 100644 --- a/src/client/types/base.ts +++ b/src/client/types/base.ts @@ -60,4 +60,10 @@ export type Options = { [K in keyof T]: K extends keyof U ? T[K] : never; }; -export type OrderBy = "ASC" | "DESC"; +export type OrderBy = + | "ASC" + | "DESC" + | "ASC_NULLS_FIRST" + | "ASC_NULLS_LAST" + | "DESC_NULLS_FIRST" + | "DESC_NULLS_LAST"; diff --git a/src/graphql/graphql-schema.test.ts b/src/graphql/graphql-schema.test.ts index 5cd8f89..fe4db99 100644 --- a/src/graphql/graphql-schema.test.ts +++ b/src/graphql/graphql-schema.test.ts @@ -69,4 +69,21 @@ describe("GraphQL schema tests", async () => { const schema = buildGraphQLSchema([user]); expect(schema.getType("UserType")).toBeDefined(); }); + + it("should have NULLS FIRST/LAST in OrderBy enum", async () => { + const user = defineEntity({ + name: "User", + fields: [{ name: "name", type: "String" }], + }); + const schema = buildGraphQLSchema([user]); + const orderBy = schema.getType("OrderBy") as any; + expect(orderBy).toBeDefined(); + const values = orderBy.getValues().map((x: any) => x.name); + expect(values).toContain("ASC"); + expect(values).toContain("DESC"); + expect(values).toContain("ASC_NULLS_FIRST"); + expect(values).toContain("ASC_NULLS_LAST"); + expect(values).toContain("DESC_NULLS_FIRST"); + expect(values).toContain("DESC_NULLS_LAST"); + }); }); diff --git a/src/graphql/graphql-schema.ts b/src/graphql/graphql-schema.ts index a7d1c3e..a2cc5e4 100644 --- a/src/graphql/graphql-schema.ts +++ b/src/graphql/graphql-schema.ts @@ -117,6 +117,10 @@ const OrderBy = new GraphQLEnumType({ values: { ASC: { value: "ASC" }, DESC: { value: "DESC" }, + ASC_NULLS_FIRST: { value: "ASC_NULLS_FIRST" }, + ASC_NULLS_LAST: { value: "ASC_NULLS_LAST" }, + DESC_NULLS_FIRST: { value: "DESC_NULLS_FIRST" }, + DESC_NULLS_LAST: { value: "DESC_NULLS_LAST" }, }, }); diff --git a/src/test/client-nested-pagination.test.ts b/src/test/client-nested-pagination.test.ts new file mode 100644 index 0000000..72a0108 --- /dev/null +++ b/src/test/client-nested-pagination.test.ts @@ -0,0 +1,696 @@ +import { describe, expect, it } from "vitest"; +import { getTestClient } from "./client.utils"; + +describe("nested collection pagination", async () => { + const client = await getTestClient(); + + const createContactWithAddresses = async ( + firstName: string, + streets: string[], + ) => { + const contact = await client.contact.create({ + data: { firstName, lastName: "Tester" }, + }); + for (const street of streets) { + await client.address.create({ + data: { contact: { select: { id: contact.id } }, street }, + }); + } + return contact; + }; + + const createTaggedAddress = async ( + contactId: string, + street: string, + tags: string[], + ) => { + const address = await client.address.create({ + data: { contact: { select: { id: contactId } }, street }, + }); + for (const name of tags) { + await client.addressTag.create({ + data: { name, address: { select: { id: address.id } } }, + }); + } + return address; + }; + + it("should limit each record's collection to `take` items", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // both contacts have addresses, so each must come back with exactly one: + // its own newest + expect(contacts[0].addresses).toHaveLength(1); + expect(contacts[0].addresses?.[0]?.street).toBe("A3"); + expect(contacts[1].addresses).toHaveLength(1); + expect(contacts[1].addresses?.[0]?.street).toBe("B2"); + }); + + it("should skip items within each record's collection", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "DESC" }, + take: 1, + skip: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // skipping the newest address leaves each contact's second-newest + expect(contacts[0].addresses?.[0]?.street).toBe("A2"); + expect(contacts[1].addresses?.[0]?.street).toBe("B1"); + }); + + it("should take from the end of each record's collection when take is negative", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "ASC" }, + take: -1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // like the top-level take, a negative take selects the last items, still + // returned in the requested order + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A3", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B2", + ]); + }); + + it("should sort each record's collection by the nested orderBy", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "DESC" }, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A3", + "A2", + "A1", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B2", + "B1", + ]); + }); + + it("should combine a filter on a single-valued relation with a per-record take", async () => { + const india = await client.country.create({ + data: { code: "IN", name: "India" }, + }); + const france = await client.country.create({ + data: { code: "FR", name: "France" }, + }); + + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + const createAddress = ( + contactId: string, + street: string, + country: string, + ) => + client.address.create({ + data: { + contact: { select: { id: contactId } }, + street, + country: { select: { id: country } }, + }, + }); + await createAddress(alice.id, "A1", france.id); + await createAddress(alice.id, "A2", india.id); + await createAddress(alice.id, "A3", india.id); + await createAddress(bob.id, "B1", india.id); + await createAddress(bob.id, "B2", france.id); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + where: { country: { name: "India" } }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // each contact gets their newest address among the matching ones only + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A3", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + ]); + }); + + it("should not double-count an item matching a collection filter twice", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + await createTaggedAddress(alice.id, "A1", ["Red"]); + // matches the filter through two of its tags — must still count once + await createTaggedAddress(alice.id, "A2", ["Red", "Rose"]); + await createTaggedAddress(alice.id, "A3", ["Blue"]); + await createTaggedAddress(bob.id, "B1", ["Rose"]); + await createTaggedAddress(bob.id, "B2", ["Blue"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + where: { tags: { name: { like: "R%" } } }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // exactly one address each: the newest one having a matching tag + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A2", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + ]); + }); + + it("should paginate collections at every nesting level", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + await createTaggedAddress(alice.id, "A1", ["A1-old", "A1-new"]); + await createTaggedAddress(alice.id, "A2", ["A2-1", "A2-2", "A2-3"]); + await createTaggedAddress(bob.id, "B1", ["B1-only"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { + street: true, + tags: { + select: { name: true }, + orderBy: { id: "DESC" }, + take: 2, + }, + }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // each contact gets their newest address, and inside it only the two + // newest tags of that address + const aliceAddresses = contacts[0].addresses; + expect(aliceAddresses?.map((address) => address.street)).toEqual(["A2"]); + expect(aliceAddresses?.[0]?.tags?.map((tag) => tag.name)).toEqual([ + "A2-3", + "A2-2", + ]); + + const bobAddresses = contacts[1].addresses; + expect(bobAddresses?.map((address) => address.street)).toEqual(["B1"]); + expect(bobAddresses?.[0]?.tags?.map((tag) => tag.name)).toEqual([ + "B1-only", + ]); + }); + + it("should limit many-to-many collections per record", async () => { + const createCircle = (code: string) => + client.circle.create({ data: { code, name: code } }); + const c1 = await createCircle("c1"); + const c2 = await createCircle("c2"); + const c3 = await createCircle("c3"); + const c4 = await createCircle("c4"); + + await client.contact.create({ + data: { + firstName: "Alice", + lastName: "Tester", + circles: { select: [{ id: c1.id }, { id: c2.id }, { id: c3.id }] }, + }, + }); + await client.contact.create({ + data: { + firstName: "Bob", + lastName: "Tester", + circles: { select: [{ id: c2.id }, { id: c4.id }] }, + }, + }); + + const contacts = await client.contact.find({ + select: { + firstName: true, + circles: { + select: { code: true }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // shared circles must not leak one contact's limit into the other's + expect(contacts[0].circles?.map((circle) => circle.code)).toEqual(["c3"]); + expect(contacts[1].circles?.map((circle) => circle.code)).toEqual(["c4"]); + }); + + it("should not return a shared item beyond a record's limit because another record kept it", async () => { + const old = await client.circle.create({ + data: { code: "old", name: "old" }, + }); + const fresh = await client.circle.create({ + data: { code: "new", name: "new" }, + }); + + // "old" is Bob's newest circle but only Alice's second-newest; Alice's + // result must not grow to two circles just because Bob keeps "old" + await client.contact.create({ + data: { + firstName: "Alice", + lastName: "Tester", + circles: { select: [{ id: old.id }, { id: fresh.id }] }, + }, + }); + await client.contact.create({ + data: { + firstName: "Bob", + lastName: "Tester", + circles: { select: [{ id: old.id }] }, + }, + }); + + const contacts = await client.contact.find({ + select: { + firstName: true, + circles: { + select: { code: true }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + expect(contacts[0].circles?.map((circle) => circle.code)).toEqual(["new"]); + expect(contacts[1].circles?.map((circle) => circle.code)).toEqual(["old"]); + }); + + it("should limit per record when filtering through a relation's collection", async () => { + const g1 = await client.circle.create({ data: { code: "g1", name: "g1" } }); + const g2 = await client.circle.create({ data: { code: "g2", name: "g2" } }); + + // Alice belongs to two matching circles, so her addresses match the + // filter twice over; the limit must still count addresses, not matches + const alice = await client.contact.create({ + data: { + firstName: "Alice", + lastName: "Tester", + circles: { select: [{ id: g1.id }, { id: g2.id }] }, + }, + }); + const bob = await client.contact.create({ + data: { + firstName: "Bob", + lastName: "Tester", + circles: { select: [{ id: g1.id }] }, + }, + }); + await client.address.create({ + data: { contact: { select: { id: alice.id } }, street: "A1" }, + }); + await client.address.create({ + data: { contact: { select: { id: alice.id } }, street: "A2" }, + }); + await client.address.create({ + data: { contact: { select: { id: bob.id } }, street: "B1" }, + }); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + where: { contact: { circles: { code: { like: "g%" } } } }, + orderBy: { id: "DESC" }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A2", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + ]); + }); + + it("should clamp a negative take window that runs past the list start", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "ASC" }, + take: -2, + skip: 2, + }, + }, + orderBy: { id: "ASC" }, + }); + + // 3 addresses, skipping 2 from the end leaves only the first one — the + // window is clamped at the list start and still returns 2 items, exactly + // like the top-level take/skip on a 3-record table + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A1", + "A2", + ]); + // a shorter list clamps the same way: the window stays anchored at the + // list start + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + "B2", + ]); + }); + + it("should clamp the negative take window also when filtering through a collection", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + await createTaggedAddress(alice.id, "A1", ["T"]); + await createTaggedAddress(alice.id, "A2", ["T"]); + await createTaggedAddress(alice.id, "A3", ["T"]); + await createTaggedAddress(bob.id, "B1", ["T"]); + await createTaggedAddress(bob.id, "B2", ["T"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + where: { tags: { name: "T" } }, + orderBy: { id: "ASC" }, + take: -2, + skip: 2, + }, + }, + orderBy: { id: "ASC" }, + }); + + // same window, same clamp — the result must not depend on whether the + // filter goes through a collection + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A1", + "A2", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + "B2", + ]); + }); + + it("should pick limited items in id order when no orderBy is given", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + const createAddress = (contactId: string, street: string) => + client.address.create({ + data: { contact: { select: { id: contactId } }, street }, + }); + const firstOfAlice = await createAddress(alice.id, "A1"); + await createAddress(alice.id, "A2"); + await createAddress(alice.id, "A3"); + const firstOfBob = await createAddress(bob.id, "B1"); + await createAddress(bob.id, "B2"); + /* Rewrite the first addresses so their physical rows move to the end of + * the table — picking by storage order instead of id order now gives a + * different answer. */ + await client.address.update({ + data: { + id: firstOfAlice.id, + version: firstOfAlice.version, + area: "moved", + }, + }); + await client.address.update({ + data: { id: firstOfBob.id, version: firstOfBob.version, area: "moved" }, + }); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A1", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + ]); + }); + + it("should pick limited items in id order also when filtering through a collection", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + const firstOfAlice = await createTaggedAddress(alice.id, "A1", ["T"]); + await createTaggedAddress(alice.id, "A2", ["T"]); + await createTaggedAddress(alice.id, "A3", ["T"]); + const firstOfBob = await createTaggedAddress(bob.id, "B1", ["T"]); + await createTaggedAddress(bob.id, "B2", ["T"]); + /* Same physical-row shuffle as above: the limit must follow id order, not + * storage order. */ + await client.address.update({ + data: { + id: firstOfAlice.id, + version: firstOfAlice.version, + area: "moved", + }, + }); + await client.address.update({ + data: { id: firstOfBob.id, version: firstOfBob.version, area: "moved" }, + }); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + where: { tags: { name: "T" } }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A1", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + ]); + }); + + it("should treat a null take as no limit", async () => { + await createContactWithAddresses("First", ["A1", "A2", "A3"]); + await createContactWithAddresses("Second", ["B1", "B2"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { id: "ASC" }, + // untyped callers can send null where the types say number + take: null as unknown as number, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A1", + "A2", + "A3", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B1", + "B2", + ]); + }); + + it("should keep page metadata and cursor support on a single-record page", async () => { + const contact = await createContactWithAddresses("First", [ + "A1", + "A2", + "A3", + ]); + + // with a single parent the batched query is exactly that record's list, + // so the nested take keeps the full top-level page semantics — this is + // the path GraphQL nested connections use + const found = await client.contact.findOne({ + where: { id: { eq: contact.id } }, + select: { + addresses: { + select: { street: true }, + orderBy: { id: "ASC" }, + take: 2, + }, + }, + }); + + const addresses = found?.addresses ?? []; + expect(addresses.map((address) => address.street)).toEqual(["A1", "A2"]); + + const last = addresses[addresses.length - 1] as (typeof addresses)[0] & { + _count?: number; + _cursor?: string; + }; + expect(last._count).toBe(3); + expect(last._cursor).toBeTruthy(); + + const next = await client.contact.findOne({ + where: { id: { eq: contact.id } }, + select: { + addresses: { + select: { street: true }, + orderBy: { id: "ASC" }, + take: 2, + cursor: last._cursor, + }, + }, + }); + + expect(next?.addresses?.map((address) => address.street)).toEqual(["A3"]); + }); + + it("should limit per record when ordering by a column of a nested collection", async () => { + const alice = await client.contact.create({ + data: { firstName: "Alice", lastName: "Tester" }, + }); + const bob = await client.contact.create({ + data: { firstName: "Bob", lastName: "Tester" }, + }); + await createTaggedAddress(alice.id, "A1", ["zebra"]); + await createTaggedAddress(alice.id, "A2", ["apple"]); + await createTaggedAddress(alice.id, "A3", ["mango"]); + await createTaggedAddress(bob.id, "B1", ["kiwi"]); + await createTaggedAddress(bob.id, "B2", ["berry"]); + + const contacts = await client.contact.find({ + select: { + firstName: true, + addresses: { + select: { street: true }, + orderBy: { tags: { name: "ASC" } }, + take: 1, + }, + }, + orderBy: { id: "ASC" }, + }); + + expect(contacts).toHaveLength(2); + + // each contact gets the address whose tag sorts first — alphabetical by + // tag name, independent of address ids + expect(contacts[0].addresses?.map((address) => address.street)).toEqual([ + "A2", + ]); + expect(contacts[1].addresses?.map((address) => address.street)).toEqual([ + "B2", + ]); + }); +}); diff --git a/src/test/graphql.test.ts b/src/test/graphql.test.ts index b542b11..9864f48 100644 --- a/src/test/graphql.test.ts +++ b/src/test/graphql.test.ts @@ -81,6 +81,101 @@ describe("GraphQL tests", async () => { }); }); + it("should page a record's collection through a relay connection", async () => { + const contact = await client.contact.create({ + data: { firstName: "Single", lastName: "Tester" }, + }); + for (const street of ["A1", "A2", "A3"]) { + await client.address.create({ + data: { contact: { select: { id: contact.id } }, street }, + }); + } + + const firstPageQuery = /* GraphQL */ ` + { + contact { + edges { + node { + addresses(first: 2) { + edges { + cursor + node { + street + } + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + totalCount + } + } + } + } + } + } + `; + + const firstPage: any = await graphql({ + schema, + source: firstPageQuery, + contextValue: { client }, + }); + + expect(firstPage.errors).toBeUndefined(); + const addresses = firstPage.data?.contact?.edges?.[0]?.node?.addresses; + expect(addresses).toBeDefined(); + + const { edges, pageInfo } = addresses; + expect(edges.map((edge: any) => edge.node.street)).toEqual(["A1", "A2"]); + edges.forEach((edge: any) => expect(edge.cursor).toBeTruthy()); + expect(pageInfo.totalCount).toBe(3); + expect(pageInfo.hasNextPage).toBeTruthy(); + expect(pageInfo.startCursor).toBeTruthy(); + expect(pageInfo.endCursor).toBeTruthy(); + + const secondPageQuery = /* GraphQL */ ` + query ($after: String) { + contact { + edges { + node { + addresses(first: 2, after: $after) { + edges { + cursor + node { + street + } + } + pageInfo { + hasPreviousPage + hasNextPage + totalCount + } + } + } + } + } + } + `; + + const secondPage: any = await graphql({ + schema, + source: secondPageQuery, + variableValues: { after: pageInfo.endCursor }, + contextValue: { client }, + }); + + expect(secondPage.errors).toBeUndefined(); + const nextAddresses = secondPage.data?.contact?.edges?.[0]?.node?.addresses; + expect(nextAddresses.edges.map((edge: any) => edge.node.street)).toEqual([ + "A3", + ]); + expect(nextAddresses.pageInfo.totalCount).toBe(3); + expect(nextAddresses.pageInfo.hasNextPage).toBeFalsy(); + expect(nextAddresses.pageInfo.hasPreviousPage).toBeTruthy(); + }); + it("should create", async () => { const mutation = /* GraphQL */ ` mutation { diff --git a/src/test/parser-query.test.ts b/src/test/parser-query.test.ts index 8e4cd4e..64d8189 100644 --- a/src/test/parser-query.test.ts +++ b/src/test/parser-query.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { parseQuery } from "../client/parser"; +import { encodeCursor, parseCursor } from "../client/parser/cursor"; import { QueryOptions } from "../client/types"; import { getTestClient } from "./client.utils"; @@ -11,6 +12,131 @@ describe("query parser tests", async () => { // access the internal typeorm repo for testing const getContactRepo = () => (client.contact as any).unwrap(); + it("should parse cursor with ASC_NULLS_LAST", () => { + const cursor = encodeCursor([["self.firstName", "ASC_NULLS_LAST", "John"]]); + const res = parseCursor({ + order: { "self.firstName": "ASC_NULLS_LAST" }, + cursor, + }); + // ASC NULLS LAST: nulls sort after non-nulls, so the page after John + // must also include trailing NULL rows. + expect(res.where).toBe("(self.firstName > :q0 OR self.firstName IS NULL)"); + }); + + it("should parse cursor with ASC_NULLS_FIRST", () => { + const cursor = encodeCursor([ + ["self.firstName", "ASC_NULLS_FIRST", "John"], + ]); + const res = parseCursor({ + order: { "self.firstName": "ASC_NULLS_FIRST" }, + cursor, + }); + // ASC NULLS FIRST: NULLs already passed, so just > John. + expect(res.where).toBe("self.firstName > :q0"); + }); + + it("should parse cursor with DESC_NULLS_LAST and non-null value", () => { + const cursor = encodeCursor([ + ["self.firstName", "DESC_NULLS_LAST", "John"], + ]); + const res = parseCursor({ + order: { "self.firstName": "DESC_NULLS_LAST" }, + cursor, + }); + // DESC NULLS LAST: nulls sort after non-nulls, include trailing NULLs. + expect(res.where).toBe("(self.firstName < :q0 OR self.firstName IS NULL)"); + }); + + it("should parse backward cursor with ASC_NULLS_LAST", () => { + const cursor = encodeCursor([["self.firstName", "ASC_NULLS_LAST", "John"]]); + const res = parseCursor({ + order: { "self.firstName": "ASC_NULLS_LAST" }, + cursor, + take: -10, + }); + // Backward through ASC NULLS LAST: previous page is rows < John; + // trailing NULLs are already past us, so exclude them. + expect(res.where).toBe("self.firstName < :q0"); + }); + + it("should parse backward cursor with ASC_NULLS_FIRST", () => { + const cursor = encodeCursor([ + ["self.firstName", "ASC_NULLS_FIRST", "John"], + ]); + const res = parseCursor({ + order: { "self.firstName": "ASC_NULLS_FIRST" }, + cursor, + take: -10, + }); + // Backward through ASC NULLS FIRST: previous page is rows < John, + // and leading NULLs (sorted before everything) must be included. + expect(res.where).toBe("(self.firstName < :q0 OR self.firstName IS NULL)"); + }); + + it("should parse cursor with NULL value and DESC", () => { + const cursor = encodeCursor([["self.firstName", "DESC", null]]); + const res = parseCursor({ + order: { "self.firstName": "DESC" }, + cursor, + }); + // DESC defaults to NULLS FIRST (Postgres), so after NULL there are + // still non-null rows to return. + expect(res.where).toBe("self.firstName IS NOT NULL"); + }); + + it("should parse multi-key cursor where leading key is NULL", () => { + const cursor = encodeCursor([ + ["self.firstName", "ASC", null], + ["self.lastName", "ASC", "Doe"], + ]); + const res = parseCursor({ + order: { "self.firstName": "ASC", "self.lastName": "ASC" }, + cursor, + }); + // ASC defaults to NULLS LAST: leading NULL means we are in the + // trailing NULL block; the only rows "after" share NULL firstName and + // have lastName > 'Doe'. The tie-break must use IS NOT DISTINCT FROM + // so the NULL = NULL match holds. + expect(res.where).toBe( + "FALSE OR (self.firstName IS NOT DISTINCT FROM :q0 AND (self.lastName > :q1 OR self.lastName IS NULL))", + ); + }); + + it("should parse cursor with NULL value and ASC (default NULLS LAST)", () => { + const cursor = encodeCursor([["self.firstName", "ASC", null]]); + const res = parseCursor({ + order: { "self.firstName": "ASC" }, + cursor, + }); + // ASC defaults to NULLS LAST; NULL sits at the trailing edge, so no + // rows come after it. + expect(res.where).toBe("FALSE"); + }); + + it("should parse backward cursor with NULL value and ASC (default NULLS LAST)", () => { + const cursor = encodeCursor([["self.firstName", "ASC", null]]); + const res = parseCursor({ + order: { "self.firstName": "ASC" }, + cursor, + take: -10, + }); + // Backward through ASC NULLS LAST starting at a NULL cursor: the + // previous page is every non-null row (they all sort before NULL). + expect(res.where).toBe("self.firstName IS NOT NULL"); + }); + + it("should parse backward cursor with NULL value and DESC (default NULLS FIRST)", () => { + const cursor = encodeCursor([["self.firstName", "DESC", null]]); + const res = parseCursor({ + order: { "self.firstName": "DESC" }, + cursor, + take: -10, + }); + // Backward through DESC NULLS FIRST starting at a NULL cursor: NULLs + // sit at the leading edge, so nothing comes before them. + expect(res.where).toBe("FALSE"); + }); + it("should parse simple `select` options", () => { const opts: QueryOptions = { select: {