From 37d8bcaf803b0d374c13a40a171bbe48b274e1e0 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 4 Oct 2025 16:25:01 +0200 Subject: [PATCH 1/4] test: add negative `take` pagination tests --- tests/pagination-cursor.test.ts | 28 ++++++++++++++++++++++++++++ tests/pagination-offset.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/tests/pagination-cursor.test.ts b/tests/pagination-cursor.test.ts index 82be9cc..6747834 100644 --- a/tests/pagination-cursor.test.ts +++ b/tests/pagination-cursor.test.ts @@ -47,3 +47,31 @@ it('returns the `take` number of results after the cursor', async () => { '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, + })) + + expect( + users.findMany(undefined, { + cursor: users.findFirst((q) => q.where({ id: 10 })), + take: -3, + }), + ).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: 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: 10 }, { id: 9 }, { id: 8 }]) +}) diff --git a/tests/pagination-offset.test.ts b/tests/pagination-offset.test.ts index f470187..c8345aa 100644 --- a/tests/pagination-offset.test.ts +++ b/tests/pagination-offset.test.ts @@ -111,3 +111,33 @@ it('returns an empty array if all the results were skipped', async () => { 'Supports regular queries', ).toEqual([]) }) + +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 })).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 }), + ).toEqual([{ id: 2 }, { id: 1 }, { id: 10 }]) + + expect( + users.findMany((q) => q.where({ id: (id) => id > 2 }), { + skip: 3, + take: -3, + }), + ).toEqual([{ id: 9 }, { id: 8 }, { id: 7 }]) +}) From 4f66bc4aa1586d3b1d5d9f23039fe12de52bd6d7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 4 Oct 2025 16:32:36 +0200 Subject: [PATCH 2/4] test: add invalid `skip` value tests --- tests/pagination-offset.test.ts | 57 +++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/tests/pagination-offset.test.ts b/tests/pagination-offset.test.ts index c8345aa..712a298 100644 --- a/tests/pagination-offset.test.ts +++ b/tests/pagination-offset.test.ts @@ -112,26 +112,63 @@ it('returns an empty array if all the results were skipped', async () => { ).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 })).toEqual([ - { id: 10 }, - { id: 9 }, - { id: 8 }, - ]) + 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(undefined, { skip: 3, take: -3 }), + 'Respects `skip`', + ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) expect( users.findMany((q) => q.where({ id: (id) => id > 2 }), { take: -3 }), + 'Supports looping the results', ).toEqual([{ id: 2 }, { id: 1 }, { id: 10 }]) expect( From ad4ecac3ee5e25ffe7f1a5ff1a15dc62d35b3843 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 4 Oct 2025 17:50:48 +0200 Subject: [PATCH 3/4] fix: support negative `take` values --- README.md | 11 ++++++++++ src/collection.ts | 36 +++++++++++++++++++++++++++------ tests/pagination-cursor.test.ts | 21 +++++++++++++------ tests/pagination-offset.test.ts | 15 +++++++------- 4 files changed, 64 insertions(+), 19 deletions(-) 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..8f52ed7 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -42,7 +42,7 @@ export type PaginationOptions = export interface OffsetPaginationOptions { cursor?: never /** - * A number of matching records to take (after the skip). + * A number of matching records to take (after `skip`, if any). */ take?: number /** @@ -619,12 +619,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 +654,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 +672,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 6747834..bfabfd2 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,7 +45,7 @@ it('returns the `take` number of results after the cursor', async () => { expect( users.findMany(undefined, { cursor, take: 3 }), 'Supports match-all queries', - ).toEqual([{ id: 8 }, { id: 9 }, { id: 10 }]) + ).toEqual([{ id: 7 }, { id: 8 }, { id: 9 }]) }) it('supports negative values for `take`', async () => { @@ -54,24 +54,33 @@ it('supports negative values for `take`', async () => { id: index + 1, })) + const cursor = users.findFirst((q) => q.where({ id: 10 })) + expect( users.findMany(undefined, { - cursor: users.findFirst((q) => q.where({ id: 10 })), + cursor, take: -3, }), - ).toEqual([{ id: 9 }, { id: 8 }, { id: 7 }]) + ).toEqual([{ id: 10 }, { id: 9 }, { id: 8 }]) expect( users.findMany((q) => q.where({ id: (id) => id > 2 }), { cursor: users.findFirst((q) => q.where({ id: 8 })), take: -3, }), - ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) + ).toEqual([{ id: 8 }, { id: 7 }, { id: 6 }]) expect( users.findMany((q) => q.where({ id: (id) => id > 2 }), { cursor: users.findFirst((q) => q.where({ id: 3 })), take: -3, }), - ).toEqual([{ id: 10 }, { id: 9 }, { id: 8 }]) + ).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 712a298..aff5658 100644 --- a/tests/pagination-offset.test.ts +++ b/tests/pagination-offset.test.ts @@ -161,20 +161,21 @@ it('supports negative values for `take`', async () => { '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 }), - 'Respects `skip`', - ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) + 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 }), - 'Supports looping the results', - ).toEqual([{ id: 2 }, { id: 1 }, { id: 10 }]) + '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: 9 }, { id: 8 }, { id: 7 }]) + ).toEqual([{ id: 7 }, { id: 6 }, { id: 5 }]) }) From 895b0f1cd25fbc2a3558786338fad50ed06d31e1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 4 Oct 2025 17:53:07 +0200 Subject: [PATCH 4/4] fix: support `skip` in cursor-based pagination --- src/collection.ts | 23 +++++------------------ tests/pagination-cursor.test.ts | 31 +++++++++++++++++++++++++++++++ tests/types/find-many.test-d.ts | 2 -- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/collection.ts b/src/collection.ts index 8f52ed7..e1251f4 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -35,12 +35,11 @@ export type CollectionOptions = { extensions?: Array } -export type PaginationOptions = - | OffsetPaginationOptions - | CursorPaginationOptions - -export interface OffsetPaginationOptions { - cursor?: never +export interface PaginationOptions { + /** + * 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). */ @@ -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 } diff --git a/tests/pagination-cursor.test.ts b/tests/pagination-cursor.test.ts index bfabfd2..7c5c206 100644 --- a/tests/pagination-cursor.test.ts +++ b/tests/pagination-cursor.test.ts @@ -48,6 +48,20 @@ it('returns the `take` number of results after the cursor', async () => { ).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) => ({ @@ -63,6 +77,15 @@ it('supports negative values for `take`', async () => { }), ).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 })), @@ -70,6 +93,14 @@ it('supports negative values for `take`', async () => { }), ).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 })), 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, })