diff --git a/README.md b/README.md index 8ba8ffd..287d021 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,17 @@ users.findMany( ) ``` +The negative value for `take` is also supported to have backward pagination: + +```ts +users.findMany( + (q) => q.where({ email: (email) => email.includes('@google.com') }), + { + take: -5, + }, +) +```` + ### Cursor-based pagination Provide a reference to the record of the same collection as the `cursor` property for cursor-based pagination. diff --git a/src/collection.ts b/src/collection.ts index 115b52a..e1251f4 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -35,14 +35,13 @@ export type CollectionOptions = { extensions?: Array } -export type PaginationOptions = - | OffsetPaginationOptions - | CursorPaginationOptions - -export interface OffsetPaginationOptions { - cursor?: never +export interface PaginationOptions { /** - * A number of matching records to take (after the skip). + * A reference to a record to use as a cursor to start the querying from. + */ + cursor?: RecordType> + /** + * A number of matching records to take (after `skip`, if any). */ take?: number /** @@ -51,18 +50,6 @@ export interface OffsetPaginationOptions { skip?: number } -export interface CursorPaginationOptions { - skip?: never - /** - * A reference to a record to use as a cursor to start the querying from. - */ - cursor: RecordType> - /** - * A number of matching records to take (after the skip). - */ - take?: number -} - export interface UpdateOptions { data: UpdateFunction } @@ -619,12 +606,34 @@ export class Collection { > { const { take, cursor, skip } = options + invariant( + typeof skip !== 'undefined' ? Number.isInteger(skip) && skip >= 0 : true, + 'Failed to query the collection: expected the "skip" pagination option to be a number larger or equal to 0 but got %j', + skip, + ) + let taken = 0 let skipped = 0 - let store = this.#records + + // if (cursor != null) { + // const cursorIndex = store.findIndex((record) => { + // return record[kPrimaryKey] === cursor[kPrimaryKey] + // }) + + // if (cursorIndex === -1) { + // return + // } + + // store = store.slice(cursorIndex + 1) + // } + + const shouldTake = Math.abs(take ?? Infinity) + const delta = take && take < 0 ? -1 : 1 + let start = delta === 1 ? 0 : this.#records.length - 1 + const end = delta === 1 ? this.#records.length : -1 if (cursor != null) { - const cursorIndex = store.findIndex((record) => { + const cursorIndex = this.#records.findIndex((record) => { return record[kPrimaryKey] === cursor[kPrimaryKey] }) @@ -632,10 +641,12 @@ export class Collection { return } - store = store.slice(cursorIndex + 1) + start = cursorIndex } - for (const record of store) { + for (let i = start; i !== end; i += delta) { + const record = this.#records[i] + if (query.test(record)) { if (skip != null) { if (skipped < skip) { @@ -648,7 +659,7 @@ export class Collection { taken++ } - if (taken >= (take ?? Infinity)) { + if (taken >= shouldTake) { break } } diff --git a/tests/pagination-cursor.test.ts b/tests/pagination-cursor.test.ts index 82be9cc..7c5c206 100644 --- a/tests/pagination-cursor.test.ts +++ b/tests/pagination-cursor.test.ts @@ -31,7 +31,7 @@ it('returns all the matching records after the cursor', async () => { expect( users.findMany(undefined, { cursor }), 'Supports match-all queries', - ).toEqual([{ id: 8 }, { id: 9 }, { id: 10 }]) + ).toEqual([{ id: 7 }, { id: 8 }, { id: 9 }, { id: 10 }]) }) it('returns the `take` number of results after the cursor', async () => { @@ -45,5 +45,73 @@ it('returns the `take` number of results after the cursor', async () => { expect( users.findMany(undefined, { cursor, take: 3 }), 'Supports match-all queries', + ).toEqual([{ id: 7 }, { id: 8 }, { id: 9 }]) +}) + +it('supports skipping the cursor', async () => { + const users = new Collection({ schema: userSchema }) + await users.createMany(10, (index) => ({ + id: index + 1, + })) + + const cursor = users.findFirst((q) => q.where({ id: 7 }))! + + expect( + users.findMany(undefined, { cursor, skip: 1, take: 3 }), + 'Supports match-all queries', ).toEqual([{ id: 8 }, { id: 9 }, { id: 10 }]) }) + +it('supports negative values for `take`', async () => { + const users = new Collection({ schema: userSchema }) + await users.createMany(10, (index) => ({ + id: index + 1, + })) + + const cursor = users.findFirst((q) => q.where({ id: 10 })) + + expect( + users.findMany(undefined, { + cursor, + take: -3, + }), + ).toEqual([{ id: 10 }, { id: 9 }, { id: 8 }]) + + expect( + users.findMany(undefined, { + cursor, + skip: 1, + take: -3, + }), + 'Supports skipping the cursor', + ).toEqual([{ id: 9 }, { id: 8 }, { id: 7 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + cursor: users.findFirst((q) => q.where({ id: 8 })), + take: -3, + }), + ).toEqual([{ id: 8 }, { id: 7 }, { id: 6 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + cursor: users.findFirst((q) => q.where({ id: 8 })), + skip: 1, + take: -3, + }), + ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + cursor: users.findFirst((q) => q.where({ id: 3 })), + take: -3, + }), + ).toEqual([{ id: 3 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + cursor: users.findFirst((q) => q.where({ id: 2 })), + take: -3, + }), + ).toEqual([]) +}) diff --git a/tests/pagination-offset.test.ts b/tests/pagination-offset.test.ts index f470187..aff5658 100644 --- a/tests/pagination-offset.test.ts +++ b/tests/pagination-offset.test.ts @@ -111,3 +111,71 @@ it('returns an empty array if all the results were skipped', async () => { 'Supports regular queries', ).toEqual([]) }) + +it('throws if providing an invalid value for `skip`', async () => { + const users = new Collection({ schema: userSchema }) + await users.createMany(10, (index) => ({ + id: index + 1, + })) + + expect(() => users.findMany(undefined, { skip: -1 })).toThrow( + 'Failed to query the collection: expected the "skip" pagination option to be a number larger or equal to 0 but got -1', + ) + + expect(() => + users.findMany(undefined, { + // @ts-expect-error Intentionally invalid value. + skip: false, + }), + ).toThrow( + 'Failed to query the collection: expected the "skip" pagination option to be a number larger or equal to 0 but got false', + ) + + expect(() => + users.findMany(undefined, { + // @ts-expect-error Intentionally invalid value. + skip: null, + }), + ).toThrow( + 'Failed to query the collection: expected the "skip" pagination option to be a number larger or equal to 0 but got null', + ) + + expect(() => + users.findMany(undefined, { + // @ts-expect-error Intentionally invalid value. + skip: 'invalid', + }), + ).toThrow( + 'Failed to query the collection: expected the "skip" pagination option to be a number larger or equal to 0 but got "invalid"', + ) +}) + +it('supports negative values for `take`', async () => { + const users = new Collection({ schema: userSchema }) + await users.createMany(10, (index) => ({ + id: index + 1, + })) + + expect( + users.findMany(undefined, { take: -3 }), + 'Returns the last n results if `skip` is not provided', + ).toEqual([{ id: 10 }, { id: 9 }, { id: 8 }]) + + expect(users.findMany(undefined, { skip: 3, take: -3 })).toEqual([ + { id: 7 }, + { id: 6 }, + { id: 5 }, + ]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { take: -3 }), + 'Does not loop the results', + ).toEqual([{ id: 10 }, { id: 9 }, { id: 8 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + skip: 3, + take: -3, + }), + ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) +}) diff --git a/tests/types/find-many.test-d.ts b/tests/types/find-many.test-d.ts index e9c59cd..a47082a 100644 --- a/tests/types/find-many.test-d.ts +++ b/tests/types/find-many.test-d.ts @@ -79,7 +79,6 @@ it('supports offset-based pagination', () => { users.findMany(undefined, { skip: 5, - // @ts-expect-error cursor: users.findFirst(), }) }) @@ -94,7 +93,6 @@ it('supports cursor-based pagination', () => { users.findMany(undefined, { take: 5, cursor }) users.findMany(undefined, { - // @ts-expect-error cursor: users.findFirst(), skip: 5, })