diff --git a/packages/_example/src/forest/typings.ts b/packages/_example/src/forest/typings.ts index 8e67d1b310..d0f06d4448 100644 --- a/packages/_example/src/forest/typings.ts +++ b/packages/_example/src/forest/typings.ts @@ -342,25 +342,25 @@ export type Schema = { 'dvd:rentalPrice': number; 'dvd:storeId': number; 'dvd:numberOfRentals': number; - 'dvd:store:id': number; - 'dvd:store:name': string; - 'dvd:store:ownerId': number; - 'dvd:store:ownerFullName': string; - 'dvd:store:owner:id': number; - 'dvd:store:owner:firstName': string; - 'dvd:store:owner:lastName': string; - 'dvd:store:owner:fullName': string; 'rental:id': number; 'rental:startDate': string; 'rental:endDate': string; 'rental:customerId': number; 'rental:numberOfDays': number; + 'dvd:store:id': number; + 'dvd:store:name': string; + 'dvd:store:ownerId': number; + 'dvd:store:ownerFullName': string; 'rental:customer:id': number; 'rental:customer:name': string; 'rental:customer:firstName': string; 'rental:customer:createdAt': string; 'rental:customer:updatedAt': string; 'rental:customer:deletedAt': string; + 'dvd:store:owner:id': number; + 'dvd:store:owner:firstName': string; + 'dvd:store:owner:lastName': string; + 'dvd:store:owner:fullName': string; }; }; 'owner': { diff --git a/packages/agent/src/utils/context-filter-factory.ts b/packages/agent/src/utils/context-filter-factory.ts index 34e9b02fb7..0da90af354 100644 --- a/packages/agent/src/utils/context-filter-factory.ts +++ b/packages/agent/src/utils/context-filter-factory.ts @@ -18,7 +18,12 @@ export default class ContextFilterFactory { ): PaginatedFilter { return new PaginatedFilter({ sort: QueryStringParser.parseSort(collection, context), - page: QueryStringParser.parsePagination(context), + page: + collection.paginationType === 'page' + ? QueryStringParser.parsePagination(context) + : undefined, + cursor: + collection.paginationType === 'cursor' ? QueryStringParser.parseCursor(context) : undefined, ...ContextFilterFactory.build(collection, context, scope), ...partialFilter, }); diff --git a/packages/agent/src/utils/forest-schema/generator-collection.ts b/packages/agent/src/utils/forest-schema/generator-collection.ts index c621b85f73..983d02fbc2 100644 --- a/packages/agent/src/utils/forest-schema/generator-collection.ts +++ b/packages/agent/src/utils/forest-schema/generator-collection.ts @@ -25,7 +25,7 @@ export default class SchemaGeneratorCollection { isVirtual: false, name: collection.name, onlyForRelationships: false, - paginationType: 'page', + paginationType: collection.paginationType, segments: this.buildSegments(collection), }; } diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index 2d148f3549..6720ac1b82 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -4,6 +4,7 @@ import { Collection, ConditionTree, ConditionTreeValidator, + Cursor, Page, Projection, ProjectionFactory, @@ -194,4 +195,23 @@ export default class QueryStringParser { throw new ValidationError(`Invalid sort: ${sortString}`); } } + + static parseCursor(context: Context): Cursor { + const { query, body } = context.request as any; + + const queryItemsPerPage = ( + body?.data?.attributes?.all_records_subset_query?.['page[size]'] ?? + query['page[size]'] ?? + DEFAULT_ITEMS_PER_PAGE + ).toString(); + + const itemsPerPage = Number.parseInt(queryItemsPerPage, 10); + const cursor = query.ending_before || query.starting_after; + const backward = !!query.ending_before; + + if (Number.isNaN(itemsPerPage) || itemsPerPage <= 0) + throw new ValidationError(`Invalid cursor pagination [limit: ${itemsPerPage}]`); + + return new Cursor(itemsPerPage, cursor, backward); + } } diff --git a/packages/agent/test/utils/context-filter-factory.test.ts b/packages/agent/test/utils/context-filter-factory.test.ts index 6885ef524d..ef4cae6da7 100644 --- a/packages/agent/test/utils/context-filter-factory.test.ts +++ b/packages/agent/test/utils/context-filter-factory.test.ts @@ -1,6 +1,7 @@ import { ConditionTreeBranch, ConditionTreeLeaf, + Cursor, Page, PaginatedFilter, Sort, @@ -91,4 +92,49 @@ describe('FilterFactory', () => { ); }); }); + + describe('with pagination cursor', () => { + const setupContextWithAllFeatures = (backward = false) => { + const collection = factories.collection.build({ + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + }, + }), + paginationType: 'cursor', + }); + + const context = createMockContext({ + customProperties: { + query: { + 'page[size]': 10, + starting_after: backward ? null : 1, + ending_before: backward ? 1 : null, + }, + }, + }); + + const scope = factories.conditionTreeLeaf.build(); + + return { context, collection, scope }; + }; + + test('should build a paginated filter from a given context', () => { + const { context, collection, scope } = setupContextWithAllFeatures(); + + const filter = ContextFilterFactory.buildPaginated(collection, context, scope); + + expect(filter.cursor).toEqual(new Cursor(10, '1')); + expect(filter.page).toBeUndefined(); + }); + + test('should build a paginated filter from a given backward context', () => { + const { context, collection, scope } = setupContextWithAllFeatures(true); + + const filter = ContextFilterFactory.buildPaginated(collection, context, scope); + + expect(filter.cursor).toEqual(new Cursor(10, '1', true)); + expect(filter.page).toBeUndefined(); + }); + }); }); diff --git a/packages/agent/test/utils/forest-schema/generator-collection.test.ts b/packages/agent/test/utils/forest-schema/generator-collection.test.ts index 3e65bfbb65..cf93c64061 100644 --- a/packages/agent/test/utils/forest-schema/generator-collection.test.ts +++ b/packages/agent/test/utils/forest-schema/generator-collection.test.ts @@ -37,6 +37,10 @@ describe('SchemaGeneratorCollection', () => { }), getForm: jest.fn().mockReturnValue(Promise.resolve(null)), }), + factories.collection.build({ + name: 'author', + paginationType: 'cursor', + }), ]); test('books should not be readonly and skip foreign keys', async () => { @@ -86,4 +90,10 @@ describe('SchemaGeneratorCollection', () => { relationship: 'HasOne', }); }); + + test('author should have pagination type cursor', async () => { + const schema = await SchemaGeneratorCollection.buildSchema(dataSource.getCollection('author')); + + expect(schema.paginationType).toEqual('cursor'); + }); }); diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index bd4159e6ad..f2d6b6a5b9 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -488,6 +488,46 @@ describe('QueryStringParser', () => { }); }); + describe('parseCursor', () => { + test('should return the pagination parameters', () => { + const context = createMockContext({ + customProperties: { query: { 'page[size]': 10, starting_after: 3 } }, + }); + + const cursor = QueryStringParser.parseCursor(context); + + expect(cursor.limit).toEqual(10); + expect(cursor.cursor).toEqual('3'); + expect(cursor.backward).toEqual(false); + }); + + describe('when context does not provide the limit parameters', () => { + test('should return the default limit 15', () => { + const context = createMockContext({ + customProperties: { query: { ending_before: 2 } }, + }); + + const cursor = QueryStringParser.parseCursor(context); + + expect(cursor.limit).toEqual(15); + expect(cursor.cursor).toEqual('2'); + expect(cursor.backward).toEqual(true); + }); + }); + + describe('when context provides invalid values', () => { + test('should return a ValidationError', () => { + const context = createMockContext({ + customProperties: { query: { 'page[size]': -5, starting_after: 1 } }, + }); + + const fn = () => QueryStringParser.parseCursor(context); + + expect(fn).toThrow('Invalid cursor pagination [limit: -5]'); + }); + }); + }); + describe('parseSort', () => { test('should sort by pk ascending when not sort is given', () => { const context = createMockContext({ diff --git a/packages/datasource-toolkit/src/base-collection.ts b/packages/datasource-toolkit/src/base-collection.ts index 4e708d7d7c..4b24adf9f2 100644 --- a/packages/datasource-toolkit/src/base-collection.ts +++ b/packages/datasource-toolkit/src/base-collection.ts @@ -3,7 +3,7 @@ import { Caller } from './interfaces/caller'; import { Chart } from './interfaces/chart'; import { Collection, DataSource } from './interfaces/collection'; import Aggregation, { AggregateResult } from './interfaces/query/aggregation'; -import PaginatedFilter from './interfaces/query/filter/paginated'; +import PaginatedFilter, { PaginationType } from './interfaces/query/filter/paginated'; import Filter from './interfaces/query/filter/unpaginated'; import Projection from './interfaces/query/projection'; import { RecordData } from './interfaces/record'; @@ -15,6 +15,8 @@ export default abstract class BaseCollection implements Collection { readonly schema: CollectionSchema; readonly nativeDriver: unknown; + paginationType: PaginationType = 'page'; + constructor(name: string, datasource: DataSource, nativeDriver: unknown = null) { this.dataSource = datasource; this.name = name; diff --git a/packages/datasource-toolkit/src/decorators/collection-decorator.ts b/packages/datasource-toolkit/src/decorators/collection-decorator.ts index 191ba64231..882bcbcd3e 100644 --- a/packages/datasource-toolkit/src/decorators/collection-decorator.ts +++ b/packages/datasource-toolkit/src/decorators/collection-decorator.ts @@ -3,7 +3,7 @@ import { Caller } from '../interfaces/caller'; import { Chart } from '../interfaces/chart'; import { Collection, DataSource } from '../interfaces/collection'; import Aggregation, { AggregateResult } from '../interfaces/query/aggregation'; -import PaginatedFilter from '../interfaces/query/filter/paginated'; +import PaginatedFilter, { PaginationType } from '../interfaces/query/filter/paginated'; import Filter from '../interfaces/query/filter/unpaginated'; import Projection from '../interfaces/query/projection'; import { CompositeId, RecordData } from '../interfaces/record'; @@ -33,6 +33,10 @@ export default class CollectionDecorator implements Collection { return this.childCollection.name; } + get paginationType(): PaginationType { + return this.childCollection.paginationType; + } + constructor(childCollection: Collection, dataSource: DataSource) { this.childCollection = childCollection; this.dataSource = dataSource; diff --git a/packages/datasource-toolkit/src/index.ts b/packages/datasource-toolkit/src/index.ts index 412c3d8f58..d53ecc0e93 100644 --- a/packages/datasource-toolkit/src/index.ts +++ b/packages/datasource-toolkit/src/index.ts @@ -16,6 +16,7 @@ export { default as ConditionTreeBranch } from './interfaces/query/condition-tre export { default as ConditionTreeLeaf } from './interfaces/query/condition-tree/nodes/leaf'; export { default as Filter } from './interfaces/query/filter/unpaginated'; export { default as Page } from './interfaces/query/page'; +export { default as Cursor } from './interfaces/query/cursor'; export { default as PaginatedFilter } from './interfaces/query/filter/paginated'; export { default as Projection } from './interfaces/query/projection'; export { default as Sort } from './interfaces/query/sort'; diff --git a/packages/datasource-toolkit/src/interfaces/collection.ts b/packages/datasource-toolkit/src/interfaces/collection.ts index c98439885f..c94cf5b4d8 100644 --- a/packages/datasource-toolkit/src/interfaces/collection.ts +++ b/packages/datasource-toolkit/src/interfaces/collection.ts @@ -2,7 +2,7 @@ import { ActionField, ActionResult } from './action'; import { Caller } from './caller'; import { Chart } from './chart'; import Aggregation, { AggregateResult } from './query/aggregation'; -import PaginatedFilter from './query/filter/paginated'; +import PaginatedFilter, { PaginationType } from './query/filter/paginated'; import Filter from './query/filter/unpaginated'; import Projection from './query/projection'; import { CompositeId, RecordData } from './record'; @@ -23,6 +23,8 @@ export interface Collection { get name(): string; get schema(): CollectionSchema; + paginationType: PaginationType; + execute( caller: Caller, name: string, diff --git a/packages/datasource-toolkit/src/interfaces/query/cursor.ts b/packages/datasource-toolkit/src/interfaces/query/cursor.ts new file mode 100644 index 0000000000..7961f130e4 --- /dev/null +++ b/packages/datasource-toolkit/src/interfaces/query/cursor.ts @@ -0,0 +1,13 @@ +export type PlainCursor = { limit: number; cursor: { before?: string; after?: string } }; + +export default class Cursor { + cursor: string; + backward: boolean; + limit: number; + + constructor(limit: number, cursor?: string, backward?: boolean) { + this.limit = limit; + this.cursor = cursor || null; + this.backward = backward ?? false; + } +} diff --git a/packages/datasource-toolkit/src/interfaces/query/filter/paginated.ts b/packages/datasource-toolkit/src/interfaces/query/filter/paginated.ts index 7f8caced19..4c2f6a2d95 100644 --- a/packages/datasource-toolkit/src/interfaces/query/filter/paginated.ts +++ b/packages/datasource-toolkit/src/interfaces/query/filter/paginated.ts @@ -1,25 +1,32 @@ import Filter, { FilterComponents, PlainFilter } from './unpaginated'; +import Cursor, { PlainCursor } from '../cursor'; import Page, { PlainPage } from '../page'; import Sort, { PlainSortClause } from '../sort'; +export type PaginationType = 'page' | 'cursor'; + export type PaginatedFilterComponents = FilterComponents & { sort?: Sort; page?: Page; + cursor?: Cursor; }; export type PlainPaginatedFilter = PlainFilter & { sort?: Array; page?: PlainPage; + cursor?: PlainCursor; }; export default class PaginatedFilter extends Filter { sort?: Sort; page?: Page; + cursor?: Cursor; constructor(parts: PaginatedFilterComponents) { super(parts); this.sort = parts.sort; this.page = parts.page; + this.cursor = parts.cursor; } override override(fields: PaginatedFilterComponents): PaginatedFilter { diff --git a/packages/datasource-toolkit/test/__factories__/collection.ts b/packages/datasource-toolkit/test/__factories__/collection.ts index 2ff33bdc2e..067a68908f 100644 --- a/packages/datasource-toolkit/test/__factories__/collection.ts +++ b/packages/datasource-toolkit/test/__factories__/collection.ts @@ -2,6 +2,7 @@ import { Factory } from 'fishery'; import collectionSchemaFactory from './schema/collection-schema'; +import { PaginationType } from '../../src'; import { ActionField } from '../../src/interfaces/action'; import { Collection } from '../../src/interfaces/collection'; import { ActionSchema } from '../../src/interfaces/schema'; @@ -32,4 +33,5 @@ export default CollectionFactory.define(() => ({ update: jest.fn(), delete: jest.fn(), aggregate: jest.fn(), + paginationType: 'page' as PaginationType, })); diff --git a/packages/datasource-toolkit/test/decorators/collection-decorator.test.ts b/packages/datasource-toolkit/test/decorators/collection-decorator.test.ts index ca2aff0d76..706c80f55c 100644 --- a/packages/datasource-toolkit/test/decorators/collection-decorator.test.ts +++ b/packages/datasource-toolkit/test/decorators/collection-decorator.test.ts @@ -269,6 +269,17 @@ describe('CollectionDecorator', () => { }); }); + describe('paginationType', () => { + it('calls the child paginationType', async () => { + const decoratedCollection = new DecoratedCollection( + factories.collection.build({ name: 'a name', paginationType: 'cursor' }), + factories.dataSource.build(), + ); + + expect(decoratedCollection.paginationType).toStrictEqual('cursor'); + }); + }); + describe('refineFilter', () => { it('should be the identity function', async () => { const decoratedCollection = new DecoratedCollection( diff --git a/packages/datasource-toolkit/test/interfaces/cursor.test.ts b/packages/datasource-toolkit/test/interfaces/cursor.test.ts new file mode 100644 index 0000000000..b56600c4bd --- /dev/null +++ b/packages/datasource-toolkit/test/interfaces/cursor.test.ts @@ -0,0 +1,35 @@ +import Cursor from '../../src/interfaces/query/cursor'; + +describe('Cursor', () => { + describe('simple declaration', () => { + describe('whith no cursors defined', () => { + test('should default cursor to null', () => { + const cursor = new Cursor(10); + + expect(cursor.limit).toEqual(10); + expect(cursor.cursor).toEqual(null); + expect(cursor.backward).toEqual(false); + }); + }); + + describe('when it is backward', () => { + test('should define right cursor', () => { + const cursor = new Cursor(10, 'abc', true); + + expect(cursor.limit).toEqual(10); + expect(cursor.cursor).toEqual('abc'); + expect(cursor.backward).toEqual(true); + }); + }); + + describe('when it is forward', () => { + test('should default to null before', () => { + const cursor = new Cursor(10, 'abc'); + + expect(cursor.limit).toEqual(10); + expect(cursor.cursor).toEqual('abc'); + expect(cursor.backward).toEqual(false); + }); + }); + }); +}); diff --git a/packages/forestadmin-client/src/schema/types.ts b/packages/forestadmin-client/src/schema/types.ts index b503cbfa63..dab4fb2efb 100644 --- a/packages/forestadmin-client/src/schema/types.ts +++ b/packages/forestadmin-client/src/schema/types.ts @@ -1,4 +1,4 @@ -import type { PrimitiveTypes } from '@forestadmin/datasource-toolkit'; +import type { PaginationType, PrimitiveTypes } from '@forestadmin/datasource-toolkit'; export type ForestSchema = { collections: ForestServerCollection[]; @@ -26,7 +26,7 @@ export type ForestServerCollection = { isSearchable: boolean; isVirtual: false; onlyForRelationships: boolean; - paginationType: 'page'; + paginationType: PaginationType; actions: Array; fields: Array; segments: Array;