From cd90b01a1c4f298850bbf1172e234e9b9e3ee0a3 Mon Sep 17 00:00:00 2001 From: Kalle Ilves Date: Sat, 15 Feb 2025 12:47:26 +0200 Subject: [PATCH] implement makePaginateLazy --- README.md | 28 +++++ package-lock.json | 6 +- src/LazyPaginationConnection.ts | 82 ++++++++++++++ src/index.ts | 3 +- src/makePaginate.ts | 191 ++++++++++++++++++-------------- src/tests/makePaginate.test.ts | 54 ++++++++- src/tests/models.ts | 5 + 7 files changed, 283 insertions(+), 86 deletions(-) create mode 100644 src/LazyPaginationConnection.ts diff --git a/README.md b/README.md index eebc815..e3279dd 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,30 @@ const thirdResult = await Counter.paginate({ }); ``` +## Only fetching specific pagination connection attributes + +One pagination operation (including `edges`, `totalCount` and `pageInfo`) requires three database queries, but if you are only insterested in the `edges`, one database query is enough. The `makePaginateLazy` function can be used to create a "lazy evaluation" version of the `paginate` function. With this version, the `paginateLazy` function returns a `LazyPaginationConnection` object, containing methods `getEdges`, `getTotalCount`, and `getPageInfo`. These methods can be used to fetch the edges, total count, and page info, respectively: + +```javascript +import { makePaginateLazy } from 'sequelize-cursor-pagination'; + +Counter.paginateLazy = makePaginateLazy(Counter); + +// Same options are supported as with the regular paginate function +const connection = Counter.paginateLazy({ + limit: 10, +}); + +// Only one database query is performed in case we are only insterested in the edges +const edges = await connection.getEdges(); + +// Otherwise, we can fetch the total count and page info as well +const totalCount = await connection.getTotalCount(); +const pageInfo = await connection.getPageInfo(); +``` + +The database queries are cached, so there's no extra overhead when fetching the edges, total count, or page info multiple times. + ## TypeScript The library is written in TypeScript, so types are on the house! @@ -125,6 +149,10 @@ export class Counter extends Model< declare static paginate: ( options: PaginateOptions, ) => Promise>; + + declare static paginateLazy: ( + options: PaginateOptions, + ) => LazyPaginationConnection; } // ... diff --git a/package-lock.json b/package-lock.json index e5d3908..e781383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12181,9 +12181,9 @@ "dev": true }, "moment": { - "version": "2.29.3", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.3.tgz", - "integrity": "sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw==", + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", "dev": true }, "moment-timezone": { diff --git a/src/LazyPaginationConnection.ts b/src/LazyPaginationConnection.ts new file mode 100644 index 0000000..aa25203 --- /dev/null +++ b/src/LazyPaginationConnection.ts @@ -0,0 +1,82 @@ +import { PaginationEdge } from './types'; + +interface LazyPaginationConnectionOptions { + getEdgesPromise: () => Promise[]>; + getCursorCountPromise: () => Promise; + getTotalCountPromise: () => Promise; + isBefore: boolean; +} + +class CachedPromise { + #promiseGetter: () => Promise; + #cachedPromise: Promise | undefined; + + constructor(promiseGetter: () => Promise) { + this.#promiseGetter = promiseGetter; + } + + public get(): Promise { + if (this.#cachedPromise) { + return this.#cachedPromise; + } + + this.#cachedPromise = this.#promiseGetter(); + + return this.#cachedPromise; + } +} + +export default class LazyPaginationConnection { + #edgesCachedPromise: CachedPromise[]>; + #cursorCountCachedPromise: CachedPromise; + #totalCountCachedPromise: CachedPromise; + #isBefore: boolean; + + constructor(options: LazyPaginationConnectionOptions) { + this.#edgesCachedPromise = new CachedPromise(options.getEdgesPromise); + this.#cursorCountCachedPromise = new CachedPromise( + options.getCursorCountPromise, + ); + this.#totalCountCachedPromise = new CachedPromise( + options.getTotalCountPromise, + ); + this.#isBefore = options.isBefore; + } + + async getEdges(): Promise[]> { + return this.#edgesCachedPromise.get(); + } + + async getTotalCount(): Promise { + return this.#totalCountCachedPromise.get(); + } + + async getPageInfo() { + const [edges, totalCount, cursorCount] = await Promise.all([ + this.getEdges(), + this.getTotalCount(), + this.#getCursorCount(), + ]); + + const remaining = cursorCount - edges.length; + + const hasNextPage = + (!this.#isBefore && remaining > 0) || + (this.#isBefore && totalCount - cursorCount > 0); + + const hasPreviousPage = + (this.#isBefore && remaining > 0) || + (!this.#isBefore && totalCount - cursorCount > 0); + + return { + hasNextPage, + hasPreviousPage, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }; + } + + async #getCursorCount(): Promise { + return this.#cursorCountCachedPromise.get(); + } +} diff --git a/src/index.ts b/src/index.ts index 29cd597..53fcb8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export { default as makePaginate } from './makePaginate'; - +export { makePaginateLazy } from './makePaginate'; +export { default as LazyPaginationConnection } from './LazyPaginationConnection'; export * from './types'; diff --git a/src/makePaginate.ts b/src/makePaginate.ts index 44457a6..099b2a0 100644 --- a/src/makePaginate.ts +++ b/src/makePaginate.ts @@ -11,107 +11,114 @@ import { getCount, } from './utils'; +import LazyPaginationConnection from './LazyPaginationConnection'; + import { MakePaginateOptions, PaginateOptions, PaginationConnection, } from './types'; +function getLazyPaginationConnection( + modelClass: ModelStatic, + paginateOptions: PaginateOptions, + makePaginateOptions?: MakePaginateOptions, +) { + const primaryKeyField = + makePaginateOptions?.primaryKeyField ?? getPrimaryKeyFields(modelClass); + + const omitPrimaryKeyFromOrder = + makePaginateOptions?.omitPrimaryKeyFromOrder ?? false; + + const { + order: orderOption, + where, + after, + before, + limit, + ...restQueryOptions + } = paginateOptions; + + const normalizedOrder = normalizeOrder( + orderOption, + primaryKeyField, + omitPrimaryKeyFromOrder, + ); + + const order = before ? reverseOrder(normalizedOrder) : normalizedOrder; + + const cursor = after + ? parseCursor(after) + : before + ? parseCursor(before) + : null; + + const paginationQuery = cursor ? getPaginationQuery(order, cursor) : null; + + const paginationWhere: WhereOptions | undefined = paginationQuery + ? { [Op.and]: [paginationQuery, where] } + : where; + + const paginationQueryOptions = { + where: paginationWhere, + limit, + order, + ...restQueryOptions, + }; + + const totalCountQueryOptions = { + where, + ...restQueryOptions, + }; + + const cursorCountQueryOptions = { + where: paginationWhere, + ...restQueryOptions, + }; + + return new LazyPaginationConnection({ + getEdgesPromise: async () => { + const instances = await modelClass.findAll(paginationQueryOptions); + + if (before) { + instances.reverse(); + } + + return instances.map((node) => ({ + node, + cursor: createCursor(node, order), + })); + }, + getTotalCountPromise: () => getCount(modelClass, totalCountQueryOptions), + getCursorCountPromise: () => getCount(modelClass, cursorCountQueryOptions), + isBefore: Boolean(before), + }); +} + const makePaginate = ( model: ModelStatic, - options?: MakePaginateOptions, + makePaginateOptions?: MakePaginateOptions, ) => { - const primaryKeyField = - options?.primaryKeyField ?? getPrimaryKeyFields(model); - - const omitPrimaryKeyFromOrder = options?.omitPrimaryKeyFromOrder ?? false; - async function paginate( this: unknown, - queryOptions: PaginateOptions, + paginateOptions: PaginateOptions, ): Promise> { const modelClass: ModelStatic = isModelClass(this) ? this : model; - const { - order: orderOption, - where, - after, - before, - limit, - ...restQueryOptions - } = queryOptions; - - const normalizedOrder = normalizeOrder( - orderOption, - primaryKeyField, - omitPrimaryKeyFromOrder, + const connection = getLazyPaginationConnection( + modelClass, + paginateOptions, + makePaginateOptions, ); - const order = before ? reverseOrder(normalizedOrder) : normalizedOrder; - - const cursor = after - ? parseCursor(after) - : before - ? parseCursor(before) - : null; - - const paginationQuery = cursor ? getPaginationQuery(order, cursor) : null; - - const paginationWhere: WhereOptions | undefined = paginationQuery - ? { [Op.and]: [paginationQuery, where] } - : where; - - const paginationQueryOptions = { - where: paginationWhere, - limit, - order, - ...restQueryOptions, - }; - - const totalCountQueryOptions = { - where, - ...restQueryOptions, - }; - - const cursorCountQueryOptions = { - where: paginationWhere, - ...restQueryOptions, - }; - - const [instances, totalCount, cursorCount] = await Promise.all([ - modelClass.findAll(paginationQueryOptions), - getCount(modelClass, totalCountQueryOptions), - getCount(modelClass, cursorCountQueryOptions), + const [edges, totalCount, pageInfo] = await Promise.all([ + connection.getEdges(), + connection.getTotalCount(), + connection.getPageInfo(), ]); - if (before) { - instances.reverse(); - } - - const remaining = cursorCount - instances.length; - - const hasNextPage = - (!before && remaining > 0) || - (Boolean(before) && totalCount - cursorCount > 0); - - const hasPreviousPage = - (Boolean(before) && remaining > 0) || - (!before && totalCount - cursorCount > 0); - - const edges = instances.map((node) => ({ - node, - cursor: createCursor(node, order), - })); - - const pageInfo = { - hasNextPage, - hasPreviousPage, - startCursor: edges.length > 0 ? edges[0].cursor : null, - endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, - }; - return { totalCount, edges, @@ -122,4 +129,26 @@ const makePaginate = ( return paginate; }; +export function makePaginateLazy( + model: ModelStatic, + makePaginateOptions?: MakePaginateOptions, +) { + function paginateLazy( + this: unknown, + paginateOptions: PaginateOptions, + ) { + const modelClass: ModelStatic = isModelClass(this) + ? this + : model; + + return getLazyPaginationConnection( + modelClass, + paginateOptions, + makePaginateOptions, + ); + } + + return paginateLazy; +} + export default makePaginate; diff --git a/src/tests/makePaginate.test.ts b/src/tests/makePaginate.test.ts index 474d1ba..0b9333d 100644 --- a/src/tests/makePaginate.test.ts +++ b/src/tests/makePaginate.test.ts @@ -1,6 +1,6 @@ import { sequelize, Counter } from './models'; import { OrderConfig } from '../types'; -import makePaginate from '../makePaginate'; +import makePaginate, { makePaginateLazy } from '../makePaginate'; const expectCorrectPageInfoCursors = (result: any) => { expect(typeof result.pageInfo.startCursor === 'string').toBe(true); @@ -28,6 +28,7 @@ const generateTestData = () => { }; Counter.paginate = makePaginate(Counter); +Counter.paginateLazy = makePaginateLazy(Counter); describe('makePaginate', () => { beforeEach(async () => { @@ -217,3 +218,54 @@ describe('makePaginate', () => { expect(result.totalCount).toBe(2); }); }); + +describe('makeLazyPaginate', () => { + beforeEach(async () => { + jest.restoreAllMocks(); + await sequelize.sync({ force: true }); + }); + + it('paginates correctly', async () => { + await generateTestData(); + + const connection = Counter.paginateLazy({ limit: 2 }); + const result = await Counter.paginate({ limit: 2 }); + + const edges = await connection.getEdges(); + const pageInfo = await connection.getPageInfo(); + const totalCount = await connection.getTotalCount(); + + expect(edges).toEqual(result.edges); + expect(pageInfo).toEqual(result.pageInfo); + expect(totalCount).toEqual(result.totalCount); + }); + + it('no unnecessary database queries are performed', async () => { + jest.spyOn(Counter, 'findAll'); + jest.spyOn(Counter, 'count'); + + await generateTestData(); + + const connection = Counter.paginateLazy({ limit: 2 }); + + await connection.getEdges(); + + expect(Counter.findAll).toHaveBeenCalledTimes(1); + expect(Counter.count).not.toHaveBeenCalled(); + + await connection.getTotalCount(); + + expect(Counter.findAll).toHaveBeenCalledTimes(1); + expect(Counter.count).toHaveBeenCalledTimes(1); + + await connection.getPageInfo(); + + expect(Counter.findAll).toHaveBeenCalledTimes(1); + expect(Counter.count).toHaveBeenCalledTimes(2); + + await connection.getEdges(); + + expect(Counter.findAll).toHaveBeenCalledTimes(1); + }); +}); + diff --git a/src/tests/models.ts b/src/tests/models.ts index 9090f9a..29fad36 100644 --- a/src/tests/models.ts +++ b/src/tests/models.ts @@ -8,6 +8,7 @@ import { } from 'sequelize'; import { PaginateOptions, PaginationConnection } from '../types'; +import LazyPaginationConnection from '../LazyPaginationConnection'; export const sequelize = new Sequelize('sqlite::memory:'); @@ -22,6 +23,10 @@ export class Counter extends Model< declare static paginate: ( options: PaginateOptions, ) => Promise>; + + declare static paginateLazy: ( + options: PaginateOptions, + ) => LazyPaginationConnection; } Counter.init(