Skip to content

Commit 3ec4b5f

Browse files
authored
feat: Add pagination max page size configuration (#436)
# Why Currently, the Entity pagination system allows clients to request arbitrarily large page sizes using the `first` or `last` parameters in pagination queries. This can lead to: - Performance degradation when clients request excessively large pages - Potential memory issues on the server - Unpredictable query response times - Difficulty in capacity planning and resource allocation By adding configurable max page size limits, we can: - Protect backend services from resource exhaustion - Ensure predictable performance characteristics - Provide better control over API usage patterns - Maintain backward compatibility (no limit by default) # How The implementation adds a `paginationMaxPageSize` configuration option to the PostgresEntityDatabaseAdapterProvider that flows through the adapter hierarchy: 1. **Configuration Interface**: Added `PostgresEntityDatabaseAdapterConfiguration` with an optional `paginationMaxPageSize` field 2. **Provider Level**: `PostgresEntityDatabaseAdapterProvider` constructor now accepts the configuration object 3. **Adapter Level**: - `BasePostgresEntityDatabaseAdapter` defines a `paginationMaxPageSize` getter (defaults to `undefined`) - `PostgresEntityDatabaseAdapter` overrides it to return the configured value 4. **Validation**: `EntityKnexDataManager.loadPageAsync` validates `first`/`last` parameters against the max page size before executing queries The validation throws descriptive errors when limits are exceeded: - `"first must not exceed maximum page size of {limit}"` - `"last must not exceed maximum page size of {limit}"` Usage example: ```typescript const provider = new PostgresEntityDatabaseAdapterProvider({ paginationMaxPageSize: 100 }); ``` # Test Plan Added comprehensive unit tests in `EntityKnexDataManager-test.ts`.
1 parent f72cbca commit 3ec4b5f

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
@@ -324,17 +324,30 @@ export class EntityKnexDataManager<
324324
const idField = this.entityConfiguration.idField;
325325

326326
// Validate pagination arguments
327+
const maxPageSize = this.databaseAdapter.paginationMaxPageSize;
327328
const isForward = 'first' in args;
328329
if (isForward) {
329330
assert(
330331
Number.isInteger(args.first) && args.first > 0,
331332
'first must be an integer greater than 0',
332333
);
334+
if (maxPageSize !== undefined) {
335+
assert(
336+
args.first <= maxPageSize,
337+
`first must not exceed maximum page size of ${maxPageSize}`,
338+
);
339+
}
333340
} else {
334341
assert(
335342
Number.isInteger(args.last) && args.last > 0,
336343
'last must be an integer greater than 0',
337344
);
345+
if (maxPageSize !== undefined) {
346+
assert(
347+
args.last <= maxPageSize,
348+
`last must not exceed maximum page size of ${maxPageSize}`,
349+
);
350+
}
338351
}
339352

340353
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)