From e036bd312e10fc5b30ed8266d37a42d4a512e877 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Wed, 4 Feb 2026 12:02:29 +0530 Subject: [PATCH 1/2] Add explicit NULL ordering and NULL-aware cursors Extend OrderBy with ASC_NULLS_FIRST/LAST and DESC_NULLS_FIRST/LAST, exposed through the GraphQL OrderBy enum and translated to SQL NULLS FIRST/LAST. Fix cursor pagination over nullable columns: - Tie-break uses IS NOT DISTINCT FROM so multi-key cursors do not drop rows when a key is NULL. - Keyset predicate includes trailing NULLs when they sit after the cursor, and handles a NULL cursor value explicitly. - Plain ASC/DESC fall back to the Postgres default (ASC -> NULLS LAST, DESC -> NULLS FIRST); backward pagination flips null position with direction. --- README.md | 14 +++ src/client/parser/cursor.ts | 39 +++++++-- src/client/parser/types.ts | 12 +++ src/client/repository/query/query.ts | 4 +- src/client/repository/query/utils.ts | 21 ++++- src/client/types/base.ts | 8 +- src/graphql/graphql-schema.test.ts | 17 ++++ src/graphql/graphql-schema.ts | 4 + src/test/parser-query.test.ts | 126 +++++++++++++++++++++++++++ 9 files changed, 234 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 9f98678..bfbd864 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:** 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/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..2fe781f 100644 --- a/src/client/repository/query/query.ts +++ b/src/client/repository/query/query.ts @@ -7,7 +7,7 @@ import { parseCursor, } from "../../parser"; import { OrmRepository } from "../types"; -import { createSelectQuery, relationQuery } from "./utils"; +import { createSelectQuery, relationQuery, toSqlOrder } from "./utils"; export const runQuery = async ( repo: OrmRepository, @@ -66,7 +66,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; diff --git a/src/client/repository/query/utils.ts b/src/client/repository/query/utils.ts index eff3ef5..1721a79 100644 --- a/src/client/repository/query/utils.ts +++ b/src/client/repository/query/utils.ts @@ -1,9 +1,26 @@ -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 { 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, + ); + export const relationQuery = ( manager: EntityManager, relation: RelationMetadata, @@ -122,7 +139,7 @@ export const createSelectQuery = ( } if (where) sq.andWhere(where, params); - if (order) sq.orderBy(order); + if (order) sq.orderBy(toSqlOrder(order)); return sq; }; 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/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: { From 9235775efd8ff99e12e5a73328327675640a2b96 Mon Sep 17 00:00:00 2001 From: kac-axelor <215158475+kac-axelor@users.noreply.github.com> Date: Thu, 11 Jun 2026 03:52:33 +0530 Subject: [PATCH 2/2] Fix nested collection take/skip to paginate per record A take/skip inside a collection select was applied as a LIMIT on the single batched query that loads the collection for the whole page of parents, capping the combined batch instead of each record's list. Nested take/skip are now stripped from the batch query and applied per record: a row_number() window partitioned by the parent filters the ids (matched as (parent, item) pairs so shared many-to-many items cannot leak across windows); when the nested where/order traverses another collection, the filtered lists are sliced in memory instead, since the multiplied rows would corrupt the ranking. Negative take selects from the end of each list, mirroring the top-level semantics via the inverted NULL-aware ordering. A nested cursor is dropped alongside, and multi-valued join detection now walks the whole join chain. --- README.md | 42 ++ .../parser/processors/query-processor.ts | 6 +- src/client/repository/query/query.ts | 160 +++- src/client/repository/query/utils.ts | 231 ++++-- src/test/client-nested-pagination.test.ts | 696 ++++++++++++++++++ src/test/graphql.test.ts | 95 +++ 6 files changed, 1156 insertions(+), 74 deletions(-) create mode 100644 src/test/client-nested-pagination.test.ts diff --git a/README.md b/README.md index bfbd864..2598001 100644 --- a/README.md +++ b/README.md @@ -627,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/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/repository/query/query.ts b/src/client/repository/query/query.ts index 2fe781f..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, toSqlOrder } 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 = { @@ -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 1721a79..390f7e3 100644 --- a/src/client/repository/query/utils.ts +++ b/src/client/repository/query/utils.ts @@ -1,7 +1,7 @@ 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]; @@ -21,6 +21,15 @@ export const toSqlOrder = (order: Record): OrderByCondition => {} 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, @@ -35,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 @@ -86,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}`); @@ -144,6 +173,116 @@ export const createSelectQuery = ( 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/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 {