Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle cursor pagination #878

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions packages/_example/src/forest/typings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': {
Expand Down
7 changes: 6 additions & 1 deletion packages/agent/src/utils/context-filter-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default class SchemaGeneratorCollection {
isVirtual: false,
name: collection.name,
onlyForRelationships: false,
paginationType: 'page',
paginationType: collection.paginationType,
segments: this.buildSegments(collection),
};
}
Expand Down
26 changes: 26 additions & 0 deletions packages/agent/src/utils/query-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Collection,
ConditionTree,
ConditionTreeValidator,
Cursor,
Page,
Projection,
ProjectionFactory,
Expand Down Expand Up @@ -194,4 +195,29 @@ 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 before = query.starting_before;
const after = query.starting_after;

if (Number.isNaN(itemsPerPage) || itemsPerPage <= 0)
throw new ValidationError(`Invalid cursor pagination [limit: ${itemsPerPage}]`);

if (!before && !after)
throw new ValidationError(
// eslint-disable-next-line max-len
'Invalid cursor pagination, you should have at least "starting_before" or "starting_after" cursor set.',
);

return new Cursor(before, after, itemsPerPage);
}
}
36 changes: 36 additions & 0 deletions packages/agent/test/utils/context-filter-factory.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ConditionTreeBranch,
ConditionTreeLeaf,
Cursor,
Page,
PaginatedFilter,
Sort,
Expand Down Expand Up @@ -91,4 +92,39 @@ describe('FilterFactory', () => {
);
});
});

describe('with pagination cursor', () => {
const setupContextWithAllFeatures = () => {
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: 1,
},
},
});

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(undefined, '1', 10));
expect(filter.page).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
});
});
51 changes: 51 additions & 0 deletions packages/agent/test/utils/query-string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,57 @@ 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.after).toEqual('3');
});

describe('when context does not provide the limit parameters', () => {
test('should return the default limit 15', () => {
const context = createMockContext({
customProperties: { query: { starting_before: 2 } },
});

const cursor = QueryStringParser.parseCursor(context);

expect(cursor.limit).toEqual(15);
expect(cursor.before).toEqual('2');
});
});

describe('when context provides invalid values', () => {
test('should return a ValidationError error on bad pageSize', () => {
const context = createMockContext({
customProperties: { query: { 'page[size]': -5, starting_after: 1 } },
});

const fn = () => QueryStringParser.parseCursor(context);

expect(fn).toThrow('Invalid cursor pagination [limit: -5]');
});

test('should return a ValidationError error on no cursor present', () => {
const context = createMockContext({
customProperties: { query: { 'page[size]': 5 } },
});

const fn = () => QueryStringParser.parseCursor(context);

expect(fn).toThrow(
// eslint-disable-next-line max-len
'Invalid cursor pagination, you should have at least "starting_before" or "starting_after" cursor set.',
);
});
});
});

describe('parseSort', () => {
test('should sort by pk ascending when not sort is given', () => {
const context = createMockContext({
Expand Down
4 changes: 3 additions & 1 deletion packages/datasource-toolkit/src/base-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/datasource-toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 3 additions & 1 deletion packages/datasource-toolkit/src/interfaces/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +23,8 @@ export interface Collection {
get name(): string;
get schema(): CollectionSchema;

paginationType: PaginationType;

execute(
caller: Caller,
name: string,
Expand Down
13 changes: 13 additions & 0 deletions packages/datasource-toolkit/src/interfaces/query/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type PlainCursor = { cursor: string; limit: number };

export default class Cursor {
before: string;
after: string;
limit: number;

constructor(before?: string, after?: string, limit?: number) {
this.after = after;
this.before = before;
this.limit = limit;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we choose the definition? Could the interface be more lax? (not that easy)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Elasticsearch we have two ways:

"pit": {
    "id":  "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==", 
    "keep_alive": "1m"
  },

We could certainly do something about it in the future if we want to take this option for other DS.

Original file line number Diff line number Diff line change
@@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤝


export type PaginatedFilterComponents = FilterComponents & {
sort?: Sort;
page?: Page;
cursor?: Cursor;
};

export type PlainPaginatedFilter = PlainFilter & {
sort?: Array<PlainSortClause>;
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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/datasource-toolkit/test/__factories__/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -32,4 +33,5 @@ export default CollectionFactory.define(() => ({
update: jest.fn(),
delete: jest.fn(),
aggregate: jest.fn(),
paginationType: 'page' as PaginationType,
}));
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/forestadmin-client/src/schema/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PrimitiveTypes } from '@forestadmin/datasource-toolkit';
import type { PaginationType, PrimitiveTypes } from '@forestadmin/datasource-toolkit';

export type ForestSchema = {
collections: ForestServerCollection[];
Expand Down Expand Up @@ -26,7 +26,7 @@ export type ForestServerCollection = {
isSearchable: boolean;
isVirtual: false;
onlyForRelationships: boolean;
paginationType: 'page';
paginationType: PaginationType;
actions: Array<ForestServerAction>;
fields: Array<ForestServerField>;
segments: Array<ForestServerSegment>;
Expand Down
Loading