Skip to content

Commit cdf51ef

Browse files
committed
initial commit
0 parents  commit cdf51ef

File tree

5 files changed

+422
-0
lines changed

5 files changed

+422
-0
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.idea
2+
node_modules

package.json

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"name": "@ephys/sequelize-cursor-pagination",
3+
"version": "1.0.0",
4+
"description": "Implement Cursor Pagination in the Sequelize ORM",
5+
"main": "lib/index.js",
6+
"scripts": {
7+
"test": "jest"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "git+https://github.com/Ephys/sequelize-cursor-pagination.git"
12+
},
13+
"author": "Guylian Cox <[email protected]>",
14+
"license": "MIT",
15+
"bugs": {
16+
"url": "https://github.com/Ephys/sequelize-cursor-pagination/issues"
17+
},
18+
"homepage": "https://github.com/Ephys/sequelize-cursor-pagination#readme",
19+
"peerDependencies": {
20+
"sequelize": "^6.6.2"
21+
}
22+
}

src/sequelize-find-by-cursor.ts

+366
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,366 @@
1+
import {
2+
Model,
3+
ModelCtor as ModelClass,
4+
Sequelize,
5+
Op,
6+
FindOptions,
7+
OrderItem as SequelizeOrderItem,
8+
Logging,
9+
Transactionable,
10+
} from 'sequelize';
11+
import { getPrimaryColumns, matchAssociationReference } from './sequelize-utils';
12+
import { MaybePromise } from './types';
13+
14+
/**
15+
* @module sequelize-find-by-cursor
16+
*
17+
* A sequelize implementation of cursor-based pagination (find after or before another entity).
18+
*
19+
* Based on
20+
* @link https://facebook.github.io/relay/graphql/connections.htm
21+
*/
22+
23+
export type ModelFinder<E> = (query) => Promise<E[]>;
24+
25+
export type OrderTuple = [string, 'ASC' | 'DESC'];
26+
27+
type Cursor = { [key: string]: any }
28+
29+
interface QueryMetadata<Entity extends Model> {
30+
isLast: boolean,
31+
limit: number,
32+
33+
sortOrder: Array<OrderTuple>,
34+
35+
after: Cursor | null,
36+
before: Cursor | null,
37+
38+
findAll: ModelFinder<Entity>,
39+
40+
passDown: IDownPassed,
41+
}
42+
43+
// TODO: this library could generate a stateless cursor
44+
// TODO: add support for extra properties:
45+
// - where: join it to our cursor where using Sequelize.and(config.where, orderQuery)
46+
47+
interface IDownPassed extends Logging, Transactionable {}
48+
49+
export interface FindByCursorConfig<E extends Model> extends IDownPassed {
50+
model: ModelClass<E>,
51+
order: OrderTuple[],
52+
53+
/**
54+
* This is a cursor. If provided, only entities that are located before this cursor will be returned.
55+
*
56+
* The cursor is an object that must contain one value for each column used in the `order` property, plus the primary keys of the entity.
57+
*/
58+
before?: { [key: string]: any } | null,
59+
60+
/**
61+
* This is a cursor. If provided, only entities that are located after this cursor will be returned.
62+
*
63+
* The cursor is an object that must contain one value for each column used in the `order` property, plus the primary keys of the entity.
64+
*/
65+
after?: { [key: string]: any } | null,
66+
first?: number | null,
67+
last?: number | null,
68+
69+
findAll?: ModelFinder<E>,
70+
}
71+
72+
export interface FindByCursorResult<T> {
73+
nodes: T[],
74+
hasNextPage: () => MaybePromise<boolean>,
75+
hasPreviousPage: () => MaybePromise<boolean>,
76+
}
77+
78+
export async function sequelizeFindByCursor<Entity extends Model>(
79+
config: FindByCursorConfig<Entity>,
80+
): Promise<FindByCursorResult<Entity>> {
81+
82+
const {
83+
model, order, after, before, first, last,
84+
findAll = (query => config.model.findAll(query)),
85+
...passDown
86+
} = config;
87+
88+
if (!order || order.length === 0) {
89+
throw new Error(`'order' must be specified`);
90+
}
91+
92+
if (after && before) {
93+
// TODO
94+
throw new Error(`Having both 'before' and 'after' is not currently supported.`);
95+
}
96+
97+
if (first != null && last != null) {
98+
throw new Error(`Having both 'first' and 'last' is not currently supported.`);
99+
}
100+
101+
const primaryKeys: string[] = getPrimaryColumns(model)
102+
// sort by db name to ensure they are in the same order between restarts
103+
.sort((c1, c2) => c1.field.localeCompare(c2.field))
104+
.map(col => col.fieldName);
105+
106+
const limit = first || last;
107+
if (!Number.isSafeInteger(limit)) {
108+
throw new Error(`'first' and 'last' must be safe integers, and one of them must be provided.`);
109+
}
110+
111+
if (limit < 0) {
112+
throw new Error(`'first' and 'last' cannot be < 0`);
113+
}
114+
115+
// sort by PK last to ensure the [where PK] (see below) works on a consistent dataset.
116+
const pkOrderBy: OrderTuple[] = primaryKeys.map(pk => [pk, 'ASC']);
117+
const sortOrder: OrderTuple[] = [...order, ...pkOrderBy];
118+
119+
const queryMetadata: QueryMetadata<Entity> = {
120+
isLast: last != null,
121+
limit,
122+
sortOrder,
123+
after,
124+
before,
125+
findAll,
126+
passDown,
127+
};
128+
129+
const { nodes, hasMoreNodes } = await getPage<Entity>(queryMetadata);
130+
131+
return {
132+
nodes,
133+
hasNextPage: () => hasNextPage(queryMetadata, hasMoreNodes),
134+
hasPreviousPage: () => hasPreviousPage(queryMetadata, hasMoreNodes),
135+
};
136+
}
137+
138+
/*
139+
hasPreviousPage is used to indicate whether more edges exist prior to the set defined by the clients arguments.
140+
141+
1. If last is set:
142+
a. Let edges be the result of calling ApplyCursorsToEdges(allEdges, before, after).
143+
b. If edges contains more than last elements return true, otherwise false.
144+
2. If after is set:
145+
a. If the server can efficiently determine that elements exist prior to after, return true.
146+
3. Return false.
147+
*/
148+
function hasPreviousPage(queryMetadata, hasMoreNodes) {
149+
150+
if (queryMetadata.isLast) {
151+
return hasMoreNodes;
152+
}
153+
154+
if (queryMetadata.after) {
155+
return getPage({
156+
...queryMetadata,
157+
158+
before: queryMetadata.after,
159+
isLast: true,
160+
161+
sortOrder: queryMetadata.sortOrder,
162+
after: null,
163+
164+
// we take 0 items because getPage will by default take 1 more
165+
// for hasMoreNodes
166+
limit: 0,
167+
}).then(results => results.hasMoreNodes);
168+
}
169+
170+
return false;
171+
}
172+
173+
/*
174+
hasNextPage is used to indicate whether more edges exist following the set defined by the clients arguments.
175+
176+
1. If first is set:
177+
a. Let edges be the result of calling ApplyCursorsToEdges(allEdges, before, after).
178+
b. If edges contains more than first elements return true, otherwise false.
179+
2. If before is set:
180+
a. If the server can efficiently determine that elements exist following before, return true.
181+
3. Return false.
182+
*/
183+
function hasNextPage(queryMetadata, hasMoreNodes) {
184+
185+
if (!queryMetadata.isLast) {
186+
return hasMoreNodes;
187+
}
188+
189+
if (queryMetadata.before) {
190+
return getPage({
191+
...queryMetadata,
192+
after: queryMetadata.before,
193+
isLast: false,
194+
195+
sortOrder: queryMetadata.sortOrder,
196+
before: null,
197+
198+
// we take 0 items because getPage will by default take 1 more
199+
// for hasMoreNodes
200+
limit: 0,
201+
}).then(results => {
202+
return results.hasMoreNodes;
203+
});
204+
}
205+
206+
return false;
207+
}
208+
209+
function reverseOrder(order) {
210+
if (!order) {
211+
return order;
212+
}
213+
214+
return order.map(orderPart => {
215+
const direction = orderPart[1] === 'ASC' ? 'DESC' : 'ASC';
216+
217+
return [orderPart[0], direction];
218+
});
219+
}
220+
221+
enum CursorType {
222+
AFTER,
223+
BEFORE,
224+
}
225+
226+
async function getPage<Entity extends Model>(
227+
queryMetadata: QueryMetadata<Entity>,
228+
): Promise<{ nodes: Entity[], hasMoreNodes: boolean }> {
229+
230+
const { sortOrder, after, before, isLast, findAll, passDown } = queryMetadata;
231+
232+
const queryOrder = orderTupleToSequelizeOrder(isLast ? reverseOrder(sortOrder) : sortOrder);
233+
const query: FindOptions = {
234+
...passDown, // Transactionable & Logging
235+
limit: queryMetadata.limit,
236+
order: queryOrder,
237+
238+
// subqueries are not compatible with referencing a joined table in `order`
239+
// TODO: This should be fixed in Sequelize, need a bug report
240+
subQuery: queryOrder.find(item => item.length === 3) == null,
241+
};
242+
243+
/*
244+
* The basic idea to implement an `after: x` in SQL is to ORDER BY the results by a set of fields
245+
*
246+
* Then filter out the rows that are before `after` by filtering on each item of the ORDER BY clause
247+
*
248+
* e.g. If order by pk ASC:
249+
*
250+
* WHERE pk > after.pk
251+
*
252+
* e.g. If order by firstName ASC, pk ASC:
253+
*
254+
* WHERE firstName > after.firstName OR (firstName = after.firstName AND pk > after.pk)
255+
*
256+
* e.g. If ordering by firstName ASC, lastName ASC, pk ASC:
257+
*
258+
* WHERE firstName > after.firstName
259+
* OR (firstName = after.firstName AND (lastName > after.lastName
260+
* OR (lastName = after.lastName AND pk > after.pk)))
261+
*/
262+
263+
const wheres = [];
264+
265+
if (after != null) {
266+
wheres.push(buildOrderQuery(sortOrder, after, CursorType.AFTER));
267+
}
268+
269+
if (before != null) {
270+
wheres.push(buildOrderQuery(sortOrder, before, CursorType.BEFORE));
271+
}
272+
273+
if (wheres.length > 0) {
274+
query.where = wheres.length === 1 ? wheres[0] : Sequelize.and(...wheres);
275+
}
276+
277+
// get one more result than needed to check if there are still results after this
278+
query.limit += 1;
279+
280+
const currentPageResults: Entity[] = await findAll(query);
281+
282+
if (queryMetadata.isLast) {
283+
currentPageResults.reverse();
284+
}
285+
286+
const hasMoreResults = currentPageResults.length === queryMetadata.limit + 1;
287+
if (hasMoreResults) {
288+
if (queryMetadata.isLast) {
289+
currentPageResults.shift();
290+
} else {
291+
currentPageResults.pop();
292+
}
293+
}
294+
295+
return { nodes: currentPageResults, hasMoreNodes: hasMoreResults };
296+
}
297+
298+
function buildOrderQuery(orderBy: OrderTuple[], cursor: Cursor, cursorType: CursorType) {
299+
const operators = cursorType === CursorType.AFTER ? {
300+
ASC: Op.gt,
301+
DESC: Op.lt,
302+
} : {
303+
ASC: Op.lt,
304+
DESC: Op.gt,
305+
};
306+
307+
let orderQuery;
308+
309+
// we build the sort order from the inside out (starting with the last item, to the first)
310+
{
311+
// very last item: orderQuery = pk > after.pk
312+
const lastSortEntry = orderBy[orderBy.length - 1];
313+
const [sortColumn, orderDirection] = lastSortEntry;
314+
315+
const operator = operators[orderDirection];
316+
317+
if (!(sortColumn in cursor)) {
318+
throw new Error(`cursor is missing key ${sortColumn}`);
319+
}
320+
321+
orderQuery = {
322+
[sortColumn]: { [operator]: cursor[sortColumn] },
323+
};
324+
}
325+
326+
// subsequent items:
327+
// orderQuery = lastName > after.lastName OR (lastName = after.lastName AND {orderQuery})
328+
for (let i = orderBy.length - 2; i >= 0; i--) {
329+
const [sortColumn, orderDirection]: [string, string] = orderBy[i];
330+
const operator = operators[orderDirection];
331+
332+
if (!(sortColumn in cursor)) {
333+
throw new Error(`cursor is missing key ${sortColumn}`);
334+
}
335+
336+
// orderQuery
337+
orderQuery = Sequelize.or(
338+
{ [sortColumn]: { [operator]: cursor[sortColumn] } },
339+
// @ts-ignore
340+
Sequelize.and(
341+
{ [sortColumn]: cursor[sortColumn] },
342+
orderQuery,
343+
),
344+
);
345+
}
346+
347+
return orderQuery;
348+
}
349+
350+
// TODO: PR sequelize to support $association.column$ in `order` as they already support it in `where`
351+
export function orderTupleToSequelizeOrder(orders: OrderTuple[]): SequelizeOrderItem[] {
352+
return orders.map(order => {
353+
const [column, direction] = order;
354+
355+
const associationReference = matchAssociationReference(column);
356+
if (!associationReference) {
357+
return order;
358+
}
359+
360+
return [
361+
/* association name */ associationReference[0],
362+
/* association column */ associationReference[1],
363+
direction,
364+
];
365+
});
366+
}

0 commit comments

Comments
 (0)