Compare commits

...
41 changed files with 3347 additions and 1201 deletions

No files matched your search

@@ -3,27 +3,10 @@ import {
EntityDatabaseAdapter,
IEntityDatabaseAdapterProvider,
} from '@expo/entity';
import {
installEntityCompanionExtensions,
installEntityTableDataCoordinatorExtensions,
installReadonlyEntityExtensions,
installViewerScopedEntityCompanionExtensions,
} from '@expo/entity-database-adapter-knex';
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getExtensionsKey(): string {
return 'StubPostgresDatabaseAdapterProvider';
}
installExtensions(): void {
installEntityCompanionExtensions();
installEntityTableDataCoordinatorExtensions();
installViewerScopedEntityCompanionExtensions();
installReadonlyEntityExtensions();
}
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
@@ -14,13 +14,14 @@ import {
OrderByOrdering,
} from './BasePostgresEntityDatabaseAdapter';
import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder';
import { PaginationStrategy } from './PaginationStrategy';
import { SQLFragment } from './SQLOperator';
import type { Connection, PageInfo } from './internal/EntityKnexDataManager';
import { EntityKnexDataManager } from './internal/EntityKnexDataManager';
export interface EntityLoaderOrderByClause<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The field name to order by.
@@ -38,7 +39,7 @@ export interface EntityLoaderOrderByClause<
*/
export interface EntityLoaderQuerySelectionModifiers<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Order the entities by specified columns and orders.
@@ -58,7 +59,7 @@ export interface EntityLoaderQuerySelectionModifiers<
export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* Order the entities by a raw SQL `ORDER BY` clause.
@@ -68,7 +69,7 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByRaw<
export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderQuerySelectionModifiers<TFields, TSelectedFields> {
/**
* Order the entities by a SQL fragment `ORDER BY` clause.
@@ -76,12 +77,96 @@ export interface EntityLoaderQuerySelectionModifiersWithOrderByFragment<
orderByFragment?: SQLFragment;
}
/**
* Base pagination arguments
*/
interface EntityLoaderBasePaginationArgs<
interface SearchSpecificationBase<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* The search term to search for. Must be a non-empty string.
*/
term: string;
/**
* The fields to search within. Must be a non-empty array.
*/
fields: TSelectedFields[];
}
interface ILikeSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Case-insensitive pattern matching search using SQL ILIKE operator.
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
*/
strategy: PaginationStrategy.ILIKE_SEARCH;
}
interface TrigramSearchSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> extends SearchSpecificationBase<TFields, TSelectedFields> {
/**
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
* Note that trigram similarity search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes, and results may not be as relevant as more advanced full-text search solutions.
* It is recommended to use this strategy only when ILIKE search does not meet the application's needs and to ensure appropriate database indexing for performance.
*/
strategy: PaginationStrategy.TRIGRAM_SEARCH;
/**
* Similarity threshold for trigram matching.
* Must be between 0 and 1, where:
* - 0 matches everything
* - 1 requires exact match
*
* Recommended threshold values:
* - 0.3: Loose matching, allows more variation (default PostgreSQL similarity threshold)
* - 0.4-0.5: Moderate matching, good balance for most use cases
* - 0.6+: Strict matching, requires high similarity
*/
threshold: number;
/**
* Optional additional fields to order by after similarity score and before ID for tie-breaking.
* These fields are independent of search fields and can be used to provide meaningful
* ordering when multiple results have the same similarity score.
*/
extraOrderByFields?: TSelectedFields[];
}
interface StandardPaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* Standard pagination without search. Results are ordered by the specified orderBy fields.
*/
strategy: PaginationStrategy.STANDARD;
/**
* Order the entities by specified columns and orders. If the ID field is not included, it will be automatically added for stable pagination.
*/
orderBy: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
}
/**
* Pagination specification for SQL-based pagination (with or without search).
*/
export type PaginationSpecification<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| StandardPaginationSpecification<TFields, TSelectedFields>
| ILikeSearchSpecification<TFields, TSelectedFields>
| TrigramSearchSpecification<TFields, TSelectedFields>;
/**
* Base unified pagination arguments
*/
interface EntityLoaderBaseUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields = keyof TFields,
> {
/**
* SQLFragment representing the WHERE clause to filter the entities being paginated.
@@ -89,56 +174,56 @@ interface EntityLoaderBasePaginationArgs<
where?: SQLFragment;
/**
* Order the entities by specified columns and orders. If the ID field is not included in the orderBy, it will be automatically included as the last orderBy field to ensure stable pagination.
* Pagination specification determining how to order and paginate results.
*/
orderBy?: EntityLoaderOrderByClause<TFields, TSelectedFields>[];
pagination: PaginationSpecification<TFields, TSelectedFields>;
}
/**
* Forward pagination arguments
* Forward unified pagination arguments
*/
export interface EntityLoaderForwardPaginationArgs<
export interface EntityLoaderForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity after the cursor (for forward pagination). Must be a positive integer.
* The number of entities to return starting from the entity after the cursor. Must be a positive integer.
*/
first: number;
/**
* The cursor to paginate after for forward pagination, typically an opaque string encoding of the values of the cursor fields of the last entity in the previous page. If not provided, pagination starts from the beginning of the result set.
* The cursor to paginate after for forward pagination.
*/
after?: string;
}
/**
* Backward pagination arguments
* Backward unified pagination arguments
*/
export interface EntityLoaderBackwardPaginationArgs<
export interface EntityLoaderBackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
> extends EntityLoaderBasePaginationArgs<TFields, TSelectedFields> {
TSelectedFields extends keyof TFields = keyof TFields,
> extends EntityLoaderBaseUnifiedPaginationArgs<TFields, TSelectedFields> {
/**
* The number of entities to return starting from the entity before the cursor (for backward pagination). Must be a positive integer.
* The number of entities to return starting from the entity before the cursor. Must be a positive integer.
*/
last: number;
/**
* The cursor to paginate before for backward pagination, typically an opaque string encoding of the values of the cursor fields of the first entity in the previous page. If not provided, pagination starts from the end of the result set.
* The cursor to paginate before for backward pagination.
*/
before?: string;
}
/**
* Load page pagination arguments, which can be either forward or backward pagination arguments.
* Load page pagination arguments, which can be either forward or backward unified pagination arguments.
*/
export type EntityLoaderLoadPageArgs<
TFields extends Record<string, any>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> =
| EntityLoaderForwardPaginationArgs<TFields, TSelectedFields>
| EntityLoaderBackwardPaginationArgs<TFields, TSelectedFields>;
| EntityLoaderForwardUnifiedPaginationArgs<TFields, TSelectedFields>
| EntityLoaderBackwardUnifiedPaginationArgs<TFields, TSelectedFields>;
/**
* Authorization-result-based knex entity loader for non-data-loader-based load methods.
@@ -158,7 +243,7 @@ export class AuthorizationResultBasedKnexEntityLoader<
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
TSelectedFields extends keyof TFields = keyof TFields,
> {
constructor(
private readonly queryContext: EntityQueryContext,
@@ -267,18 +352,15 @@ export class AuthorizationResultBasedKnexEntityLoader<
}
/**
* Load a page of entities with Relay-style cursor pagination.
* Load a page of entities with Relay-style cursor pagination using a unified pagination specification.
* Only returns successfully authorized entities for cursor stability; failed authorization results are filtered out.
*
* @returns Connection with only successfully authorized entities
*/
async loadPageBySQLAsync(
async loadPageAsync(
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
): Promise<Connection<TEntity>> {
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
this.queryContext,
args,
);
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
const edgeResults = await Promise.all(
pageResult.edges.map(async (edge) => {
@@ -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.
@@ -191,37 +191,14 @@ export class EnforcingKnexEntityLoader<
/**
* Load a page of entities with Relay-style cursor pagination.
*
* @param args - Pagination arguments with either first/after or last/before
*
* @example
* ```typescript
* // Forward pagination - get first 10 items
* const users = await TestEntity.knexLoader(vc)
* .loadPageBySQLAsync({
* first: 10,
* where: sql`age > ${18}`,
* orderBy: 'created_at'
* });
*
* // Backward pagination with cursor - get last 10 items before the cursor
* const lastResults = await TestEntity.knexLoader(vc)
* .loadPageBySQLAsync({
* last: 10,
* where: sql`status = ${'active'}`,
* before: cursor,
* });
* ```
*
* @param args - Pagination arguments with pagination and either first/after or last/before
* @returns a page of entities matching the pagination arguments
* @throws EntityNotAuthorizedError if viewer is not authorized to view any returned entity
*/
async loadPageBySQLAsync(
async loadPageAsync(
args: EntityLoaderLoadPageArgs<TFields, TSelectedFields>,
): Promise<Connection<TEntity>> {
const pageResult = await this.knexDataManager.loadPageBySQLFragmentAsync(
this.queryContext,
args,
);
const pageResult = await this.knexDataManager.loadPageAsync(this.queryContext, args);
const edges = await Promise.all(
pageResult.edges.map(async (edge) => {
const entityResult = await this.constructionUtils.constructAndAuthorizeEntityAsync(
@@ -1,81 +0,0 @@
import {
IEntityClass,
EntityPrivacyPolicy,
EntityQueryContext,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
/**
* The primary interface for loading entities via non-data-loader-based methods
* (knex queries). These methods are not batched or cached through dataloader.
*/
export class KnexEntityLoader<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TViewerContext2 extends TViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly viewerContext: TViewerContext2,
private readonly queryContext: EntityQueryContext,
private readonly entityClass: IEntityClass<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
) {}
/**
* Enforcing knex entity loader. All loads through this loader are
* guaranteed to be the values of successful results (or null for some loader methods),
* and will throw otherwise.
*/
enforcing(): EnforcingKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.viewerContext
.getViewerScopedEntityCompanionForClass(this.entityClass)
.getKnexLoaderFactory()
.forLoadEnforcing(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
}
/**
* Authorization-result-based knex entity loader. All loads through this
* loader are results (or null for some loader methods), where an unsuccessful result
* means an authorization error or entity construction error occurred. Other errors are thrown.
*/
withAuthorizationResults(): AuthorizationResultBasedKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.viewerContext
.getViewerScopedEntityCompanionForClass(this.entityClass)
.getKnexLoaderFactory()
.forLoad(this.queryContext, { previousValue: null, cascadingDeleteCause: null });
}
}
@@ -1,5 +1,6 @@
import {
EntityCompanion,
EntityConstructionUtils,
EntityPrivacyPolicy,
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
@@ -7,7 +8,6 @@ import {
ViewerContext,
IEntityMetricsAdapter,
} from '@expo/entity';
import { EntityConstructionUtils } from '@expo/entity/src/EntityConstructionUtils';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
@@ -0,0 +1,32 @@
/**
* Search strategy for SQL-based pagination.
*/
export enum PaginationStrategy {
/**
* Standard pagination with ORDER BY. Results are ordered by the specified orderBy fields, with ID field automatically included for stable pagination if not already present.
*/
STANDARD = 'standard',
/**
* Case-insensitive pattern matching search using SQL ILIKE operator.
* Results are ordered by the fields being searched within in the order specified, then by ID for tie-breaking and stable pagination.
*/
ILIKE_SEARCH = 'ilike-search',
/**
* Similarity search using PostgreSQL trigram similarity. Results are ordered by exact match priority, then by similarity score, then by specified extra order by fields if provided, then by ID for tie-breaking and stable pagination.
*
* Performance considerations:
* - Trigram search can be significantly slower than ILIKE search, especially on large datasets without appropriate indexes.
* - Consider using ILIKE search for smaller datasets or when exact substring matching is sufficient
* - For larger datasets, ensure proper indexing or consider dedicated full-text search solutions.
* - For optimal performance, create GIN or GIST indexes on searchable columns:
* ```sql
* CREATE EXTENSION IF NOT EXISTS pg_trgm;
* CREATE INDEX idx_table_field_trigram ON table_name USING gin(field_name gin_trgm_ops);
* -- Or for multiple columns:
* CREATE INDEX idx_table_search ON table_name USING gin((field1 || ' ' || field2) gin_trgm_ops);
* ```
*/
TRIGRAM_SEARCH = 'trigram',
}
@@ -0,0 +1,116 @@
import {
Entity,
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import {
knexLoader as knexLoaderFn,
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
} from './knexLoader';
/**
* Abstract base class for mutable entities backed by Postgres.
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods,
* in addition to the mutation methods inherited from `Entity`.
*/
export abstract class PostgresEntity<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields,
> extends Entity<TFields, TIDField, TViewerContext, TSelectedFields> {
/**
* Vend knex loader for loading entities via knex-specific methods in a given query context.
*
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderFn(this, viewerContext, queryContext);
}
/**
* Vend knex loader for loading entities via knex-specific methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
*
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
}
}
@@ -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<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>>([
[
@@ -110,14 +122,19 @@ export class PostgresEntityDatabaseAdapter<
query: Knex.QueryBuilder,
querySelectionModifiers: TableQuerySelectionModifiersWithOrderByRaw,
): Knex.QueryBuilder {
let ret = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
const { orderByRaw } = querySelectionModifiers;
if (orderByRaw !== undefined) {
ret = ret.orderByRaw(orderByRaw);
}
return ret;
// orderByRaw takes precedence over orderBy - they are mutually exclusive
if (orderByRaw !== undefined) {
// Apply only orderByRaw (offset/limit still applied, but not orderBy)
return this.applyQueryModifiersToQuery(query, {
...querySelectionModifiers,
orderBy: undefined, // Explicitly exclude orderBy when orderByRaw is present
}).orderByRaw(orderByRaw);
} else {
// Apply regular orderBy (and offset/limit)
return this.applyQueryModifiersToQuery(query, querySelectionModifiers);
}
}
private applyQueryModifiersToQuery(
@@ -216,10 +233,19 @@ export class PostgresEntityDatabaseAdapter<
.select()
.from(tableName)
.whereRaw(sqlFragment.sql, sqlFragment.getKnexBindings());
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
// Apply order by modifiers
// orderByFragment takes precedence over orderBy - they are mutually exclusive
const { orderByFragment } = querySelectionModifiers;
if (orderByFragment !== undefined) {
query = query.orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
// Apply only orderByFragment (offset/limit still applied, but not orderBy)
query = this.applyQueryModifiersToQuery(query, {
...querySelectionModifiers,
orderBy: undefined, // Explicitly exclude orderBy when orderByFragment is present
}).orderByRaw(orderByFragment.sql, orderByFragment.getKnexBindings());
} else {
// Apply regular orderBy (and offset/limit)
query = this.applyQueryModifiersToQuery(query, querySelectionModifiers);
}
return await wrapNativePostgresCallAsync(() => query);
}
@@ -5,26 +5,21 @@ import {
} from '@expo/entity';
import { PostgresEntityDatabaseAdapter } from './PostgresEntityDatabaseAdapter';
import { installEntityCompanionExtensions } from './extensions/EntityCompanionExtensions';
import { installEntityTableDataCoordinatorExtensions } from './extensions/EntityTableDataCoordinatorExtensions';
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 {
getExtensionsKey(): string {
return 'PostgresEntityDatabaseAdapterProvider';
}
installExtensions(): void {
installEntityCompanionExtensions();
installEntityTableDataCoordinatorExtensions();
installViewerScopedEntityCompanionExtensions();
installReadonlyEntityExtensions();
}
constructor(private readonly configuration: PostgresEntityDatabaseAdapterConfiguration = {}) {}
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);
}
}
@@ -0,0 +1,115 @@
import {
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import {
knexLoader as knexLoaderFn,
knexLoaderWithAuthorizationResults as knexLoaderWithAuthorizationResultsFn,
} from './knexLoader';
/**
* Abstract base class for readonly entities backed by Postgres.
* Provides `knexLoader` and `knexLoaderWithAuthorizationResults` as inherited static methods.
*
* Entities that should not be mutated (e.g., representing SQL views or immutable tables)
* can extend this class instead of `ReadonlyEntity` to get knex loader ergonomics.
*/
export abstract class ReadonlyPostgresEntity<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TSelectedFields extends keyof TFields = keyof TFields,
> extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields> {
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderFn(this, viewerContext, queryContext);
}
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return knexLoaderWithAuthorizationResultsFn(this, viewerContext, queryContext);
}
}
@@ -1,89 +0,0 @@
import {
EntityPrivacyPolicy,
EntityPrivacyPolicyEvaluationContext,
EntityQueryContext,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory';
/**
* Provides a cleaner API for loading entities via knex by passing through the ViewerContext.
*/
export class ViewerScopedKnexEntityLoaderFactory<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly knexEntityLoaderFactory: KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly viewerContext: TViewerContext,
) {}
forLoad(
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
): AuthorizationResultBasedKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.knexEntityLoaderFactory.forLoad(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext,
);
}
forLoadEnforcing(
queryContext: EntityQueryContext,
privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
): EnforcingKnexEntityLoader<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.knexEntityLoaderFactory.forLoadEnforcing(
this.viewerContext,
queryContext,
privacyPolicyEvaluationContext,
);
}
}
@@ -2,7 +2,6 @@ import {
AlwaysAllowPrivacyPolicyRule,
BooleanField,
DateField,
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
@@ -16,6 +15,7 @@ import {
import { Knex } from 'knex';
import { BigIntField, JSONArrayField, MaybeJSONArrayField } from '../EntityFields';
import { PostgresEntity } from '../PostgresEntity';
type PostgresTestEntityFields = {
id: string;
@@ -31,9 +31,14 @@ type PostgresTestEntityFields = {
maybeJsonArrayField: string[] | { hello: string } | null;
bigintField: string | null;
binaryField: Buffer | null;
createdAt: Date;
};
export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', ViewerContext> {
export class PostgresTestEntity extends PostgresEntity<
PostgresTestEntityFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
PostgresTestEntityFields,
'id',
@@ -64,6 +69,7 @@ export class PostgresTestEntity extends Entity<PostgresTestEntityFields, 'id', V
table.jsonb('maybe_json_array_field');
table.bigint('bigint_field');
table.binary('binary_field');
table.dateTime('created_at', { useTz: true }).defaultTo(knex.fn.now());
});
}
await knex.into(tableName).truncate();
@@ -159,6 +165,9 @@ export const postgresTestEntityConfiguration = new EntityConfiguration<
binaryField: new BufferField({
columnName: 'binary_field',
}),
createdAt: new DateField({
columnName: 'created_at',
}),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
@@ -12,6 +12,7 @@ import { v4 as uuidv4 } from 'uuid';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { OrderByOrdering } from '../BasePostgresEntityDatabaseAdapter';
import { PaginationStrategy } from '../PaginationStrategy';
import { sql } from '../SQLOperator';
import {
TestEntity,
@@ -436,7 +437,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
});
});
describe('loads entities with loadPageBySQLAsync', () => {
describe('loads entities with loadPageAsync', () => {
it('returns paginated entities with forward pagination', async () => {
const privacyPolicy = new TestEntityPrivacyPolicy();
const spiedPrivacyPolicy = spy(privacyPolicy);
@@ -460,7 +461,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
const id1 = uuidv4();
const id2 = uuidv4();
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
@@ -511,10 +512,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
constructionUtils,
);
const connection = await knexEntityLoader.loadPageBySQLAsync({
const connection = await knexEntityLoader.loadPageAsync({
first: 10,
where: sql`intField > ${0}`,
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
},
});
expect(connection.edges).toHaveLength(2);
@@ -564,7 +568,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
const id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
@@ -623,10 +627,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
constructionUtils,
);
const connection = await knexEntityLoader.loadPageBySQLAsync({
const connection = await knexEntityLoader.loadPageAsync({
first: 10,
where: sql`score > ${0}`,
orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }],
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'createdAt', order: OrderByOrdering.ASCENDING }],
},
});
// Should only have 2 entities (unauthorized one filtered out)
@@ -666,7 +673,7 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
const knexDataManagerMock =
mock<EntityKnexDataManager<TestFields, 'customIdField'>>(EntityKnexDataManager);
when(knexDataManagerMock.loadPageBySQLFragmentAsync(queryContext, anything())).thenResolve({
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor5',
@@ -706,10 +713,13 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
constructionUtils,
);
const connection = await knexEntityLoader.loadPageBySQLAsync({
const connection = await knexEntityLoader.loadPageAsync({
last: 5,
before: 'someCursor',
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [{ fieldName: 'intField', order: OrderByOrdering.ASCENDING }],
},
});
expect(connection.edges).toHaveLength(1);
@@ -717,4 +727,441 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => {
expect(connection.pageInfo.hasNextPage).toBe(false);
});
});
describe('loads entities with loadPageAsync (search)', () => {
it('performs ILIKE search and filters unauthorized entities', async () => {
const privacyPolicy = new TestPaginationPrivacyPolicy();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = instance(mock<EntityQueryContext>());
const knexDataManagerMock =
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
const id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
// Mock data manager to return 3 entities from search
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: {
id: id1,
name: 'Alice Johnson',
status: 'active',
createdAt: new Date(),
score: 1,
},
},
{
cursor: 'cursor2',
node: {
id: id2,
name: 'Bob Johnson',
status: 'unauthorized', // This will fail authorization per TestPaginationPrivacyPolicy
createdAt: new Date(),
score: 2,
},
},
{
cursor: 'cursor3',
node: {
id: id3,
name: 'Charlie Johnson',
status: 'active',
createdAt: new Date(),
score: 3,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor3',
},
});
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testPaginationEntityConfiguration,
TestPaginationEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
queryContext,
instance(knexDataManagerMock),
metricsAdapter,
constructionUtils,
);
const connection = await knexEntityLoader.loadPageAsync({
first: 10,
pagination: {
strategy: PaginationStrategy.ILIKE_SEARCH,
term: 'Johnson',
fields: ['name'],
},
});
// Should only have 2 edges (Bob was filtered out)
expect(connection.edges).toHaveLength(2);
expect(connection.edges[0]?.node.getField('id')).toBe(id1);
expect(connection.edges[0]?.node.getField('name')).toBe('Alice Johnson');
expect(connection.edges[1]?.node.getField('id')).toBe(id3);
expect(connection.edges[1]?.node.getField('name')).toBe('Charlie Johnson');
// Cursors should be updated to reflect only authorized entities
expect(connection.pageInfo.startCursor).toBe('cursor1');
expect(connection.pageInfo.endCursor).toBe('cursor3');
});
it('performs TRIGRAM search with cursor pagination', async () => {
const privacyPolicy = new TestPaginationPrivacyPolicy();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = instance(mock<EntityQueryContext>());
const knexDataManagerMock =
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
const id1 = uuidv4();
const id2 = uuidv4();
// Mock first page of TRIGRAM search results
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: {
id: id1,
name: 'Johnson', // Exact match
status: 'active',
createdAt: new Date(),
score: 1,
},
},
{
cursor: 'cursor2',
node: {
id: id2,
name: 'Jonson', // Similar match
status: 'active',
createdAt: new Date(),
score: 2,
},
},
],
pageInfo: {
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
});
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testPaginationEntityConfiguration,
TestPaginationEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
queryContext,
instance(knexDataManagerMock),
metricsAdapter,
constructionUtils,
);
const connection = await knexEntityLoader.loadPageAsync({
first: 2,
after: 'someCursor',
pagination: {
strategy: PaginationStrategy.TRIGRAM_SEARCH,
term: 'Johnson',
fields: ['name'],
threshold: 0.3,
extraOrderByFields: ['score'],
},
});
expect(connection.edges).toHaveLength(2);
expect(connection.edges[0]?.node.getField('name')).toBe('Johnson');
expect(connection.edges[1]?.node.getField('name')).toBe('Jonson');
expect(connection.pageInfo.hasNextPage).toBe(true);
});
it('handles backward pagination with search', async () => {
const privacyPolicy = new TestPaginationPrivacyPolicy();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = instance(mock<EntityQueryContext>());
const knexDataManagerMock =
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
const id1 = uuidv4();
const id2 = uuidv4();
// Mock backward pagination results
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: {
id: id1,
name: 'Charlie Smith',
status: 'active',
createdAt: new Date(),
score: 3,
},
},
{
cursor: 'cursor2',
node: {
id: id2,
name: 'David Smith',
status: 'active',
createdAt: new Date(),
score: 4,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: true,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
});
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testPaginationEntityConfiguration,
TestPaginationEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
queryContext,
instance(knexDataManagerMock),
metricsAdapter,
constructionUtils,
);
const connection = await knexEntityLoader.loadPageAsync({
last: 2,
before: 'someCursor',
pagination: {
strategy: PaginationStrategy.ILIKE_SEARCH,
term: 'Smith',
fields: ['name'],
},
});
expect(connection.edges).toHaveLength(2);
expect(connection.pageInfo.hasPreviousPage).toBe(true);
expect(connection.pageInfo.hasNextPage).toBe(false);
});
it('handles empty search results', async () => {
const privacyPolicy = new TestPaginationPrivacyPolicy();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = instance(mock<EntityQueryContext>());
const knexDataManagerMock =
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
// Mock empty search results
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
});
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testPaginationEntityConfiguration,
TestPaginationEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
queryContext,
instance(knexDataManagerMock),
metricsAdapter,
constructionUtils,
);
const connection = await knexEntityLoader.loadPageAsync({
first: 10,
pagination: {
strategy: PaginationStrategy.ILIKE_SEARCH,
term: 'NonexistentTerm',
fields: ['name'],
},
});
expect(connection.edges).toHaveLength(0);
expect(connection.pageInfo.startCursor).toBeNull();
expect(connection.pageInfo.endCursor).toBeNull();
});
it('handles all entities failing authorization', async () => {
const privacyPolicy = new TestPaginationPrivacyPolicy();
const viewerContext = instance(mock(ViewerContext));
const privacyPolicyEvaluationContext =
instance(
mock<
EntityPrivacyPolicyEvaluationContext<
TestPaginationFields,
'id',
ViewerContext,
TestPaginationEntity
>
>(),
);
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = instance(mock<EntityQueryContext>());
const knexDataManagerMock =
mock<EntityKnexDataManager<TestPaginationFields, 'id'>>(EntityKnexDataManager);
const id1 = uuidv4();
const id2 = uuidv4();
// Mock search results
when(knexDataManagerMock.loadPageAsync(queryContext, anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
node: {
id: id1,
name: 'Alice',
status: 'unauthorized', // This will fail authorization
createdAt: new Date(),
score: 1,
},
},
{
cursor: 'cursor2',
node: {
id: id2,
name: 'Bob',
status: 'unauthorized', // This will fail authorization
createdAt: new Date(),
score: 2,
},
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor1',
endCursor: 'cursor2',
},
});
const constructionUtils = new EntityConstructionUtils(
viewerContext,
queryContext,
privacyPolicyEvaluationContext,
testPaginationEntityConfiguration,
TestPaginationEntity,
/* entitySelectedFields */ undefined,
privacyPolicy,
metricsAdapter,
);
const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader(
queryContext,
instance(knexDataManagerMock),
metricsAdapter,
constructionUtils,
);
const connection = await knexEntityLoader.loadPageAsync({
first: 10,
pagination: {
strategy: PaginationStrategy.ILIKE_SEARCH,
term: 'test',
fields: ['name'],
},
});
// All entities filtered out due to failed authorization
expect(connection.edges).toHaveLength(0);
expect(connection.pageInfo.startCursor).toBeNull();
expect(connection.pageInfo.endCursor).toBeNull();
});
});
});
@@ -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));
@@ -8,6 +8,7 @@ import {
AuthorizationResultBasedSQLQueryBuilder,
} from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { PaginationStrategy } from '../PaginationStrategy';
import { sql } from '../SQLOperator';
import { EntityKnexDataManager } from '../internal/EntityKnexDataManager';
@@ -248,7 +249,7 @@ describe(EnforcingKnexEntityLoader, () => {
});
});
describe('loadPageBySQLAsync', () => {
describe('loadPageAsync', () => {
it('throws when result is unsuccessful', async () => {
const queryContext = instance(mock(EntityQueryContext));
const knexDataManagerMock = mock<EntityKnexDataManager<any, any>>(EntityKnexDataManager);
@@ -257,7 +258,7 @@ describe(EnforcingKnexEntityLoader, () => {
const rejection = new Error('Entity not authorized');
// Mock the data manager to return a connection with field objects
when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
@@ -286,9 +287,12 @@ describe(EnforcingKnexEntityLoader, () => {
);
await expect(
enforcingKnexEntityLoader.loadPageBySQLAsync({
enforcingKnexEntityLoader.loadPageAsync({
first: 10,
orderBy: [],
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [],
},
}),
).rejects.toThrow(rejection);
});
@@ -301,7 +305,7 @@ describe(EnforcingKnexEntityLoader, () => {
const entity1 = { id: '1', name: 'Entity 1', getID: () => '1' };
const entity2 = { id: '2', name: 'Entity 2', getID: () => '2' };
when(knexDataManagerMock.loadPageBySQLFragmentAsync(anything(), anything())).thenResolve({
when(knexDataManagerMock.loadPageAsync(anything(), anything())).thenResolve({
edges: [
{
cursor: 'cursor1',
@@ -339,9 +343,12 @@ describe(EnforcingKnexEntityLoader, () => {
instance(constructionUtilsMock),
);
const connection = await enforcingKnexEntityLoader.loadPageBySQLAsync({
const connection = await enforcingKnexEntityLoader.loadPageAsync({
first: 10,
orderBy: [],
pagination: {
strategy: PaginationStrategy.STANDARD,
orderBy: [],
},
});
expect(connection.edges).toHaveLength(2);
@@ -0,0 +1,172 @@
import {
AlwaysAllowPrivacyPolicyRule,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
StringField,
UUIDField,
ViewerContext,
} from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { PostgresEntity } from '../PostgresEntity';
import { ReadonlyPostgresEntity } from '../ReadonlyPostgresEntity';
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
type TestPostgresFields = {
id: string;
name: string;
};
const testPostgresEntityConfiguration = new EntityConfiguration<TestPostgresFields, 'id'>({
idField: 'id',
tableName: 'postgres_entity_test',
schema: {
id: new UUIDField({ columnName: 'id', cache: true }),
name: new StringField({ columnName: 'name' }),
},
databaseAdapterFlavor: 'postgres',
cacheAdapterFlavor: 'redis',
});
class TestPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestPostgresFields,
'id',
ViewerContext,
TestPostgresEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<TestPostgresFields, 'id', ViewerContext, TestPostgresEntity>(),
];
}
class TestPostgresEntity extends PostgresEntity<TestPostgresFields, 'id', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPostgresFields,
'id',
ViewerContext,
TestPostgresEntity,
TestPostgresEntityPrivacyPolicy
> {
return {
entityClass: TestPostgresEntity,
entityConfiguration: testPostgresEntityConfiguration,
privacyPolicyClass: TestPostgresEntityPrivacyPolicy,
};
}
}
class TestReadonlyPostgresEntityPrivacyPolicy extends EntityPrivacyPolicy<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
> {
protected override readonly readRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly createRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly updateRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
protected override readonly deleteRules = [
new AlwaysAllowPrivacyPolicyRule<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity
>(),
];
}
class TestReadonlyPostgresEntity extends ReadonlyPostgresEntity<
TestPostgresFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPostgresFields,
'id',
ViewerContext,
TestReadonlyPostgresEntity,
TestReadonlyPostgresEntityPrivacyPolicy
> {
return {
entityClass: TestReadonlyPostgresEntity,
entityConfiguration: testPostgresEntityConfiguration,
privacyPolicyClass: TestReadonlyPostgresEntityPrivacyPolicy,
};
}
}
describe(PostgresEntity, () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
EnforcingKnexEntityLoader,
);
});
});
describe('knexLoaderWithAuthorizationResults', () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
});
});
});
describe(ReadonlyPostgresEntity, () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(TestReadonlyPostgresEntity.knexLoader(viewerContext)).toBeInstanceOf(
EnforcingKnexEntityLoader,
);
});
});
describe('knexLoaderWithAuthorizationResults', () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(
TestReadonlyPostgresEntity.knexLoaderWithAuthorizationResults(viewerContext),
).toBeInstanceOf(AuthorizationResultBasedKnexEntityLoader);
});
});
});
@@ -1,16 +1,18 @@
import { ReadonlyEntity, ViewerContext } from '@expo/entity';
import { ViewerContext } from '@expo/entity';
import { describe, expect, it } from '@jest/globals';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { knexLoader, knexLoaderWithAuthorizationResults } from '../knexLoader';
import { TestEntity } from './fixtures/TestEntity';
import { createUnitTestPostgresEntityCompanionProvider } from './fixtures/createUnitTestPostgresEntityCompanionProvider';
describe(ReadonlyEntity, () => {
describe('knexLoader', () => {
describe('knexLoader', () => {
it('creates a new EnforcingKnexEntityLoader', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(knexLoader(TestEntity, viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
expect(TestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader);
});
});
@@ -19,6 +21,9 @@ describe(ReadonlyEntity, () => {
it('creates a new AuthorizationResultBasedKnexEntityLoader', async () => {
const companionProvider = createUnitTestPostgresEntityCompanionProvider();
const viewerContext = new ViewerContext(companionProvider);
expect(knexLoaderWithAuthorizationResults(TestEntity, viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
expect(TestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf(
AuthorizationResultBasedKnexEntityLoader,
);
@@ -5,23 +5,8 @@ import {
} from '@expo/entity';
import { StubPostgresDatabaseAdapter } from './StubPostgresDatabaseAdapter';
import { installEntityCompanionExtensions } from '../../extensions/EntityCompanionExtensions';
import { installEntityTableDataCoordinatorExtensions } from '../../extensions/EntityTableDataCoordinatorExtensions';
import { installReadonlyEntityExtensions } from '../../extensions/ReadonlyEntityExtensions';
import { installViewerScopedEntityCompanionExtensions } from '../../extensions/ViewerScopedEntityCompanionExtensions';
export class StubPostgresDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getExtensionsKey(): string {
return 'StubPostgresDatabaseAdapterProvider';
}
installExtensions(): void {
installEntityCompanionExtensions();
installEntityTableDataCoordinatorExtensions();
installViewerScopedEntityCompanionExtensions();
installReadonlyEntityExtensions();
}
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
@@ -1,5 +1,4 @@
import {
Entity,
EntityCompanionDefinition,
EntityConfiguration,
DateField,
@@ -12,6 +11,8 @@ import {
} from '@expo/entity';
import { result, Result } from '@expo/results';
import { PostgresEntity } from '../../PostgresEntity';
export type TestFields = {
customIdField: string;
testIndexedField: string;
@@ -75,7 +76,7 @@ export class TestEntityPrivacyPolicy extends EntityPrivacyPolicy<
];
}
export class TestEntity extends Entity<TestFields, 'customIdField', ViewerContext> {
export class TestEntity extends PostgresEntity<TestFields, 'customIdField', ViewerContext> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestFields,
'customIdField',
@@ -1,5 +1,4 @@
import {
Entity,
EntityCompanionDefinition,
EntityConfiguration,
EntityPrivacyPolicy,
@@ -13,6 +12,8 @@ import {
RuleEvaluationResult,
} from '@expo/entity';
import { PostgresEntity } from '../../PostgresEntity';
export interface TestPaginationFields {
id: string;
name: string;
@@ -85,7 +86,11 @@ export class TestPaginationPrivacyPolicy extends EntityPrivacyPolicy<
protected override readonly deleteRules = [];
}
export class TestPaginationEntity extends Entity<TestPaginationFields, 'id', ViewerContext> {
export class TestPaginationEntity extends PostgresEntity<
TestPaginationFields,
'id',
ViewerContext
> {
static defineCompanionDefinition(): EntityCompanionDefinition<
TestPaginationFields,
'id',
@@ -1,81 +0,0 @@
import { EntityCompanion, EntityPrivacyPolicy, ReadonlyEntity, ViewerContext } from '@expo/entity';
import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory';
const KNEX_LOADER_FACTORY = Symbol('knexLoaderFactory');
declare module '@expo/entity' {
interface EntityCompanion<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
[KNEX_LOADER_FACTORY]:
| KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>
| undefined;
getKnexLoaderFactory(): KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
}
}
export function installEntityCompanionExtensions(): void {
EntityCompanion.prototype.getKnexLoaderFactory = function <
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
>(
this: EntityCompanion<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
): KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return (this[KNEX_LOADER_FACTORY] ??= new KnexEntityLoaderFactory(
this,
this.tableDataCoordinator.getKnexDataManager(),
this.metricsAdapter,
));
};
}
@@ -1,44 +0,0 @@
import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity';
import assert from 'assert';
import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter';
import { EntityKnexDataManager } from '../internal/EntityKnexDataManager';
const KNEX_DATA_MANAGER = Symbol('knexDataManager');
declare module '@expo/entity' {
interface EntityTableDataCoordinator<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
> {
[KNEX_DATA_MANAGER]: EntityKnexDataManager<TFields, TIDField> | undefined;
getKnexDataManager(): EntityKnexDataManager<TFields, TIDField>;
}
}
function requireBasePostgresAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
databaseAdapter: EntityDatabaseAdapter<TFields, TIDField>,
): BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
assert(
databaseAdapter instanceof BasePostgresEntityDatabaseAdapter,
`Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`,
);
return databaseAdapter;
}
export function installEntityTableDataCoordinatorExtensions(): void {
EntityTableDataCoordinator.prototype.getKnexDataManager = function <
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(this: EntityTableDataCoordinator<TFields, TIDField>): EntityKnexDataManager<TFields, TIDField> {
return (this[KNEX_DATA_MANAGER] ??= new EntityKnexDataManager(
this.entityConfiguration,
requireBasePostgresAdapter(this.databaseAdapter),
this.metricsAdapter,
this.entityClassName,
));
};
}
@@ -1,179 +0,0 @@
import {
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader';
import { KnexEntityLoader } from '../KnexEntityLoader';
declare module '@expo/entity' {
namespace ReadonlyEntity {
export function knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2,
queryContext?: EntityQueryContext,
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>;
export function knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2,
queryContext?: EntityQueryContext,
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>;
}
}
class ReadonlyEntityExtensions {
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return new KnexEntityLoader(viewerContext, queryContext, this).enforcing();
}
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
static knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMViewerContext2 extends TMViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
this: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext2,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(this)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return new KnexEntityLoader(viewerContext, queryContext, this).withAuthorizationResults();
}
}
export function installReadonlyEntityExtensions(): void {
ReadonlyEntity.knexLoader = ReadonlyEntityExtensions.knexLoader;
ReadonlyEntity.knexLoaderWithAuthorizationResults =
ReadonlyEntityExtensions.knexLoaderWithAuthorizationResults;
}
@@ -1,72 +0,0 @@
import {
EntityPrivacyPolicy,
ReadonlyEntity,
ViewerContext,
ViewerScopedEntityCompanion,
} from '@expo/entity';
import { ViewerScopedKnexEntityLoaderFactory } from '../ViewerScopedKnexEntityLoaderFactory';
declare module '@expo/entity' {
interface ViewerScopedEntityCompanion<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
> {
getKnexLoaderFactory(): ViewerScopedKnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;
}
}
export function installViewerScopedEntityCompanionExtensions(): void {
ViewerScopedEntityCompanion.prototype.getKnexLoaderFactory = function <
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields,
>(
this: ViewerScopedEntityCompanion<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
): ViewerScopedKnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return new ViewerScopedKnexEntityLoaderFactory(
this.entityCompanion.getKnexLoaderFactory(),
this.viewerContext,
);
};
}
@@ -9,16 +9,17 @@ export * from './BasePostgresEntityDatabaseAdapter';
export * from './BaseSQLQueryBuilder';
export * from './EnforcingKnexEntityLoader';
export * from './EntityFields';
export * from './KnexEntityLoader';
export * from './KnexEntityLoaderFactory';
export * from './knexLoader';
export * from './PaginationStrategy';
export * from './PostgresEntity';
export * from './PostgresEntityDatabaseAdapter';
export * from './PostgresEntityDatabaseAdapterProvider';
export * from './PostgresEntityQueryContextProvider';
export * from './ReadonlyPostgresEntity';
export * from './SQLOperator';
export * from './ViewerScopedKnexEntityLoaderFactory';
export * from './errors/wrapNativePostgresCallAsync';
export * from './extensions/EntityCompanionExtensions';
export * from './extensions/EntityTableDataCoordinatorExtensions';
export * from './extensions/ReadonlyEntityExtensions';
export * from './extensions/ViewerScopedEntityCompanionExtensions';
export * from './internal/EntityKnexDataManager';
export * from './internal/getKnexDataManager';
export * from './internal/getKnexEntityLoaderFactory';
export * from './internal/weakMaps';
@@ -18,42 +18,63 @@ import {
PostgresQuerySelectionModifiersWithOrderByFragment,
PostgresQuerySelectionModifiersWithOrderByRaw,
} from '../BasePostgresEntityDatabaseAdapter';
import { SQLFragment, identifier, raw, sql } from '../SQLOperator';
import { PaginationStrategy } from '../PaginationStrategy';
import { SQLFragment, SQLFragmentHelpers, identifier, raw, sql } from '../SQLOperator';
/**
* Base pagination arguments
*/
interface BasePaginationArgs<TFields extends Record<string, any>> {
where?: SQLFragment;
orderBy?: PostgresOrderByClause<TFields>[];
interface DataManagerStandardSpecification<TFields extends Record<string, any>> {
strategy: PaginationStrategy.STANDARD;
orderBy: PostgresOrderByClause<TFields>[];
}
/**
* Forward pagination arguments
*/
export interface ForwardPaginationArgs<
interface DataManagerSearchSpecificationBase<TFields extends Record<string, any>> {
term: string;
fields: (keyof TFields)[];
}
interface DataManagerILikeSearchSpecification<
TFields extends Record<string, any>,
> extends BasePaginationArgs<TFields> {
> extends DataManagerSearchSpecificationBase<TFields> {
strategy: PaginationStrategy.ILIKE_SEARCH;
}
interface DataManagerTrigramSearchSpecification<
TFields extends Record<string, any>,
> extends DataManagerSearchSpecificationBase<TFields> {
strategy: PaginationStrategy.TRIGRAM_SEARCH;
threshold: number;
extraOrderByFields?: (keyof TFields)[];
}
type DataManagerSearchSpecification<TFields extends Record<string, any>> =
| DataManagerILikeSearchSpecification<TFields>
| DataManagerTrigramSearchSpecification<TFields>;
type DataManagerPaginationSpecification<TFields extends Record<string, any>> =
| DataManagerStandardSpecification<TFields>
| DataManagerSearchSpecification<TFields>;
interface BaseUnifiedPaginationArgs<TFields extends Record<string, any>> {
where?: SQLFragment;
pagination: DataManagerPaginationSpecification<TFields>;
}
interface ForwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BaseUnifiedPaginationArgs<TFields> {
first: number;
after?: string;
}
/**
* Backward pagination arguments
*/
export interface BackwardPaginationArgs<
interface BackwardUnifiedPaginationArgs<
TFields extends Record<string, any>,
> extends BasePaginationArgs<TFields> {
> extends BaseUnifiedPaginationArgs<TFields> {
last: number;
before?: string;
}
/**
* Combined pagination arguments using discriminated union
*/
export type LoadPageArgs<TFields extends Record<string, any>> =
| ForwardPaginationArgs<TFields>
| BackwardPaginationArgs<TFields>;
type LoadPageArgs<TFields extends Record<string, any>> =
| ForwardUnifiedPaginationArgs<TFields>
| BackwardUnifiedPaginationArgs<TFields>;
/**
* Edge in a connection
@@ -81,6 +102,25 @@ export interface Connection<TNode> {
pageInfo: PageInfo;
}
enum PaginationDirection {
FORWARD = 'forward',
BACKWARD = 'backward',
}
const CURSOR_ROW_TABLE_ALIAS = 'cursor_row';
interface PaginationProvider<TFields extends Record<string, any>, TIDField extends keyof TFields> {
whereClause: SQLFragment | undefined;
buildOrderBy: (direction: PaginationDirection) => {
clauses?: PostgresOrderByClause<TFields>[];
fragment?: SQLFragment | undefined;
};
buildCursorCondition: (
decodedCursorId: TFields[TIDField],
direction: PaginationDirection,
) => SQLFragment;
}
/**
* A knex data manager is responsible for handling non-dataloader-based
* database operations.
@@ -176,91 +216,181 @@ export class EntityKnexDataManager<
}
/**
* Load a page of objects using cursor-based pagination.
* Load a page of objects using cursor-based pagination with unified pagination specification.
*
* @param queryContext - query context in which to perform the load
* @param args - pagination arguments including first/after or last/before
* @param idField - the ID field name for the entity
* @param args - pagination arguments including pagination and first/after or last/before
* @returns connection with edges containing field objects and page info
*/
async loadPageBySQLFragmentAsync(
async loadPageAsync(
queryContext: EntityQueryContext,
args: LoadPageArgs<TFields>,
): Promise<Connection<Readonly<TFields>>> {
const { where, pagination } = args;
if (pagination.strategy === PaginationStrategy.STANDARD) {
// Standard pagination
const idField = this.entityConfiguration.idField;
const augmentedOrderByClauses = this.augmentOrderByIfNecessary(pagination.orderBy, idField);
const fieldsToUseInPostgresTupleCursor = augmentedOrderByClauses.map(
(order) => order.fieldName,
);
// Create strategy for regular pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause: where,
buildOrderBy: (direction) => {
// For backward pagination, we flip the ORDER BY direction to fetch records
// in reverse order. This allows us to use a simple "less than" cursor comparison
// instead of complex SQL. We'll reverse the results array later to restore
// the original requested order.
// Example: If user wants last 3 items ordered by name ASC, we:
// 1. Flip to name DESC to get the last items first
// 2. Apply cursor with < comparison
// 3. Reverse the final array to present items in name ASC order
const clauses =
direction === PaginationDirection.FORWARD
? augmentedOrderByClauses
: augmentedOrderByClauses.map((clause) => ({
fieldName: clause.fieldName,
order:
clause.order === OrderByOrdering.ASCENDING
? OrderByOrdering.DESCENDING
: OrderByOrdering.ASCENDING,
}));
return { clauses };
},
buildCursorCondition: (decodedCursorId, direction) =>
this.buildCursorCondition(decodedCursorId, fieldsToUseInPostgresTupleCursor, direction),
};
return await this.loadPageInternalAsync(queryContext, args, strategy);
} else {
// Search pagination (ILIKE or TRIGRAM)
const search = pagination;
// Validate search parameters
assert(search.term.length > 0, 'Search term must be a non-empty string');
assert(search.fields.length > 0, 'Search fields must be a non-empty array');
const direction =
'first' in args ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
const { searchWhere, searchOrderByFragment } = this.buildSearchConditionAndOrderBy(
search,
direction,
);
// Combine WHERE conditions: base where + search where
const whereClause =
where && searchWhere ? SQLFragmentHelpers.and(where, searchWhere) : (where ?? searchWhere);
const fieldsToUseInPostgresTupleCursor =
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
? // For trigram search, cursor includes extra order by fields (if specified) + ID to ensure stable ordering that matches ORDER BY clause
[...(search.extraOrderByFields ?? []), this.entityConfiguration.idField]
: // For ILIKE search, cursor includes search fields + ID to ensure stable ordering that matches ORDER BY clause
[...search.fields, this.entityConfiguration.idField];
// Create strategy for search pagination
const strategy: PaginationProvider<TFields, TIDField> = {
whereClause,
buildOrderBy: () => {
return { fragment: searchOrderByFragment };
},
buildCursorCondition: (decodedCursorId, direction) =>
search.strategy === PaginationStrategy.TRIGRAM_SEARCH
? this.buildTrigramCursorCondition(search, decodedCursorId, direction)
: this.buildCursorCondition(
decodedCursorId,
fieldsToUseInPostgresTupleCursor,
direction,
),
};
return await this.loadPageInternalAsync(queryContext, args, strategy);
}
}
/**
* Internal method for loading a page with cursor-based pagination.
* Shared logic for both regular and search pagination.
*/
private async loadPageInternalAsync(
queryContext: EntityQueryContext,
args: LoadPageArgs<TFields>,
paginationProvider: PaginationProvider<TFields, TIDField>,
): Promise<Connection<Readonly<TFields>>> {
const idField = this.entityConfiguration.idField;
// Validate pagination arguments
if ('first' in args) {
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 isForward = 'first' in args;
const { where, orderBy } = args;
let limit: number;
let cursor: string | undefined;
if (isForward) {
limit = args.first;
cursor = args.after;
} else {
limit = args.last;
cursor = args.before;
}
// Augment orderBy with ID field for stability
const orderByClauses = this.augmentOrderByIfNecessary(orderBy, idField);
// Build cursor fields from orderBy + id for stability
const fieldsToUseInPostgresCursor = orderByClauses.map((order) => order.fieldName);
const direction = isForward ? PaginationDirection.FORWARD : PaginationDirection.BACKWARD;
const limit = isForward ? args.first : args.last;
const cursor = isForward ? args.after : args.before;
// Decode cursor
const decodedExternalCursorEntityID = cursor ? this.decodeOpaqueCursor(cursor) : null;
// Build WHERE clause with cursor condition for keyset pagination
const whereClause = this.buildWhereClause({
...(where && { where }),
decodedExternalCursorEntityID,
fieldsToUseInPostgresCursor,
direction: isForward ? 'forward' : 'backward',
});
// Build WHERE clause with cursor condition
const baseWhere = paginationProvider.whereClause;
const cursorCondition = decodedExternalCursorEntityID
? paginationProvider.buildCursorCondition(decodedExternalCursorEntityID, direction)
: null;
// Adjust order for backward pagination
const finalOrderByClauses = isForward
? orderByClauses
: orderByClauses.map((clause) => ({
fieldName: clause.fieldName,
order:
clause.order === OrderByOrdering.ASCENDING
? OrderByOrdering.DESCENDING
: OrderByOrdering.ASCENDING,
}));
const whereClause = this.combineWhereConditions(baseWhere, cursorCondition);
// Get ordering from strategy
const { clauses: orderByClauses, fragment: orderByFragment } =
paginationProvider.buildOrderBy(direction);
// Determine query modifiers
const queryModifiers: PostgresQuerySelectionModifiersWithOrderByFragment<TFields> = {
...(orderByFragment !== undefined && { orderByFragment }),
...(orderByClauses !== undefined && { orderBy: orderByClauses }),
limit: limit + 1, // Fetch data with limit + 1 to check for more pages
};
// Fetch data with limit + 1 to check for more pages
const fieldObjects = await timeAndLogLoadEventAsync(
this.metricsAdapter,
EntityMetricsLoadType.LOAD_PAGE,
this.entityClassName,
queryContext,
)(
this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, {
orderBy: finalOrderByClauses,
limit: limit + 1,
}),
);
)(this.databaseAdapter.fetchManyBySQLFragmentAsync(queryContext, whereClause, queryModifiers));
// Process results
const hasMore = fieldObjects.length > limit;
const pageFieldObjects = hasMore ? fieldObjects.slice(0, limit) : [...fieldObjects];
if (!isForward) {
if (direction === PaginationDirection.BACKWARD) {
// Restore the original requested order by reversing the results.
// We fetched with flipped ORDER BY for efficient cursor comparison,
// so now we reverse to match the order the user expects.
pageFieldObjects.reverse();
}
@@ -271,8 +401,8 @@ export class EntityKnexDataManager<
}));
const pageInfo: PageInfo = {
hasNextPage: isForward ? hasMore : false,
hasPreviousPage: !isForward ? hasMore : false,
hasNextPage: direction === PaginationDirection.FORWARD ? hasMore : false,
hasPreviousPage: direction === PaginationDirection.BACKWARD ? hasMore : false,
startCursor: edges[0]?.cursor ?? null,
endCursor: edges[edges.length - 1]?.cursor ?? null,
};
@@ -283,6 +413,23 @@ export class EntityKnexDataManager<
};
}
private combineWhereConditions(
baseWhere: SQLFragment | undefined,
cursorCondition: SQLFragment | null,
): SQLFragment {
const conditions = [baseWhere, cursorCondition].filter((it) => !!it);
if (conditions.length === 0) {
return sql`1 = 1`;
}
if (conditions.length === 1) {
return conditions[0]!;
}
// Wrap baseWhere in parens if combining with cursor condition
// We know we have exactly 2 conditions at this point
const [first, second] = conditions;
return sql`(${first}) AND ${second}`;
}
private augmentOrderByIfNecessary(
orderBy: PostgresOrderByClause<TFields>[] | undefined,
idField: TIDField,
@@ -322,38 +469,15 @@ export class EntityKnexDataManager<
return parsedCursor.id;
}
private buildWhereClause(options: {
where?: SQLFragment;
decodedExternalCursorEntityID: TFields[TIDField] | null;
fieldsToUseInPostgresCursor: readonly (keyof TFields)[];
direction: 'forward' | 'backward';
}): SQLFragment {
const { where, decodedExternalCursorEntityID, fieldsToUseInPostgresCursor, direction } =
options;
const cursorCondition = decodedExternalCursorEntityID
? this.buildCursorCondition(
decodedExternalCursorEntityID,
fieldsToUseInPostgresCursor,
direction === 'forward' ? '>' : '<',
)
: null;
// Combine conditions
const conditions = [where, cursorCondition].filter((it) => !!it);
// Return combined WHERE clause or "1 = 1" if no conditions
return conditions.length > 0 ? SQLFragment.join(conditions, ' AND ') : sql`1 = 1`;
}
private buildCursorCondition(
decodedExternalCursorEntityID: TFields[TIDField],
fieldsToUseInPostgresCursor: readonly (keyof TFields)[],
operator: '<' | '>',
fieldsToUseInPostgresTupleCursor: readonly (keyof TFields)[],
direction: PaginationDirection,
): SQLFragment {
// We build a tuple comparison for fieldsToUseInPostgresCursor fields of the
// We build a tuple comparison for fieldsToUseInPostgresTupleCursor fields of the
// entity identified by the external cursor to ensure correct pagination behavior
// even in cases where multiple rows have the same value all fields other than id.
const operator = direction === PaginationDirection.FORWARD ? '>' : '<';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
@@ -361,7 +485,7 @@ export class EntityKnexDataManager<
);
const tableName = this.entityConfiguration.tableName;
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => {
const postgresCursorFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${identifier(dbField)}`;
});
@@ -370,17 +494,240 @@ export class EntityKnexDataManager<
const leftSide = SQLFragment.join(postgresCursorFieldIdentifiers, ', ');
// Build right side using subquery to get computed values for cursor entity
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresCursor.map((f) => {
const postgresCursorRowFieldIdentifiers = fieldsToUseInPostgresTupleCursor.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`cursor_row.${identifier(dbField)}`;
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
});
// Build SELECT fields for subquery
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(postgresCursorRowFieldIdentifiers, ', ')}
FROM ${identifier(tableName)} AS cursor_row
WHERE cursor_row.${identifier(idField)} = ${decodedExternalCursorEntityID}
FROM ${identifier(tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
}
private buildILikeConditions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`${fieldIdentifier} ILIKE ${'%' + EntityKnexDataManager.escapeILikePattern(search.term) + '%'}`;
});
}
private buildTrigramSimilarityExpressions(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment[] {
return search.fields.map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
const fieldIdentifier = tableAlias
? sql`${raw(tableAlias)}.${identifier(dbField)}`
: sql`${identifier(dbField)}`;
return sql`similarity(${fieldIdentifier}, ${search.term})`;
});
}
private buildTrigramExactMatchCaseExpression(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment {
const ilikeConditions = this.buildILikeConditions(search, tableAlias);
return sql`CASE WHEN ${SQLFragment.join(ilikeConditions, ' OR ')} THEN 1 ELSE 0 END`;
}
private buildTrigramSimilarityGreatestExpression(
search: DataManagerSearchSpecification<TFields>,
tableAlias?: typeof CURSOR_ROW_TABLE_ALIAS,
): SQLFragment {
const similarityExprs = this.buildTrigramSimilarityExpressions(search, tableAlias);
return sql`GREATEST(${SQLFragment.join(similarityExprs, ', ')})`;
}
private buildTrigramCursorCondition(
search: DataManagerTrigramSearchSpecification<TFields>,
decodedExternalCursorEntityID: TFields[TIDField],
direction: PaginationDirection,
): SQLFragment {
// For TRIGRAM search, we compute the similarity values using a subquery, similar to normal cursor
const operator = direction === PaginationDirection.FORWARD ? '<' : '>';
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
this.entityConfiguration.idField,
);
const exactMatchExpr = this.buildTrigramExactMatchCaseExpression(search);
const similarityExpr = this.buildTrigramSimilarityGreatestExpression(search);
// Build extra order by fields
const extraOrderByFields = search.extraOrderByFields;
const extraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${identifier(dbField)}`;
}) ?? [];
// Build left side of comparison (current row's computed values)
const leftSide = SQLFragment.join(
[exactMatchExpr, similarityExpr, ...extraFields, sql`${identifier(idField)}`],
', ',
);
// Build right side using subquery to get computed values for cursor entity
// We need to rebuild the same expressions for the cursor row
const cursorExactMatchExpr = this.buildTrigramExactMatchCaseExpression(
search,
CURSOR_ROW_TABLE_ALIAS,
);
const cursorSimilarityExpr = this.buildTrigramSimilarityGreatestExpression(
search,
CURSOR_ROW_TABLE_ALIAS,
);
const cursorExtraFields =
extraOrderByFields?.map((f) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, f);
return sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(dbField)}`;
}) ?? [];
// Build SELECT fields for subquery
const selectFields = [
cursorExactMatchExpr,
cursorSimilarityExpr,
...cursorExtraFields,
sql`${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)}`,
];
const rightSideSubquery = sql`
SELECT ${SQLFragment.join(selectFields, ', ')}
FROM ${identifier(this.entityConfiguration.tableName)} AS ${raw(CURSOR_ROW_TABLE_ALIAS)}
WHERE ${raw(CURSOR_ROW_TABLE_ALIAS)}.${identifier(idField)} = ${decodedExternalCursorEntityID}
`;
return sql`(${leftSide}) ${raw(operator)} (${rightSideSubquery})`;
}
private buildSearchConditionAndOrderBy(
search: DataManagerSearchSpecification<TFields>,
direction: PaginationDirection,
): {
searchWhere: SQLFragment;
searchOrderByFragment: SQLFragment | undefined;
} {
switch (search.strategy) {
case PaginationStrategy.ILIKE_SEARCH: {
const conditions = this.buildILikeConditions(search);
// Order by search fields + ID to match cursor fields
const orderByFields = [...search.fields, this.entityConfiguration.idField].map((field) => {
const dbField = getDatabaseFieldForEntityField(this.entityConfiguration, field);
return sql`${identifier(dbField)} ${raw(
direction === PaginationDirection.FORWARD ? 'ASC' : 'DESC',
)}`;
});
return {
searchWhere: conditions.length > 0 ? SQLFragment.join(conditions, ' OR ') : sql`1 = 0`,
searchOrderByFragment: SQLFragment.join(orderByFields, ', '),
};
}
case PaginationStrategy.TRIGRAM_SEARCH: {
// PostgreSQL trigram similarity
const ilikeConditions = this.buildILikeConditions(search);
const similarityExprs = this.buildTrigramSimilarityExpressions(search);
assert(
search.threshold >= 0 && search.threshold <= 1,
`Trigram similarity threshold must be between 0 and 1, got ${search.threshold}`,
);
const conditions = similarityExprs.map((expr) => sql`${expr} > ${search.threshold}`);
// Combine exact matches (ILIKE) with similarity
const allConditions = [...ilikeConditions, ...conditions];
// Build ORDER BY components
const exactMatchPriority = this.buildTrigramExactMatchPriority(ilikeConditions, direction);
const similarityRanking = this.buildTrigramSimilarityRanking(search, direction);
const tieBreakers = this.buildTrigramTieBreakers(search, direction);
return {
searchWhere: SQLFragment.join(allConditions, ' OR '),
// For trigram search, order by relevance with direction-aware ordering
// 1. Exact matches first (ILIKE)
// 2. Then by similarity score
// 3. Then by extra fields and ID field for stability
searchOrderByFragment: SQLFragment.join(
[exactMatchPriority, similarityRanking, tieBreakers],
', ',
),
};
}
}
}
/**
* Builds the exact match priority component of the ORDER BY clause for trigram search.
* Exact matches (via ILIKE) are prioritized over similarity matches.
*/
private buildTrigramExactMatchPriority(
ilikeConditions: SQLFragment[],
direction: PaginationDirection,
): SQLFragment {
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const exactMatchCondition = SQLFragment.join(ilikeConditions, ' OR ');
return sql`CASE WHEN ${exactMatchCondition} THEN 1 ELSE 0 END ${raw(sortOrder)}`;
}
/**
* Builds the similarity score ranking component of the ORDER BY clause.
* Uses the highest similarity score across all search fields.
*/
private buildTrigramSimilarityRanking(
search: DataManagerSearchSpecification<TFields>,
direction: PaginationDirection,
): SQLFragment {
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const similarityGreatestExpr = this.buildTrigramSimilarityGreatestExpression(search);
return sql`${similarityGreatestExpr} ${raw(sortOrder)}`;
}
/**
* Builds the tie-breaker fields component of the ORDER BY clause.
* Includes extra order-by fields (if specified) and always includes the ID field for stability.
*/
private buildTrigramTieBreakers(
search: DataManagerTrigramSearchSpecification<TFields>,
direction: PaginationDirection,
): SQLFragment {
const idField = getDatabaseFieldForEntityField(
this.entityConfiguration,
this.entityConfiguration.idField,
);
const extraOrderByFields = search.extraOrderByFields?.map((field) =>
getDatabaseFieldForEntityField(this.entityConfiguration, field),
);
const sortOrder = direction === PaginationDirection.FORWARD ? 'DESC' : 'ASC';
const allTieBreakerFields = [...(extraOrderByFields ?? []), idField];
const tieBreakerClauses = allTieBreakerFields.map(
(field) => sql`${identifier(field)} ${raw(sortOrder)}`,
);
return SQLFragment.join(tieBreakerClauses, ', ');
}
private static escapeILikePattern(term: string): string {
return term.replace(/[%_\\]/g, '\\$&');
}
}
@@ -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<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([]);
});
});
});
});
@@ -0,0 +1,25 @@
import { describe, expect, it } from '@jest/globals';
import { computeIfAbsentInWeakMap } from '../weakMaps';
describe(computeIfAbsentInWeakMap, () => {
it('computes a value when absent', () => {
const map = new WeakMap<object, string>();
const key = {};
const blah = computeIfAbsentInWeakMap(map, key, () => 'world');
expect(blah).toEqual(map.get(key));
});
it('does not compute a value when already present', () => {
const map = new WeakMap<object, string>();
const key = {};
map.set(key, 'world');
let didCompute = false;
const blah = computeIfAbsentInWeakMap(map, key, () => {
didCompute = true;
return 'world2';
});
expect(blah).toEqual(map.get(key));
expect(didCompute).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { EntityDatabaseAdapter, EntityTableDataCoordinator } from '@expo/entity';
import assert from 'assert';
import { BasePostgresEntityDatabaseAdapter } from '../BasePostgresEntityDatabaseAdapter';
import { EntityKnexDataManager } from './EntityKnexDataManager';
import { computeIfAbsentInWeakMap } from './weakMaps';
const knexDataManagerCache = new WeakMap<
EntityTableDataCoordinator<any, any>,
EntityKnexDataManager<any, any>
>();
export function getKnexDataManager<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
tableDataCoordinator: EntityTableDataCoordinator<TFields, TIDField>,
): EntityKnexDataManager<TFields, TIDField> {
return computeIfAbsentInWeakMap(
knexDataManagerCache,
tableDataCoordinator,
(coordinator) =>
new EntityKnexDataManager(
coordinator.entityConfiguration,
requireBasePostgresAdapter(coordinator.databaseAdapter),
coordinator.metricsAdapter,
coordinator.entityClassName,
),
);
}
function requireBasePostgresAdapter<
TFields extends Record<string, any>,
TIDField extends keyof TFields,
>(
databaseAdapter: EntityDatabaseAdapter<TFields, TIDField>,
): BasePostgresEntityDatabaseAdapter<TFields, TIDField> {
assert(
databaseAdapter instanceof BasePostgresEntityDatabaseAdapter,
`Cannot create KnexDataManager for EntityTableDataCoordinator with non-Postgres database adapter.`,
);
return databaseAdapter;
}
@@ -0,0 +1,59 @@
import {
EntityCompanion,
EntityPrivacyPolicy,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { KnexEntityLoaderFactory } from '../KnexEntityLoaderFactory';
import { getKnexDataManager } from './getKnexDataManager';
import { computeIfAbsentInWeakMap } from './weakMaps';
const knexEntityLoaderFactoryCache = new WeakMap<
EntityCompanion<any, any, any, any, any, any>,
KnexEntityLoaderFactory<any, any, any, any, any, any>
>();
export function getKnexEntityLoaderFactory<
TFields extends Record<string, any>,
TIDField extends keyof NonNullable<Pick<TFields, TSelectedFields>>,
TViewerContext extends ViewerContext,
TEntity extends ReadonlyEntity<TFields, TIDField, TViewerContext, TSelectedFields>,
TPrivacyPolicy extends EntityPrivacyPolicy<
TFields,
TIDField,
TViewerContext,
TEntity,
TSelectedFields
>,
TSelectedFields extends keyof TFields = keyof TFields,
>(
entityClass: IEntityClass<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
viewerContext: TViewerContext,
): KnexEntityLoaderFactory<
TFields,
TIDField,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return computeIfAbsentInWeakMap(
knexEntityLoaderFactoryCache,
viewerContext.entityCompanionProvider.getCompanionForEntity(entityClass),
(companion) =>
new KnexEntityLoaderFactory(
companion,
getKnexDataManager(companion.tableDataCoordinator),
companion.metricsAdapter,
),
);
}
@@ -0,0 +1,19 @@
/**
* If the specified key is not already associated with a value in this weak map, computes
* its value using the given mapping function and enters it into this map.
*
* @param map - map from which to get the key's value or compute and associate
* @param key - key for which to get the value or with which the computed value is to be associated
* @param mappingFunction - function to compute a value for key
*/
export const computeIfAbsentInWeakMap = <K extends WeakKey, V>(
map: WeakMap<K, V>,
key: K,
mappingFunction: (key: K) => V,
): V => {
if (!map.has(key)) {
const value = mappingFunction(key);
map.set(key, value);
}
return map.get(key)!;
};
@@ -0,0 +1,108 @@
import {
EntityPrivacyPolicy,
EntityQueryContext,
IEntityClass,
ReadonlyEntity,
ViewerContext,
} from '@expo/entity';
import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader';
import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader';
import { getKnexEntityLoaderFactory } from './internal/getKnexEntityLoaderFactory';
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* @param entityClass - entity class to load
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
export function knexLoader<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
entityClass: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
.getQueryContext(),
): EnforcingKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoadEnforcing(
viewerContext,
queryContext,
{ previousValue: null, cascadingDeleteCause: null },
);
}
/**
* Vend knex loader for loading entities via non-data-loader methods in a given query context.
* Returns authorization results instead of throwing on authorization errors.
* @param entityClass - entity class to load
* @param viewerContext - viewer context of loading user
* @param queryContext - query context in which to perform the load
*/
export function knexLoaderWithAuthorizationResults<
TMFields extends object,
TMIDField extends keyof NonNullable<Pick<TMFields, TMSelectedFields>>,
TMViewerContext extends ViewerContext,
TMEntity extends ReadonlyEntity<TMFields, TMIDField, TMViewerContext, TMSelectedFields>,
TMPrivacyPolicy extends EntityPrivacyPolicy<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMSelectedFields
>,
TMSelectedFields extends keyof TMFields = keyof TMFields,
>(
entityClass: IEntityClass<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
>,
viewerContext: TMViewerContext,
queryContext: EntityQueryContext = viewerContext
.getViewerScopedEntityCompanionForClass(entityClass)
.getQueryContextProvider()
.getQueryContext(),
): AuthorizationResultBasedKnexEntityLoader<
TMFields,
TMIDField,
TMViewerContext,
TMEntity,
TMPrivacyPolicy,
TMSelectedFields
> {
return getKnexEntityLoaderFactory(entityClass, viewerContext).forLoad(
viewerContext,
queryContext,
{ previousValue: null, cascadingDeleteCause: null },
);
}
@@ -10,14 +10,6 @@ import { v4 as uuidv4 } from 'uuid';
const dbObjects: Readonly<{ [key: string]: any }>[] = [];
export class InMemoryDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getExtensionsKey(): string {
return 'InMemoryDatabaseAdapterProvider';
}
installExtensions(): void {
// No-op
}
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
@@ -7,14 +7,6 @@ import {
import { StubDatabaseAdapter } from './StubDatabaseAdapter';
export class StubDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getExtensionsKey(): string {
return 'StubDatabaseAdapterProvider';
}
installExtensions(): void {
// No-op
}
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
+1 -16
View File
@@ -133,7 +133,6 @@ export class EntityCompanionProvider {
new Map();
private readonly tableDataCoordinatorMap: Map<string, EntityTableDataCoordinator<any, any>> =
new Map();
private static readonly installedExtensions = new Set<string>();
/**
* Instantiate an Entity framework.
@@ -159,21 +158,7 @@ export class EntityCompanionProvider {
any,
any
> = {},
) {
// Install any extensions required by the database adapter flavors
for (const flavorDefinition of databaseAdapterFlavors.values()) {
if (
!EntityCompanionProvider.installedExtensions.has(
flavorDefinition.adapterProvider.getExtensionsKey(),
)
) {
flavorDefinition.adapterProvider.installExtensions();
EntityCompanionProvider.installedExtensions.add(
flavorDefinition.adapterProvider.getExtensionsKey(),
);
}
}
}
) {}
/**
* Get the entity companion for specified entity. If not already computed and cached, the entity
@@ -8,16 +8,6 @@ import { EntityDatabaseAdapter } from './EntityDatabaseAdapter';
* Allows for passing global configuration to databse adapters, making testing easier.
*/
export interface IEntityDatabaseAdapterProvider {
/**
* A unique key for this type of adapter provider, used to avoid installing extensions multiple times.
*/
getExtensionsKey(): string;
/**
* Install any necessary extensions to the Entity system.
*/
installExtensions(): void;
/**
* Vend a database adapter.
*/
@@ -370,12 +370,6 @@ const createEntityMutatorFactory = (
),
);
const customStubDatabaseAdapterProvider: IEntityDatabaseAdapterProvider = {
getExtensionsKey() {
return 'CustomStubDatabaseAdapterProvider';
},
installExtensions: () => {},
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
_entityConfiguration: EntityConfiguration<TFields, TIDField>,
): EntityDatabaseAdapter<TFields, TIDField> {
@@ -4,14 +4,6 @@ import { IEntityDatabaseAdapterProvider } from '../../IEntityDatabaseAdapterProv
import { StubDatabaseAdapter } from '../__testfixtures__/StubDatabaseAdapter';
export class StubDatabaseAdapterProvider implements IEntityDatabaseAdapterProvider {
getExtensionsKey(): string {
return 'StubDatabaseAdapterProvider';
}
installExtensions(): void {
// No-op
}
private readonly objectCollection = new Map();
getDatabaseAdapter<TFields extends Record<string, any>, TIDField extends keyof TFields>(
@@ -1,8 +1,8 @@
import invariant from 'invariant';
/**
* If the specified key is not already associated with a value in this map, attempts to compute
* its value using the given mapping function and enters it into this map unless null.
* If the specified key is not already associated with a value in this map, computes
* its value using the given mapping function and enters it into this map.
*
* @param map - map from which to get the key's value or compute and associate
* @param key - key for which to get the value or with which the computed value is to be associated