From 0ca55fb477355b88b52fc3666e78bee7524c94f4 Mon Sep 17 00:00:00 2001 From: Ilya Semenov Date: Tue, 10 Dec 2024 20:05:52 +0700 Subject: [PATCH] Refactor cursor pagination - Expect explicit list of fields to sort on. - If not provided, take the list of sort fields from the query itself. - Fix crash on naming clash when e.g. paginating over `id` with subquery also having `id`. --- .changeset/pretty-laws-explode.md | 10 + packages/base/package.json | 1 + packages/base/src/index.ts | 2 +- packages/base/src/orm/orm.ts | 10 +- packages/base/src/paginators/cursor.ts | 140 ++++---- packages/objection/docs/pagination.md | 13 +- packages/objection/src/orm/orm.ts | 22 +- .../tests/main/nested-pagination.test.ts | 18 +- packages/orchid/docs/pagination.md | 13 +- packages/orchid/src/orm/orm.ts | 29 +- .../tests/main/pagination-name-clash.test.ts | 180 +++++++++++ .../tests/main/relation-pagination.test.ts | 20 +- .../orchid/tests/main/root-pagination.test.ts | 302 +++++++++++++++++- pnpm-lock.yaml | 13 + 14 files changed, 671 insertions(+), 102 deletions(-) create mode 100644 .changeset/pretty-laws-explode.md create mode 100644 packages/orchid/tests/main/pagination-name-clash.test.ts diff --git a/.changeset/pretty-laws-explode.md b/.changeset/pretty-laws-explode.md new file mode 100644 index 0000000..72c3696 --- /dev/null +++ b/.changeset/pretty-laws-explode.md @@ -0,0 +1,10 @@ +--- +"objection-graphql-resolver": minor +"orchid-graphql": minor +--- + +Refactor cursor pagination: + +- Expect explicit list of fields to sort on. +- If not provided, take the list of sort fields from the query itself. +- Fix crash on naming clash when e.g. paginating over `id` with subquery also having `id`. diff --git a/packages/base/package.json b/packages/base/package.json index 2ee6347..0792886 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -13,6 +13,7 @@ "graphql": "^16" }, "devDependencies": { + "@types/node": "^22.10.1", "tsconfig-vite-node": "^1.1.2" } } diff --git a/packages/base/src/index.ts b/packages/base/src/index.ts index 05f4e9a..911ce89 100644 --- a/packages/base/src/index.ts +++ b/packages/base/src/index.ts @@ -1,4 +1,4 @@ -export { OrmAdapter, OrmModifier } from "./orm/orm" +export { OrmAdapter, OrmModifier, SortOrder } from "./orm/orm" export { Paginator } from "./paginators/base" export { defineCursorPaginator } from "./paginators/cursor" export { defineFieldResolver } from "./resolvers/field" diff --git a/packages/base/src/orm/orm.ts b/packages/base/src/orm/orm.ts index c3975d8..1286ac8 100644 --- a/packages/base/src/orm/orm.ts +++ b/packages/base/src/orm/orm.ts @@ -5,6 +5,12 @@ export type OrmModifier = ( ...args: any[] ) => Orm["Query"] +export interface SortOrder { + field: string + dir: "ASC" | "DESC" + // TODO: add nulls first/last +} + export interface OrmAdapter { // Types @@ -57,7 +63,9 @@ export interface OrmAdapter
{ reset_query_order(query: Query): Query - add_query_order(query: Query, field: string, desc: boolean): Query + add_query_order(query: Query, order: SortOrder): Query + + get_query_order(query: Query): SortOrder[] set_query_limit(query: Query, limit: number): Query diff --git a/packages/base/src/paginators/cursor.ts b/packages/base/src/paginators/cursor.ts index 291b149..f105540 100644 --- a/packages/base/src/paginators/cursor.ts +++ b/packages/base/src/paginators/cursor.ts @@ -1,4 +1,6 @@ -import { OrmAdapter } from "../orm/orm" +import { Buffer } from "node:buffer" + +import { OrmAdapter, SortOrder } from "../orm/orm" import { PaginateContext, Paginator } from "./base" export function defineCursorPaginator( @@ -8,8 +10,8 @@ export function defineCursorPaginator( } export interface CursorPaginatorOptions { - fields: string[] - take: number + fields?: string[] + take?: number } export interface CursorPaginatorArgs { @@ -26,20 +28,17 @@ class CursorPaginator implements Paginator { readonly path = ["nodes"] - readonly options: CursorPaginatorOptions - readonly fields: Array<{ + readonly pageSize: number + + readonly fields?: Array<{ name: string desc: boolean }> - constructor(options: Partial = {}) { - this.options = { - fields: ["id"], - take: 10, - ...options, - } - this.fields = this.options.fields.map((field) => { + constructor(options: CursorPaginatorOptions = {}) { + this.pageSize = options.take ?? 10 + this.fields = options.fields?.map((field) => { if (field.startsWith("-")) { return { name: field.slice(1), desc: true } } else { @@ -52,63 +51,92 @@ class CursorPaginator const { orm } = context.graph const { args } = context.tree - const take = (args.take as number | undefined) ?? this.options.take + const pageSize = (args.take as number | undefined) ?? this.pageSize const cursor = args.cursor as string | undefined - // Set query order - query = orm.reset_query_order(query) - for (const field of this.fields) { - // TODO: prevent potential name clash with aliases like .as(`_${table_ref}_order_key_0`) - query = orm.select_field(query, { field: field.name, as: field.name }) - query = orm.add_query_order(query, field.name, field.desc) + const table = orm.get_query_table(query) + + const orderFields = ( + this.fields + ? this.fields.map((f) => ({ + field: f.name, + dir: f.desc ? "DESC" : "ASC", + })) + : orm.get_query_order(query) + ).map((o) => ({ ...o, alias: "_order_" + o.field })) + + if (this.fields) { + query = orm.reset_query_order(query) + for (const { alias, dir } of orderFields) { + query = orm.add_query_order(query, { field: alias, dir }) + } + } + + if (!orderFields.length) { + throw new Error("Query must be ordered.") + } + + for (const { field, alias } of orderFields) { + query = orm.select_field(query, { field, as: alias }) } if (cursor) { - const { expression, bindings } = this._parse_cursor(cursor) - query = orm.where_raw(query, expression, bindings) + const parsedCursor = parseCursor(cursor) + // Prepare raw SQL. + // For example, for (amount asc, id asc) order, that would be: + // (amount, $id) >= ($amount, id) + const left: string[] = [] + const right: string[] = [] + for (let i = 0; i < orderFields.length; ++i) { + const { field, alias, dir } = orderFields[i] + const [expressions, placeholders] = + dir === "ASC" ? [left, right] : [right, left] + expressions.push(`"${table}"."${field}"`) + placeholders.push("$" + alias) + } + const sqlExpr = `(${left.join(",")}) > (${right.join(",")})` + const bindings = Object.fromEntries( + orderFields.map(({ alias }, i) => [alias, parsedCursor[i]]), + ) + query = orm.where_raw(query, sqlExpr, bindings) + } + query = orm.set_query_limit(query, pageSize + 1) + + // TODO add support for reverse cursor, borrow implementation from orchid-pagination. + function createNodeCursor(node: any) { + return createCursor( + orderFields.map(({ field, alias }) => { + const value = node[alias] + // TODO add support for custom serializer(s). + if (value === undefined) { + throw new Error( + `Unable to create cursor: undefined field ${field} (${alias})`, + ) + } + return String(value) + }), + ) } - query = orm.set_query_limit(query, take + 1) return orm.set_query_page_result(query, (nodes) => { let cursor: string | undefined - if (nodes.length > take) { - cursor = this._create_cursor(nodes[take - 1]) - nodes = nodes.slice(0, take) + if (nodes.length > pageSize) { + cursor = createNodeCursor(nodes[pageSize - 1]) + nodes = nodes.slice(0, pageSize) } return { nodes, cursor } }) } +} - _create_cursor(instance: any) { - return JSON.stringify( - this.fields.map((field) => { - const value = instance[field.name] - if (value === undefined) { - throw new Error( - `Unable to create cursor: undefined field ${field.name}`, - ) - } - return String(value) - }), - ) - } +function createCursor(parts: string[]) { + return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString( + "base64url", + ) +} - _parse_cursor(cursor: string) { - const values = JSON.parse(cursor) - const left: string[] = [] - const right: string[] = [] - const bindings: Record = {} - for (let i = 0; i < this.fields.length; ++i) { - const field = this.fields[i] - const expressions = field.desc ? right : left - const placeholders = field.desc ? left : right - expressions.push(`"${field.name}"`) - placeholders.push("$" + field.name) - bindings[field.name] = values[i] - } - return { - expression: `(${left.join(",")}) > (${right.join(",")})`, - bindings, - } - } +function parseCursor(cursor: string): string[] { + return Buffer.from(cursor, "base64url") + .toString() + .split(String.fromCharCode(0)) } diff --git a/packages/objection/docs/pagination.md b/packages/objection/docs/pagination.md index 957c864..a29a485 100644 --- a/packages/objection/docs/pagination.md +++ b/packages/objection/docs/pagination.md @@ -14,7 +14,7 @@ The library includes simple `CursorPaginator` implementation which traverses ord { "id": 1, "foo": "bar" }, { "id": 2, "foo": "baz" } ], - "cursor": "xyzzy" + "cursor": "encoded-string-cursor-to-fetch-next-page" } ``` @@ -81,11 +81,10 @@ const graph = r.graph({ fields: { id: true, name: true, - // If it were posts: true, all posts will be returned. + // If it were posts: true, all posts would be returned. // Instead, return a page of posts sorted by newest first. posts: r.page(r.cursor({ fields: ["-id"], take: 10 })), - // Should you want this, it's still possible to pull all posts (non-paginated) - // under a different GraphQL field + // Pull all posts (non-paginated) under a different GraphQL field. all_posts: r.relation({ modelField: "posts" }), }, }), @@ -99,8 +98,10 @@ const resolvers = { }, posts: (parent, args, context, info) => { return graph.resolvePage( - Post.query(), - r.cursor({ fields: ["-id"], take: 10 }), + // Pagination fields can be taken from cursor({ fields }) + // or from the query itself, like this: + Post.query().orderBy("id", "desc"), + r.cursor({ take: 10 }), { context, info }, ) }, diff --git a/packages/objection/src/orm/orm.ts b/packages/objection/src/orm/orm.ts index 257fd22..f40d492 100644 --- a/packages/objection/src/orm/orm.ts +++ b/packages/objection/src/orm/orm.ts @@ -1,4 +1,9 @@ -import { OrmAdapter, OrmModifier, run_after_query } from "graphql-orm" +import { + OrmAdapter, + OrmModifier, + run_after_query, + SortOrder, +} from "graphql-orm" import { QueryBuilder, raw, ref, RelationMappings } from "objection" import { Model } from "objection" import { AnyModelConstructor, AnyQueryBuilder, ModelClass } from "objection" @@ -100,8 +105,19 @@ export const orm: ObjectionOrm = { return query.clearOrder() }, - add_query_order(query, field, desc) { - return query.orderBy(field, desc ? "desc" : "asc") + add_query_order(query, { field, dir }) { + return query.orderBy(field, dir) + }, + + get_query_order(query) { + const sortOrders: SortOrder[] = [] + ;(query as any).forEachOperation(/orderBy/, (op: any) => { + if (op.name === "orderBy") { + const [field, dir] = op.args + sortOrders.push({ field, dir }) + } + }) + return sortOrders }, set_query_limit(query, limit) { diff --git a/packages/objection/tests/main/nested-pagination.test.ts b/packages/objection/tests/main/nested-pagination.test.ts index 188ca86..327d0d5 100644 --- a/packages/objection/tests/main/nested-pagination.test.ts +++ b/packages/objection/tests/main/nested-pagination.test.ts @@ -111,12 +111,12 @@ const graph = r.graph({ fields: { id: true, name: true, - posts: r.page(r.cursor({ take: 2 })), + posts: r.page(r.cursor({ fields: ["id"], take: 2 })), // TODO: test the fields below - posts_page: r.page(r.cursor({ take: 2 }), { + posts_page: r.page(r.cursor({ fields: ["id"], take: 2 }), { modelField: "posts", }), - posts_by_one: r.page(r.cursor({ take: 1 }), { + posts_by_one: r.page(r.cursor({ fields: ["id"], take: 1 }), { modelField: "posts", }), all_posts: "posts", @@ -127,7 +127,7 @@ const graph = r.graph({ fields: { id: true, name: true, - posts: r.page(r.cursor({ take: 2 }), { + posts: r.page(r.cursor({ fields: ["id"], take: 2 }), { filters: true, }), }, @@ -208,7 +208,7 @@ test("nested pagination", async () => { "user": { "name": "Alice", "posts": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "id": 1, @@ -270,7 +270,7 @@ test("nested pagination", async () => { "id": 1, "name": "Alice", "posts": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "author": { @@ -280,7 +280,7 @@ test("nested pagination", async () => { "section": { "name": "News", "posts": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "author": { @@ -313,7 +313,7 @@ test("nested pagination", async () => { "section": { "name": "Editorial", "posts": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "author": { @@ -374,7 +374,7 @@ test("nested pagination", async () => { "user": { "name": "Bob", "posts": { - "cursor": "["4"]", + "cursor": "NA", "nodes": [ { "author": { diff --git a/packages/orchid/docs/pagination.md b/packages/orchid/docs/pagination.md index c911c49..4abe40a 100644 --- a/packages/orchid/docs/pagination.md +++ b/packages/orchid/docs/pagination.md @@ -14,7 +14,7 @@ The library includes simple `CursorPaginator` implementation which traverses ord { "id": 1, "foo": "bar" }, { "id": 2, "foo": "baz" } ], - "cursor": "xyzzy" + "cursor": "encoded-string-cursor-to-fetch-next-page" } ``` @@ -89,11 +89,10 @@ const graph = r.graph({ fields: { id: true, name: true, - // If it were posts: true, all posts will be returned. + // If it were posts: true, all posts would be returned. // Instead, return a page of posts sorted by newest first. posts: r.page(r.cursor({ fields: ["-id"], take: 10 })), - // Should you want this, it's still possible to pull all posts (non-paginated) - // under a different GraphQL field + // Pull all posts (non-paginated) under a different GraphQL field. all_posts: r.relation({ tableField: "posts" }), }, }), @@ -107,8 +106,10 @@ const resolvers = { }, posts: async (parent, args, context, info) => { return await graph.resolvePage( - db.post, - r.cursor({ fields: ["-id"], take: 10 }), + // Pagination fields can be taken from cursor({ fields }) + // or from the query itself, like this: + db.post.order({ id: "DESC" }), + r.cursor({ take: 10 }), { context, info }, ) }, diff --git a/packages/orchid/src/orm/orm.ts b/packages/orchid/src/orm/orm.ts index 935d3b8..ff36eef 100644 --- a/packages/orchid/src/orm/orm.ts +++ b/packages/orchid/src/orm/orm.ts @@ -1,5 +1,5 @@ -import { OrmAdapter } from "graphql-orm" -import type { Query, Table } from "orchid-orm" +import { OrmAdapter, SortOrder } from "graphql-orm" +import type { Query, SelectQueryData, Table } from "orchid-orm" import { raw } from "orchid-orm" export type OrchidOrm = OrmAdapter< @@ -69,8 +69,29 @@ export const orm: OrchidOrm = { return query.clear("order") }, - add_query_order(query, field, desc) { - return query.order({ [field]: desc ? "DESC" : "ASC" }) + add_query_order(query, { field, dir }) { + return query.order({ [field]: dir }) + }, + + get_query_order(query) { + return ( + (query.q as SelectQueryData).order?.flatMap((orderItem) => { + if (typeof orderItem === "string") { + return { field: "orderItem", dir: "ASC" } + } else if (typeof orderItem === "object") { + return Object.entries(orderItem).map(([field, order]) => { + const [dir] = order.split(" ", 1) + if (dir === "ASC" || dir === "DESC") { + return { field, dir } + } else { + throw new Error("Unsupported order: " + order) + } + }) + } else { + throw new TypeError("Unsupported order type: " + orderItem) + } + }) || [] + ) }, set_query_limit(query, limit) { diff --git a/packages/orchid/tests/main/pagination-name-clash.test.ts b/packages/orchid/tests/main/pagination-name-clash.test.ts new file mode 100644 index 0000000..e2dcaaa --- /dev/null +++ b/packages/orchid/tests/main/pagination-name-clash.test.ts @@ -0,0 +1,180 @@ +import gql from "graphql-tag" +import * as r from "orchid-graphql" +import { expect, test } from "vitest" + +import { BaseTable, create_client, create_db, Resolvers } from "../setup" + +class UserTable extends BaseTable { + readonly table = "user" + + columns = this.setColumns((t) => ({ + id: t.identity().primaryKey(), + name: t.text(), + })) +} + +class PostTable extends BaseTable { + readonly table = "post" + + columns = this.setColumns((t) => ({ + id: t.identity().primaryKey(), + text: t.text(), + author_id: t.integer(), + })) + + relations = { + author: this.belongsTo(() => UserTable, { + columns: ["author_id"], + references: ["id"], + required: true, + }), + } +} + +const db = await create_db({ + user: UserTable, + post: PostTable, +}) + +await db.$query` + create table "user" ( + id serial primary key, + name varchar(100) not null + ); + create table "post" ( + id serial primary key, + text text not null, + author_id integer not null references "user" ("id") + ); +` + +const schema = gql` + type User { + id: Int! + name: String! + } + + type Post { + id: Int! + text: String! + author: User! + } + + type PostPage { + nodes: [Post!]! + cursor: String + } + + type Query { + posts_page(cursor: String): PostPage! + } +` + +const graph = r.graph({ + User: r.table(db.user), + Post: r.table(db.post), +}) + +const resolvers: Resolvers = { + Query: { + async posts_page(_parent, args, context, info) { + return await graph.resolvePage( + db.post.order({ id: "DESC" }), + r.cursor({ take: 1 }), + { + context, + info, + }, + ) + }, + }, +} + +const client = await create_client({ typeDefs: schema, resolvers }) + +await db.user.insertMany([ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, +]) + +await db.post.insertMany([ + { text: "Hello", author_id: 2 }, + { text: "World", author_id: 1 }, +]) + +test("posts with user", async () => { + const { + posts_page: { nodes: nodes1, cursor: cursor1 }, + } = await client.request(gql` + { + posts_page { + nodes { + id + text + author { + id + name + } + } + cursor + } + } + `) + + expect(nodes1).toEqual([ + { + id: 2, + text: "World", + author: { id: 1, name: "Alice" }, + }, + ]) + expect(cursor1).not.toBeNull() + + await client.request(gql` + { + posts_page { + nodes { + id + text + author { + id + name + } + } + cursor + } + } + `) + + const { + posts_page: { nodes: nodes2, cursor: cursor2 }, + } = await client.request( + gql` + query next_page($cursor: String) { + posts_page(cursor: $cursor) { + nodes { + id + text + author { + id + name + } + } + cursor + } + } + `, + { + cursor: cursor1, + }, + ) + + expect(nodes2).toEqual([ + { + id: 1, + text: "Hello", + author: { id: 2, name: "Bob" }, + }, + ]) + expect(cursor2).toBeNull() +}) diff --git a/packages/orchid/tests/main/relation-pagination.test.ts b/packages/orchid/tests/main/relation-pagination.test.ts index 6c57fb2..ffd505a 100644 --- a/packages/orchid/tests/main/relation-pagination.test.ts +++ b/packages/orchid/tests/main/relation-pagination.test.ts @@ -184,29 +184,29 @@ const graph = r.graph( { User: r.table(db.user, { fields: { - posts_page: r.page(r.cursor({ take: 2 }), { + posts_page: r.page(r.cursor({ fields: ["id"], take: 2 }), { tableField: "posts", }), - posts_by_one: r.page(r.cursor({ take: 1 }), { + posts_by_one: r.page(r.cursor({ fields: ["id"], take: 1 }), { tableField: "posts", }), all_posts: "posts", all_posts_verbose: r.relation({ tableField: "posts" }), - comments_page: r.page(r.cursor({ take: 2 }), { + comments_page: r.page(r.cursor({ fields: ["id"], take: 2 }), { tableField: "comments", }), }, }), Post: r.table(db.post, { fields: { - comments_page: r.page(r.cursor({ take: 2 }), { + comments_page: r.page(r.cursor({ fields: ["id"], take: 2 }), { tableField: "comments", }), }, }), Comment: r.table(db.comment, { fields: { - likes_page: r.page(r.cursor({ take: 2 }), { + likes_page: r.page(r.cursor({ fields: ["id"], take: 2 }), { tableField: "likes", }), }, @@ -271,7 +271,7 @@ test("relation pagination", async () => { "user": { "name": "Alice", "posts_page": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "text": "Oil price rising.", @@ -315,14 +315,14 @@ test("double nested pagination", async () => { "user": { "name": "Alice", "posts_page": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "author": { "name": "Alice", }, "comments_page": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "text": "I am so good.", @@ -411,14 +411,14 @@ test("double and triple nested pagination", async () => { }, "name": "Alice", "posts_page": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "author": { "name": "Alice", }, "comments_page": { - "cursor": "["2"]", + "cursor": "Mg", "nodes": [ { "likes_page": { diff --git a/packages/orchid/tests/main/root-pagination.test.ts b/packages/orchid/tests/main/root-pagination.test.ts index 278814a..8cdbb26 100644 --- a/packages/orchid/tests/main/root-pagination.test.ts +++ b/packages/orchid/tests/main/root-pagination.test.ts @@ -35,7 +35,13 @@ const schema = gql` cursor: ID } + input SortOrder { + field: String! + reverse: Boolean + } + type Query { + users(sort: SortOrder!, cursor: ID, take: Int): UserPage! users_by_name(cursor: ID, take: Int): UserPage! users_by_name_reverse(cursor: ID, take: Int): UserPage! } @@ -47,6 +53,21 @@ const graph = r.graph({ const resolvers: Resolvers = { Query: { + async users(_parent, args, context, info) { + const order = args.sort + ? { [args.sort.field]: args.sort.reverse ? "DESC" : "ASC" } + : undefined + return await graph.resolvePage( + db.user + .modify((q: typeof db.user) => (order ? q.order(order) : q)) + .order({ id: "DESC" }), + r.cursor({ take: 2 }), + { + context, + info, + }, + ) + }, async users_by_name(_parent, _args, context, info) { return await graph.resolvePage( db.user, @@ -70,10 +91,10 @@ const resolvers: Resolvers = { const client = await create_client({ typeDefs: schema, resolvers }) await db.user.createMany([ - { name: "Alice" }, - { name: "Charlie" }, - { name: "Bob" }, - { name: "Charlie" }, + { id: 1, name: "Alice" }, + { id: 2, name: "Charlie" }, + { id: 3, name: "Bob" }, + { id: 4, name: "Charlie" }, ]) function test_users( @@ -92,7 +113,276 @@ function test_users( return cursor } -test("forward pagination", async () => { +test("users (by ID)", async () => { + test_users( + "without args", + await client.request(gql` + { + users(sort: { field: "id" }) { + nodes { + name + } + cursor + } + } + `), + [{ name: "Alice" }, { name: "Charlie" }], + true, + ) +}) + +test("users (by name)", async () => { + test_users( + "without args", + await client.request(gql` + { + users(sort: { field: "name" }) { + nodes { + name + } + cursor + } + } + `), + [{ name: "Alice" }, { name: "Bob" }], + true, + ) + + const take_1_cursor = test_users( + "take 1 by name", + await client.request(gql` + { + users(sort: { field: "name" }, take: 1) { + nodes { + name + } + cursor + } + } + `), + [{ name: "Alice" }], + true, + ) + + const take_2_more_after_1_cursor = test_users( + "take 2 more after 1", + await client.request( + gql` + query more_sections($cursor: ID) { + users(sort: { field: "name" }, cursor: $cursor, take: 2) { + nodes { + id + name + } + cursor + } + } + `, + { + cursor: take_1_cursor, + }, + ), + [ + { name: "Bob", id: 3 }, + { name: "Charlie", id: 4 }, + ], + true, + ) + + test_users( + "take the rest", + await client.request( + gql` + query more_sections($cursor: ID) { + users(sort: { field: "name" }, cursor: $cursor) { + nodes { + id + name + } + cursor + } + } + `, + { + cursor: take_2_more_after_1_cursor, + }, + ), + [{ name: "Charlie", id: 2 }], + false, + ) + + test_users( + "take 4", + await client.request(gql` + { + users(sort: { field: "name" }, take: 4) { + nodes { + id + name + } + cursor + } + } + `), + [ + { name: "Alice", id: 1 }, + { name: "Bob", id: 3 }, + { name: "Charlie", id: 4 }, + { name: "Charlie", id: 2 }, + ], + false, + ) + + test_users( + "take 100", + await client.request(gql` + { + users(sort: { field: "name" }, take: 100) { + nodes { + name + } + cursor + } + } + `), + [ + { name: "Alice" }, + { name: "Bob" }, + { name: "Charlie" }, + { name: "Charlie" }, + ], + false, + ) +}) + +test("users (by name reverse)", async () => { + test_users( + "without args", + await client.request(gql` + { + users: users_by_name_reverse { + nodes { + id + name + } + cursor + } + } + `), + [ + { name: "Charlie", id: 4 }, + { name: "Charlie", id: 2 }, + ], + true, + ) + + const take_1_cursor = test_users( + "take 1", + await client.request(gql` + { + users: users_by_name_reverse(take: 1) { + nodes { + name + } + cursor + } + } + `), + [{ name: "Charlie" }], + true, + ) + + const take_2_more_after_1_cursor = test_users( + "take 2 more after 1", + await client.request( + gql` + query more_sections($cursor: ID) { + users: users_by_name_reverse(cursor: $cursor, take: 2) { + nodes { + id + name + } + cursor + } + } + `, + { + cursor: take_1_cursor, + }, + ), + [ + { name: "Charlie", id: 2 }, + { name: "Bob", id: 3 }, + ], + true, + ) + + test_users( + "take the rest", + await client.request( + gql` + query more_sections($cursor: ID) { + users: users_by_name_reverse(cursor: $cursor) { + nodes { + name + } + cursor + } + } + `, + { + cursor: take_2_more_after_1_cursor, + }, + ), + [{ name: "Alice" }], + false, + ) + + test_users( + "take 4", + await client.request(gql` + { + users: users_by_name_reverse(take: 4) { + nodes { + id + name + } + cursor + } + } + `), + [ + { name: "Charlie", id: 4 }, + { name: "Charlie", id: 2 }, + { name: "Bob", id: 3 }, + { name: "Alice", id: 1 }, + ], + false, + ) + + test_users( + "take 100", + await client.request(gql` + { + users: users_by_name_reverse(take: 100) { + nodes { + name + } + cursor + } + } + `), + [ + { name: "Charlie" }, + { name: "Charlie" }, + { name: "Bob" }, + { name: "Alice" }, + ], + false, + ) +}) + +test("users_by_name", async () => { test_users( "without args", await client.request(gql` @@ -216,7 +506,7 @@ test("forward pagination", async () => { ) }) -test("reverse pagination", async () => { +test("users_by_name_reverse", async () => { test_users( "without args", await client.request(gql` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c5fa54..3f2a159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: specifier: ^4.13.0 version: 4.13.0(graphql@16.6.0) devDependencies: + '@types/node': + specifier: ^22.10.1 + version: 22.10.1 tsconfig-vite-node: specifier: ^1.1.2 version: 1.1.2 @@ -1238,6 +1241,12 @@ packages: resolution: {integrity: sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==} dev: true + /@types/node@22.10.1: + resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} + dependencies: + undici-types: 6.20.0 + dev: true + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -5497,6 +5506,10 @@ packages: which-boxed-primitive: 1.0.2 dev: true + /undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + dev: true + /unique-filename@1.1.1: resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} requiresBuild: true