diff --git a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts index b7df2f655..bef05d097 100644 --- a/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/BasePostgresEntityDatabaseAdapter.ts @@ -149,6 +149,13 @@ export abstract class BasePostgresEntityDatabaseAdapter< TFields extends Record, TIDField extends keyof TFields, > extends EntityDatabaseAdapter { + /** + * Get the maximum page size for pagination. + * @returns maximum page size if configured, undefined otherwise + */ + get paginationMaxPageSize(): number | undefined { + return undefined; + } /** * Fetch many objects matching the conjunction of where clauses constructed from * specified field equality operands. diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts index 72ac639b7..d73483390 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapter.ts @@ -1,4 +1,4 @@ -import { FieldTransformer, FieldTransformerMap } from '@expo/entity'; +import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity'; import { Knex } from 'knex'; import { @@ -10,6 +10,7 @@ import { TableQuerySelectionModifiersWithOrderByRaw, } from './BasePostgresEntityDatabaseAdapter'; import { JSONArrayField, MaybeJSONArrayField } from './EntityFields'; +import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider'; import { SQLFragment } from './SQLOperator'; import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync'; @@ -17,6 +18,17 @@ export class PostgresEntityDatabaseAdapter< TFields extends Record, TIDField extends keyof TFields, > extends BasePostgresEntityDatabaseAdapter { + constructor( + entityConfiguration: EntityConfiguration, + private readonly adapterConfiguration: PostgresEntityDatabaseAdapterConfiguration = {}, + ) { + super(entityConfiguration); + } + + override get paginationMaxPageSize(): number | undefined { + return this.adapterConfiguration.paginationMaxPageSize; + } + protected getFieldTransformerMap(): FieldTransformerMap { return new Map>([ [ diff --git a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts index eca4ac113..5a4e97dff 100644 --- a/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts +++ b/packages/entity-database-adapter-knex/src/PostgresEntityDatabaseAdapterProvider.ts @@ -10,7 +10,16 @@ import { installEntityTableDataCoordinatorExtensions } from './extensions/Entity import { installReadonlyEntityExtensions } from './extensions/ReadonlyEntityExtensions'; import { installViewerScopedEntityCompanionExtensions } from './extensions/ViewerScopedEntityCompanionExtensions'; +export interface PostgresEntityDatabaseAdapterConfiguration { + /** + * Maximum page size for pagination (first/last parameters). + * If not specified, no limit is enforced. + */ + paginationMaxPageSize?: number; +} + export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider { + constructor(private readonly configuration: PostgresEntityDatabaseAdapterConfiguration = {}) {} getExtensionsKey(): string { return 'PostgresEntityDatabaseAdapterProvider'; } @@ -25,6 +34,6 @@ export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAda getDatabaseAdapter, TIDField extends keyof TFields>( entityConfiguration: EntityConfiguration, ): EntityDatabaseAdapter { - return new PostgresEntityDatabaseAdapter(entityConfiguration); + return new PostgresEntityDatabaseAdapter(entityConfiguration, this.configuration); } } diff --git a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts index adc4037de..a76645802 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/BasePostgresEntityDatabaseAdapter-test.ts @@ -129,6 +129,13 @@ class TestEntityDatabaseAdapter extends BasePostgresEntityDatabaseAdapter< } describe(BasePostgresEntityDatabaseAdapter, () => { + describe('get paginationMaxPageSize', () => { + it('returns the default paginationMaxPageSize (undefined)', () => { + const adapter = new TestEntityDatabaseAdapter({}); + expect(adapter.paginationMaxPageSize).toBe(undefined); + }); + }); + describe('fetchManyByFieldEqualityConjunction', () => { it('transforms object', async () => { const queryContext = instance(mock(EntityQueryContext)); diff --git a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts index eddecc5d9..4a826310d 100644 --- a/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts +++ b/packages/entity-database-adapter-knex/src/internal/EntityKnexDataManager.ts @@ -324,17 +324,30 @@ export class EntityKnexDataManager< const idField = this.entityConfiguration.idField; // Validate pagination arguments + const maxPageSize = this.databaseAdapter.paginationMaxPageSize; const isForward = 'first' in args; if (isForward) { assert( Number.isInteger(args.first) && args.first > 0, 'first must be an integer greater than 0', ); + if (maxPageSize !== undefined) { + assert( + args.first <= maxPageSize, + `first must not exceed maximum page size of ${maxPageSize}`, + ); + } } else { assert( Number.isInteger(args.last) && args.last > 0, 'last must be an integer greater than 0', ); + if (maxPageSize !== undefined) { + assert( + args.last <= maxPageSize, + `last must not exceed maximum page size of ${maxPageSize}`, + ); + } } const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD; diff --git a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts index 840dc0ded..efac1d3a9 100644 --- a/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts +++ b/packages/entity-database-adapter-knex/src/internal/__tests__/EntityKnexDataManager-test.ts @@ -18,6 +18,8 @@ import { when, } from 'ts-mockito'; +import { OrderByOrdering } from '../../BasePostgresEntityDatabaseAdapter'; +import { PaginationStrategy } from '../../PaginationStrategy'; import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter'; import { TestEntity, @@ -253,4 +255,124 @@ describe(EntityKnexDataManager, () => { }); }); }); + + describe('pagination', () => { + describe('max page size validation', () => { + it('should throw when first exceeds maxPageSize', async () => { + const queryContext = instance(mock()); + const databaseAdapterMock = mock< + PostgresEntityDatabaseAdapter + >(PostgresEntityDatabaseAdapter); + + // Configure the adapter to return a maxPageSize of 100 + when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100); + + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await expect( + entityDataManager.loadPageAsync(queryContext, { + first: 101, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }], + }, + }), + ).rejects.toThrow('first must not exceed maximum page size of 100'); + }); + + it('should throw when last exceeds maxPageSize', async () => { + const queryContext = instance(mock()); + const databaseAdapterMock = mock< + PostgresEntityDatabaseAdapter + >(PostgresEntityDatabaseAdapter); + + // Configure the adapter to return a maxPageSize of 100 + when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100); + + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + await expect( + entityDataManager.loadPageAsync(queryContext, { + last: 101, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }], + }, + }), + ).rejects.toThrow('last must not exceed maximum page size of 100'); + }); + + it('should allow first/last within maxPageSize', async () => { + const queryContext = instance(mock()); + const databaseAdapterMock = mock< + PostgresEntityDatabaseAdapter + >(PostgresEntityDatabaseAdapter); + + // Configure the adapter to return a maxPageSize of 100 + when(databaseAdapterMock.paginationMaxPageSize).thenReturn(100); + when( + databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()), + ).thenResolve([]); + + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + // This should not throw + const result = await entityDataManager.loadPageAsync(queryContext, { + first: 100, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }], + }, + }); + + expect(result.edges).toEqual([]); + }); + + it('should allow pagination when maxPageSize is not configured', async () => { + const queryContext = instance(mock()); + const databaseAdapterMock = mock< + PostgresEntityDatabaseAdapter + >(PostgresEntityDatabaseAdapter); + + // Configure the adapter to return undefined for maxPageSize + when(databaseAdapterMock.paginationMaxPageSize).thenReturn(undefined); + when( + databaseAdapterMock.fetchManyBySQLFragmentAsync(queryContext, anything(), anything()), + ).thenResolve([]); + + const entityDataManager = new EntityKnexDataManager( + testEntityConfiguration, + instance(databaseAdapterMock), + new NoOpEntityMetricsAdapter(), + TestEntity.name, + ); + + // This should not throw even with a large page size + const result = await entityDataManager.loadPageAsync(queryContext, { + first: 10000, + pagination: { + strategy: PaginationStrategy.STANDARD, + orderBy: [{ fieldName: 'customIdField', order: OrderByOrdering.ASCENDING }], + }, + }); + + expect(result.edges).toEqual([]); + }); + }); + }); });