From b6005bbe929ce09b0e088839d93b9a4f43fe07a7 Mon Sep 17 00:00:00 2001 From: Ahmed Anas Date: Fri, 4 Jul 2025 15:15:01 +0400 Subject: [PATCH 1/2] fix: encodePaginationTokens to encode plain objects --- src/utils/query.ts | 51 +++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/utils/query.ts b/src/utils/query.ts index 7b8bf20..b75c4a4 100644 --- a/src/utils/query.ts +++ b/src/utils/query.ts @@ -23,6 +23,19 @@ export type PaginationResponse = { hasNext: boolean; }; +/** + * Return true only for "simple" POJOs: `{}` created by object literals or + * `Object.create(null)`. Arrays, class instances, Dates, BSON objects, etc. + * will return false. + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + + + /** * Helper function to encode pagination tokens. * @@ -38,38 +51,34 @@ export type PaginationResponse = { * * @returns void */ -function encodePaginationTokens(params: PaginationParams, response: PaginationResponse): void { +function encodePaginationTokens( + params: PaginationParams, + response: PaginationResponse +): void { const shouldSecondarySortOnId = params.paginatedField !== '_id'; - if (response.previous) { + // ----- previous ---------------------------------------------------------- + if (response.previous && isPlainObject(response.previous)) { let previousPaginatedField = objectPath.get(response.previous, params.paginatedField); if (params.sortCaseInsensitive) { previousPaginatedField = previousPaginatedField?.toLowerCase?.() ?? ''; } - if (shouldSecondarySortOnId) { - if ( - typeof response.previous === 'object' && - response.previous !== null && - '_id' in response.previous - ) { - response.previous = bsonUrlEncoding.encode([previousPaginatedField, response.previous._id]); - } - } else { - response.previous = bsonUrlEncoding.encode(previousPaginatedField); - } + + response.previous = shouldSecondarySortOnId && '_id' in response.previous + ? bsonUrlEncoding.encode([previousPaginatedField, response.previous._id]) + : bsonUrlEncoding.encode(previousPaginatedField); } - if (response.next) { + + // ----- next -------------------------------------------------------------- + if (response.next && isPlainObject(response.next)) { let nextPaginatedField = objectPath.get(response.next, params.paginatedField); if (params.sortCaseInsensitive) { nextPaginatedField = nextPaginatedField?.toLowerCase?.() ?? ''; } - if (shouldSecondarySortOnId) { - if (typeof response.next === 'object' && response.next !== null && '_id' in response.next) { - response.next = bsonUrlEncoding.encode([nextPaginatedField, response.next._id]); - } - } else { - response.next = bsonUrlEncoding.encode(nextPaginatedField); - } + + response.next = shouldSecondarySortOnId && '_id' in response.next + ? bsonUrlEncoding.encode([nextPaginatedField, response.next._id]) + : bsonUrlEncoding.encode(nextPaginatedField); } } From b2cbc8fea958a5bcd9ecffba5b4d10cf808bd4c5 Mon Sep 17 00:00:00 2001 From: Ahmed Anas Date: Fri, 4 Jul 2025 16:14:12 +0400 Subject: [PATCH 2/2] feat: unit tests --- test/utils/query.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/utils/query.test.ts b/test/utils/query.test.ts index 321e8f8..16a4ed7 100644 --- a/test/utils/query.test.ts +++ b/test/utils/query.test.ts @@ -38,6 +38,26 @@ describe('encodePaginationTokens', () => { expect(response.previous).toEqual(bsonUrlEncoding.encode(['Test', '456'])); }); + it('encodes tokens when cursor is a plain object that lacks _id', () => { + const params = { + paginatedField: 'name', + }; + + const response = { + results: [], + previous: { name: 'Alpha' }, // ⬅️ no _id + hasPrevious: false, + next: { name: 'Beta' }, // ⬅️ no _id + hasNext: false, + } as any; + + encodePaginationTokens(params, response); + + expect(response.previous).toEqual(bsonUrlEncoding.encode('Alpha')); + expect(response.next).toEqual(bsonUrlEncoding.encode('Beta')); + }); + + describe('generateCursorQuery', () => { it('generates an empty cursor query when no next or previous cursor is provided', () => { const params = {