Skip to content

Commit c75676d

Browse files
committed
feat: Add pagination max page size configuration
1 parent 28b5ab9 commit c75676d

6 files changed

Lines changed: 172 additions & 2 deletions

File tree

packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ export abstract class BasePostgresEntityDatabaseAdapter<
149149
TFields extends Record<string, any>,
150150
TIDField extends keyof TFields,
151151
> extends EntityDatabaseAdapter<TFields, TIDField> {
152+
/**
153+
* Get the maximum page size for pagination.
154+
* @returns maximum page size if configured, undefined otherwise
155+
*/
156+
get paginationMaxPageSize(): number | undefined {
157+
return undefined;
158+
}
152159
/**
153160
* Fetch many objects matching the conjunction of where clauses constructed from
154161
* specified field equality operands.

packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FieldTransformer, FieldTransformerMap } from '@expo/entity';
1+
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
22
import { Knex } from 'knex';
33

44
import {
@@ -10,13 +10,25 @@ import {
1010
TableQuerySelectionModifiersWithOrderByRaw,
1111
} from './BasePostgresEntityDatabaseAdapter';
1212
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
13+
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
1314
import { SQLFragment } from './SQLOperator';
1415
import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync';
1516

1617
export class PostgresEntityDatabaseAdapter<
1718
TFields extends Record<string, any>,
1819
TIDField extends keyof TFields,
1920
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
21+
constructor(
22+
entityConfiguration: EntityConfiguration<TFields, TIDField>,
23+
private readonly adapterConfiguration: PostgresEntityDatabaseAdapterConfiguration = {},
24+
) {
25+
super(entityConfiguration);
26+
}
27+
28+
override get paginationMaxPageSize(): number | undefined {
29+
return this.adapterConfiguration.paginationMaxPageSize;
30+
}
31+
2032
protected getFieldTransformerMap(): FieldTransformerMap {
2133
return new Map<string, FieldTransformer<any>>([
2234
[

packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ import { installEntityTableDataCoordinatorExtensions } from './extensions/Entity
1010
import { installReadonlyEntityExtensions } from './extensions/ReadonlyEntityExtensions';
1111
import { installViewerScopedEntityCompanionExtensions } from './extensions/ViewerScopedEntityCompanionExtensions';
1212

13+
export interface PostgresEntityDatabaseAdapterConfiguration {
14+
/**
15+
* Maximum page size for pagination (first/last parameters).
16+
* If not specified, no limit is enforced.
17+
*/
18+
paginationMaxPageSize?: number;
19+
}
20+
1321
export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
22+
constructor(private readonly configuration: PostgresEntityDatabaseAdapterConfiguration = {}) {}
1423
getExtensionsKey(): string {
1524
return 'PostgresEntityDatabaseAdapterProvider';
1625
}
@@ -25,6 +34,6 @@ export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAda
2534
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
2635
entityConfiguration: EntityConfiguration<TFields, TIDField>,
2736
): EntityDatabaseAdapter<TFields, TIDField> {
28-
return new PostgresEntityDatabaseAdapter(entityConfiguration);
37+
return new PostgresEntityDatabaseAdapter(entityConfiguration, this.configuration);
2938
}
3039
}

packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter<
129129
}
130130

131131
describe(BasePostgresEntityDatabaseAdapter, () => {
132+
describe('get paginationMaxPageSize', () => {
133+
it('returns the default paginationMaxPageSize (undefined)', () => {
134+
const adapter = new TestEntityDatabaseAdapter({});
135+
expect(adapter.paginationMaxPageSize).toBe(undefined);
136+
});
137+
});
138+
132139
describe('fetchManyByFieldEqualityConjunction', () => {
133140
it('transforms object', async () => {
134141
const queryContext = instance(mock(EntityQueryContext));

packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,17 +330,30 @@ export class EntityKnexDataManager<
330330
const idField = this.entityConfiguration.idField;
331331

332332
// Validate pagination arguments
333+
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
333334
const isForward = 'first' in args;
334335
if (isForward) {
335336
assert(
336337
Number.isInteger(args.first) && args.first > 0,
337338
'first must be an integer greater than 0',
338339
);
340+
if (maxPageSize !== undefined) {
341+
assert(
342+
args.first <= maxPageSize,
343+
`first must not exceed maximum page size of ${maxPageSize}`,
344+
);
345+
}
339346
} else {
340347
assert(
341348
Number.isInteger(args.last) && args.last > 0,
342349
'last must be an integer greater than 0',
343350
);
351+
if (maxPageSize !== undefined) {
352+
assert(
353+
args.last <= maxPageSize,
354+
`last must not exceed maximum page size of ${maxPageSize}`,
355+
);
356+
}
344357
}
345358

346359
const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;

packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
when,
1919
} from 'ts-mockito';
2020

21+
import { OrderByOrdering } from '../../BasePostgresEntityDatabaseAdapter';
22+
import { PaginationStrategy } from '../../PaginationStrategy';
2123
import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter';
2224
import {
2325
TestEntity,
@@ -253,4 +255,124 @@ describe(EntityKnexDataManager, () => {
253255
});
254256
});
255257
});
258+
259+
describe('pagination', () => {
260+
describe('max page size validation', () => {
261+
it('should throw when first exceeds maxPageSize', async () => {
262+
const queryContext = instance(mock<EntityQueryContext>());
263+
const databaseAdapterMock = mock<
264+
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
265+
>(PostgresEntityDatabaseAdapter);
266+
267+
// Configure the adapter to return a maxPageSize of 100
268+
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
269+
270+
const entityDataManager = new EntityKnexDataManager(
271+
testEntityConfiguration,
272+
instance(databaseAdapterMock),
273+
new NoOpEntityMetricsAdapter(),
274+
TestEntity.name,
275+
);
276+
277+
await expect(
278+
entityDataManager.loadPageAsync(queryContext, {
279+
first: 101,
280+
pagination: {
281+
strategy: PaginationStrategy.STANDARD,
282+
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
283+
},
284+
}),
285+
).rejects.toThrow('first must not exceed maximum page size of 100');
286+
});
287+
288+
it('should throw when last exceeds maxPageSize', async () => {
289+
const queryContext = instance(mock<EntityQueryContext>());
290+
const databaseAdapterMock = mock<
291+
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
292+
>(PostgresEntityDatabaseAdapter);
293+
294+
// Configure the adapter to return a maxPageSize of 100
295+
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
296+
297+
const entityDataManager = new EntityKnexDataManager(
298+
testEntityConfiguration,
299+
instance(databaseAdapterMock),
300+
new NoOpEntityMetricsAdapter(),
301+
TestEntity.name,
302+
);
303+
304+
await expect(
305+
entityDataManager.loadPageAsync(queryContext, {
306+
last: 101,
307+
pagination: {
308+
strategy: PaginationStrategy.STANDARD,
309+
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
310+
},
311+
}),
312+
).rejects.toThrow('last must not exceed maximum page size of 100');
313+
});
314+
315+
it('should allow first/last within maxPageSize', async () => {
316+
const queryContext = instance(mock<EntityQueryContext>());
317+
const databaseAdapterMock = mock<
318+
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
319+
>(PostgresEntityDatabaseAdapter);
320+
321+
// Configure the adapter to return a maxPageSize of 100
322+
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100);
323+
when(
324+
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
325+
).thenResolve([]);
326+
327+
const entityDataManager = new EntityKnexDataManager(
328+
testEntityConfiguration,
329+
instance(databaseAdapterMock),
330+
new NoOpEntityMetricsAdapter(),
331+
TestEntity.name,
332+
);
333+
334+
// This should not throw
335+
const result = await entityDataManager.loadPageAsync(queryContext, {
336+
first: 100,
337+
pagination: {
338+
strategy: PaginationStrategy.STANDARD,
339+
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
340+
},
341+
});
342+
343+
expect(result.edges).toEqual([]);
344+
});
345+
346+
it('should allow pagination when maxPageSize is not configured', async () => {
347+
const queryContext = instance(mock<EntityQueryContext>());
348+
const databaseAdapterMock = mock<
349+
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
350+
>(PostgresEntityDatabaseAdapter);
351+
352+
// Configure the adapter to return undefined for maxPageSize
353+
when(databaseAdapterMock.paginationMaxPageSize).thenReturn(undefined);
354+
when(
355+
databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()),
356+
).thenResolve([]);
357+
358+
const entityDataManager = new EntityKnexDataManager(
359+
testEntityConfiguration,
360+
instance(databaseAdapterMock),
361+
new NoOpEntityMetricsAdapter(),
362+
TestEntity.name,
363+
);
364+
365+
// This should not throw even with a large page size
366+
const result = await entityDataManager.loadPageAsync(queryContext, {
367+
first: 10000,
368+
pagination: {
369+
strategy: PaginationStrategy.STANDARD,
370+
orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }],
371+
},
372+
});
373+
374+
expect(result.edges).toEqual([]);
375+
});
376+
});
377+
});
256378
});

0 commit comments

Comments
 (0)