Skip to content

Commit 0ca55fb

Browse files
committed
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`.
1 parent 8d7c54c commit 0ca55fb

File tree

14 files changed

+671
-102
lines changed

14 files changed

+671
-102
lines changed

.changeset/pretty-laws-explode.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"objection-graphql-resolver": minor
3+
"orchid-graphql": minor
4+
---
5+
6+
Refactor cursor pagination:
7+
8+
- Expect explicit list of fields to sort on.
9+
- If not provided, take the list of sort fields from the query itself.
10+
- Fix crash on naming clash when e.g. paginating over `id` with subquery also having `id`.

packages/base/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"graphql": "^16"
1414
},
1515
"devDependencies": {
16+
"@types/node": "^22.10.1",
1617
"tsconfig-vite-node": "^1.1.2"
1718
}
1819
}

packages/base/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { OrmAdapter, OrmModifier } from "./orm/orm"
1+
export { OrmAdapter, OrmModifier, SortOrder } from "./orm/orm"
22
export { Paginator } from "./paginators/base"
33
export { defineCursorPaginator } from "./paginators/cursor"
44
export { defineFieldResolver } from "./resolvers/field"

packages/base/src/orm/orm.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export type OrmModifier<Orm extends OrmAdapter> = (
55
...args: any[]
66
) => Orm["Query"]
77

8+
export interface SortOrder {
9+
field: string
10+
dir: "ASC" | "DESC"
11+
// TODO: add nulls first/last
12+
}
13+
814
export interface OrmAdapter<Table = any, Query = any, QueryTransform = any> {
915
// Types
1016

@@ -57,7 +63,9 @@ export interface OrmAdapter<Table = any, Query = any, QueryTransform = any> {
5763

5864
reset_query_order(query: Query): Query
5965

60-
add_query_order(query: Query, field: string, desc: boolean): Query
66+
add_query_order(query: Query, order: SortOrder): Query
67+
68+
get_query_order(query: Query): SortOrder[]
6169

6270
set_query_limit(query: Query, limit: number): Query
6371

Lines changed: 84 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { OrmAdapter } from "../orm/orm"
1+
import { Buffer } from "node:buffer"
2+
3+
import { OrmAdapter, SortOrder } from "../orm/orm"
24
import { PaginateContext, Paginator } from "./base"
35

46
export function defineCursorPaginator(
@@ -8,8 +10,8 @@ export function defineCursorPaginator(
810
}
911

1012
export interface CursorPaginatorOptions {
11-
fields: string[]
12-
take: number
13+
fields?: string[]
14+
take?: number
1315
}
1416

1517
export interface CursorPaginatorArgs {
@@ -26,20 +28,17 @@ class CursorPaginator<Orm extends OrmAdapter, Context>
2628
implements Paginator<Orm, Context>
2729
{
2830
readonly path = ["nodes"]
29-
readonly options: CursorPaginatorOptions
3031

31-
readonly fields: Array<{
32+
readonly pageSize: number
33+
34+
readonly fields?: Array<{
3235
name: string
3336
desc: boolean
3437
}>
3538

36-
constructor(options: Partial<CursorPaginatorOptions> = {}) {
37-
this.options = {
38-
fields: ["id"],
39-
take: 10,
40-
...options,
41-
}
42-
this.fields = this.options.fields.map((field) => {
39+
constructor(options: CursorPaginatorOptions = {}) {
40+
this.pageSize = options.take ?? 10
41+
this.fields = options.fields?.map((field) => {
4342
if (field.startsWith("-")) {
4443
return { name: field.slice(1), desc: true }
4544
} else {
@@ -52,63 +51,92 @@ class CursorPaginator<Orm extends OrmAdapter, Context>
5251
const { orm } = context.graph
5352
const { args } = context.tree
5453

55-
const take = (args.take as number | undefined) ?? this.options.take
54+
const pageSize = (args.take as number | undefined) ?? this.pageSize
5655
const cursor = args.cursor as string | undefined
5756

58-
// Set query order
59-
query = orm.reset_query_order(query)
60-
for (const field of this.fields) {
61-
// TODO: prevent potential name clash with aliases like .as(`_${table_ref}_order_key_0`)
62-
query = orm.select_field(query, { field: field.name, as: field.name })
63-
query = orm.add_query_order(query, field.name, field.desc)
57+
const table = orm.get_query_table(query)
58+
59+
const orderFields = (
60+
this.fields
61+
? this.fields.map<SortOrder>((f) => ({
62+
field: f.name,
63+
dir: f.desc ? "DESC" : "ASC",
64+
}))
65+
: orm.get_query_order(query)
66+
).map((o) => ({ ...o, alias: "_order_" + o.field }))
67+
68+
if (this.fields) {
69+
query = orm.reset_query_order(query)
70+
for (const { alias, dir } of orderFields) {
71+
query = orm.add_query_order(query, { field: alias, dir })
72+
}
73+
}
74+
75+
if (!orderFields.length) {
76+
throw new Error("Query must be ordered.")
77+
}
78+
79+
for (const { field, alias } of orderFields) {
80+
query = orm.select_field(query, { field, as: alias })
6481
}
6582

6683
if (cursor) {
67-
const { expression, bindings } = this._parse_cursor(cursor)
68-
query = orm.where_raw(query, expression, bindings)
84+
const parsedCursor = parseCursor(cursor)
85+
// Prepare raw SQL.
86+
// For example, for (amount asc, id asc) order, that would be:
87+
// (amount, $id) >= ($amount, id)
88+
const left: string[] = []
89+
const right: string[] = []
90+
for (let i = 0; i < orderFields.length; ++i) {
91+
const { field, alias, dir } = orderFields[i]
92+
const [expressions, placeholders] =
93+
dir === "ASC" ? [left, right] : [right, left]
94+
expressions.push(`"${table}"."${field}"`)
95+
placeholders.push("$" + alias)
96+
}
97+
const sqlExpr = `(${left.join(",")}) > (${right.join(",")})`
98+
const bindings = Object.fromEntries(
99+
orderFields.map(({ alias }, i) => [alias, parsedCursor[i]]),
100+
)
101+
query = orm.where_raw(query, sqlExpr, bindings)
102+
}
103+
query = orm.set_query_limit(query, pageSize + 1)
104+
105+
// TODO add support for reverse cursor, borrow implementation from orchid-pagination.
106+
function createNodeCursor(node: any) {
107+
return createCursor(
108+
orderFields.map(({ field, alias }) => {
109+
const value = node[alias]
110+
// TODO add support for custom serializer(s).
111+
if (value === undefined) {
112+
throw new Error(
113+
`Unable to create cursor: undefined field ${field} (${alias})`,
114+
)
115+
}
116+
return String(value)
117+
}),
118+
)
69119
}
70-
query = orm.set_query_limit(query, take + 1)
71120

72121
return orm.set_query_page_result(query, (nodes) => {
73122
let cursor: string | undefined
74-
if (nodes.length > take) {
75-
cursor = this._create_cursor(nodes[take - 1])
76-
nodes = nodes.slice(0, take)
123+
if (nodes.length > pageSize) {
124+
cursor = createNodeCursor(nodes[pageSize - 1])
125+
nodes = nodes.slice(0, pageSize)
77126
}
78127
return { nodes, cursor }
79128
})
80129
}
130+
}
81131

82-
_create_cursor(instance: any) {
83-
return JSON.stringify(
84-
this.fields.map((field) => {
85-
const value = instance[field.name]
86-
if (value === undefined) {
87-
throw new Error(
88-
`Unable to create cursor: undefined field ${field.name}`,
89-
)
90-
}
91-
return String(value)
92-
}),
93-
)
94-
}
132+
function createCursor(parts: string[]) {
133+
return Buffer.from(parts.map(String).join(String.fromCharCode(0))).toString(
134+
"base64url",
135+
)
136+
}
95137

96-
_parse_cursor(cursor: string) {
97-
const values = JSON.parse(cursor)
98-
const left: string[] = []
99-
const right: string[] = []
100-
const bindings: Record<string, any> = {}
101-
for (let i = 0; i < this.fields.length; ++i) {
102-
const field = this.fields[i]
103-
const expressions = field.desc ? right : left
104-
const placeholders = field.desc ? left : right
105-
expressions.push(`"${field.name}"`)
106-
placeholders.push("$" + field.name)
107-
bindings[field.name] = values[i]
108-
}
109-
return {
110-
expression: `(${left.join(",")}) > (${right.join(",")})`,
111-
bindings,
112-
}
113-
}
138+
function parseCursor(cursor: string): string[] {
139+
return Buffer.from(cursor, "base64url")
140+
.toString()
141+
.split(String.fromCharCode(0))
114142
}

packages/objection/docs/pagination.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The library includes simple `CursorPaginator` implementation which traverses ord
1414
{ "id": 1, "foo": "bar" },
1515
{ "id": 2, "foo": "baz" }
1616
],
17-
"cursor": "xyzzy"
17+
"cursor": "encoded-string-cursor-to-fetch-next-page"
1818
}
1919
```
2020

@@ -81,11 +81,10 @@ const graph = r.graph({
8181
fields: {
8282
id: true,
8383
name: true,
84-
// If it were posts: true, all posts will be returned.
84+
// If it were posts: true, all posts would be returned.
8585
// Instead, return a page of posts sorted by newest first.
8686
posts: r.page(r.cursor({ fields: ["-id"], take: 10 })),
87-
// Should you want this, it's still possible to pull all posts (non-paginated)
88-
// under a different GraphQL field
87+
// Pull all posts (non-paginated) under a different GraphQL field.
8988
all_posts: r.relation({ modelField: "posts" }),
9089
},
9190
}),
@@ -99,8 +98,10 @@ const resolvers = {
9998
},
10099
posts: (parent, args, context, info) => {
101100
return graph.resolvePage(
102-
Post.query(),
103-
r.cursor({ fields: ["-id"], take: 10 }),
101+
// Pagination fields can be taken from cursor({ fields })
102+
// or from the query itself, like this:
103+
Post.query().orderBy("id", "desc"),
104+
r.cursor({ take: 10 }),
104105
{ context, info },
105106
)
106107
},

packages/objection/src/orm/orm.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { OrmAdapter, OrmModifier, run_after_query } from "graphql-orm"
1+
import {
2+
OrmAdapter,
3+
OrmModifier,
4+
run_after_query,
5+
SortOrder,
6+
} from "graphql-orm"
27
import { QueryBuilder, raw, ref, RelationMappings } from "objection"
38
import { Model } from "objection"
49
import { AnyModelConstructor, AnyQueryBuilder, ModelClass } from "objection"
@@ -100,8 +105,19 @@ export const orm: ObjectionOrm = {
100105
return query.clearOrder()
101106
},
102107

103-
add_query_order(query, field, desc) {
104-
return query.orderBy(field, desc ? "desc" : "asc")
108+
add_query_order(query, { field, dir }) {
109+
return query.orderBy(field, dir)
110+
},
111+
112+
get_query_order(query) {
113+
const sortOrders: SortOrder[] = []
114+
;(query as any).forEachOperation(/orderBy/, (op: any) => {
115+
if (op.name === "orderBy") {
116+
const [field, dir] = op.args
117+
sortOrders.push({ field, dir })
118+
}
119+
})
120+
return sortOrders
105121
},
106122

107123
set_query_limit(query, limit) {

packages/objection/tests/main/nested-pagination.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,12 @@ const graph = r.graph({
111111
fields: {
112112
id: true,
113113
name: true,
114-
posts: r.page(r.cursor({ take: 2 })),
114+
posts: r.page(r.cursor({ fields: ["id"], take: 2 })),
115115
// TODO: test the fields below
116-
posts_page: r.page(r.cursor({ take: 2 }), {
116+
posts_page: r.page(r.cursor({ fields: ["id"], take: 2 }), {
117117
modelField: "posts",
118118
}),
119-
posts_by_one: r.page(r.cursor({ take: 1 }), {
119+
posts_by_one: r.page(r.cursor({ fields: ["id"], take: 1 }), {
120120
modelField: "posts",
121121
}),
122122
all_posts: "posts",
@@ -127,7 +127,7 @@ const graph = r.graph({
127127
fields: {
128128
id: true,
129129
name: true,
130-
posts: r.page(r.cursor({ take: 2 }), {
130+
posts: r.page(r.cursor({ fields: ["id"], take: 2 }), {
131131
filters: true,
132132
}),
133133
},
@@ -208,7 +208,7 @@ test("nested pagination", async () => {
208208
"user": {
209209
"name": "Alice",
210210
"posts": {
211-
"cursor": "["2"]",
211+
"cursor": "Mg",
212212
"nodes": [
213213
{
214214
"id": 1,
@@ -270,7 +270,7 @@ test("nested pagination", async () => {
270270
"id": 1,
271271
"name": "Alice",
272272
"posts": {
273-
"cursor": "["2"]",
273+
"cursor": "Mg",
274274
"nodes": [
275275
{
276276
"author": {
@@ -280,7 +280,7 @@ test("nested pagination", async () => {
280280
"section": {
281281
"name": "News",
282282
"posts": {
283-
"cursor": "["2"]",
283+
"cursor": "Mg",
284284
"nodes": [
285285
{
286286
"author": {
@@ -313,7 +313,7 @@ test("nested pagination", async () => {
313313
"section": {
314314
"name": "Editorial",
315315
"posts": {
316-
"cursor": "["2"]",
316+
"cursor": "Mg",
317317
"nodes": [
318318
{
319319
"author": {
@@ -374,7 +374,7 @@ test("nested pagination", async () => {
374374
"user": {
375375
"name": "Bob",
376376
"posts": {
377-
"cursor": "["4"]",
377+
"cursor": "NA",
378378
"nodes": [
379379
{
380380
"author": {

0 commit comments

Comments
 (0)