Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ export abstract class BasePostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends EntityDatabaseAdapter<TFields, TIDField> {
/**
* 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FieldTransformer, FieldTransformerMap } from '@expo/entity';
import { EntityConfiguration, FieldTransformer, FieldTransformerMap } from '@expo/entity';
import { Knex } from 'knex';

import {
Expand All @@ -10,13 +10,25 @@ import {
TableQuerySelectionModifiersWithOrderByRaw,
} from './BasePostgresEntityDatabaseAdapter';
import { JSONArrayField, MaybeJSONArrayField } from './EntityFields';
import { PostgresEntityDatabaseAdapterConfiguration } from './PostgresEntityDatabaseAdapterProvider';
import { SQLFragment } from './SQLOperator';
import { wrapNativePostgresCallAsync } from './errors/wrapNativePostgresCallAsync';

export class PostgresEntityDatabaseAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> extends BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
constructor(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
private readonly adapterConfiguration: PostgresEntityDatabaseAdapterConfiguration = {},
) {
super(entityConfiguration);
}

override get paginationMaxPageSize(): number | undefined {
return this.adapterConfiguration.paginationMaxPageSize;
}

protected getFieldTransformerMap(): FieldTransformerMap {
return new Map<string, FieldTransformer<any>>([
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand All @@ -25,6 +34,6 @@ export class PostgresEntityDatabaseAdapterProvider implements IEntityDatabaseAda
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
return new PostgresEntityDatabaseAdapter(entityConfiguration);
return new PostgresEntityDatabaseAdapter(entityConfiguration, this.configuration);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
when,
} from 'ts-mockito';

import { OrderByOrdering } from '../../BasePostgresEntityDatabaseAdapter';
import { PaginationStrategy } from '../../PaginationStrategy';
import { PostgresEntityDatabaseAdapter } from '../../PostgresEntityDatabaseAdapter';
import {
TestEntity,
Expand Down Expand Up @@ -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<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(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<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(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<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(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<EntityQueryContext>());
const databaseAdapterMock = mock<
PostgresEntityDatabaseAdapter<TestFields, 'customIdField'>
>(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([]);
});
});
});
});