import type { EntityConstructionUtils, EntityPrivacyPolicy, EntityQueryContext, IEntityMetricsAdapter, ReadonlyEntity, ViewerContext, } from '@expo/entity'; import type { AuthorizationResultBasedKnexEntityLoader, EntityLoaderLoadPageArgs, EntityLoaderQuerySelectionModifiers, } from './AuthorizationResultBasedKnexEntityLoader.ts'; import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts'; import type { SQLFragment } from './SQLOperator.ts'; import type { Connection, EntityKnexDataManager } from './internal/EntityKnexDataManager.ts'; /** * Enforcing knex entity loader for non-data-loader-based load methods. * All loads through this loader will throw if the load is not successful. */ export class EnforcingKnexEntityLoader< TFields extends Record, TIDField extends keyof NonNullable>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity, TPrivacyPolicy extends EntityPrivacyPolicy< TFields, TIDField, TViewerContext, TEntity, TSelectedFields >, TSelectedFields extends keyof TFields, > { constructor( private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields >, private readonly queryContext: EntityQueryContext, private readonly knexDataManager: EntityKnexDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, private readonly constructionUtils: EntityConstructionUtils< TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields >, ) {} /** * Load the first entity matching the conjunction of field equality operands and * query modifiers. * * This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the * orderBy query modifier is required to ensure consistent results if more than one entity matches * the filters. * * @throws EntityNotAuthorizedError if viewer is not authorized to view the entity * @returns the first entity matching the filters, or null if none match */ async loadFirstByFieldEqualityConjunctionAsync>( fieldEqualityOperands: FieldEqualityCondition[], querySelectionModifiers: Omit< EntityLoaderQuerySelectionModifiers, 'limit' > & Required, 'orderBy'>>, ): Promise { const entityResult = await this.knexEntityLoader.loadFirstByFieldEqualityConjunctionAsync( fieldEqualityOperands, querySelectionModifiers, ); return entityResult?.enforceValue() ?? null; } /** * Load entities matching the conjunction of field equality operands and * query modifiers. * * Typically this is used for complex queries that cannot be expressed through simpler * convenience methods such as {@link loadManyByFieldEqualingAsync}. * * @throws EntityNotAuthorizedError if viewer is not authorized to view the entity * @returns entities matching the filters */ async loadManyByFieldEqualityConjunctionAsync>( fieldEqualityOperands: FieldEqualityCondition[], querySelectionModifiers: EntityLoaderQuerySelectionModifiers = {}, ): Promise { const entityResults = await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync( fieldEqualityOperands, querySelectionModifiers, ); return entityResults.map((result) => result.enforceValue()); } /** * Count entities matching the conjunction of field equality operands. * This does not perform authorization since count does not load full entities. * Note that this should be used with the same caution as loadManyByFieldEqualityConjunctionAsync * regarding indexing since counts can be expensive on large datasets without appropriate indexes. * * @returns count of entities matching the filters */ async countByFieldEqualityConjunctionAsync>( fieldEqualityOperands: FieldEqualityCondition[], ): Promise { return await this.knexEntityLoader.countByFieldEqualityConjunctionAsync(fieldEqualityOperands); } /** * Count entities matching a SQL fragment. * This does not perform authorization since count does not load full entity rows. * Note that this should be used with the same caution as loadManyBySQL regarding indexing * since counts can be expensive on large datasets without appropriate indexes. * * @returns count of entities matching the query */ async countBySQLAsync(fragment: SQLFragment>): Promise { return await this.knexEntityLoader.countBySQLAsync(fragment); } /** * Load entities using a SQL query builder. When executed, all queries will enforce authorization and throw if not authorized. * * @example * ```ts * const entities = await ExampleEntity.loader(vc) * .loadManyBySQL(sql`age >= ${18} AND status = ${'active'}`) * .orderBy('createdAt', 'DESC') * .limit(10) * .executeAsync(); * * const { between, inArray } = SQLExpression; * const filtered = await ExampleEntity.loader(vc) * .loadManyBySQL( * sql`${between('age', 18, 65)} AND ${inArray('role', ['admin', 'moderator'])}` * ) * .executeAsync(); * ``` */ loadManyBySQL( fragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers = {}, ): EnforcingSQLQueryBuilder< TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields > { return new EnforcingSQLQueryBuilder(this.knexEntityLoader, fragment, modifiers); } /** * Load a page of entities with Relay-style cursor pagination. * * @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 loadPageAsync( args: EntityLoaderLoadPageArgs, ): Promise> { 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( edge.node, ); const entity = entityResult.enforceValue(); return { ...edge, node: entity, }; }), ); return { edges, pageInfo: pageResult.pageInfo, }; } /** * Get cursor for a given entity that matches what loadPageAsync would produce. * Useful for constructing pagination cursors for entities returned from other loader methods that can then be passed to loadPageAsync for pagination. * Most commonly used for testing pagination behavior. * * @param entity - The entity to get the pagination cursor for. * @returns The pagination cursor for the given entity. */ getPaginationCursorForEntity(entity: TEntity): string { return this.knexEntityLoader.getPaginationCursorForEntity(entity); } } /** * SQL query builder for EnforcingKnexEntityLoader. * Provides a fluent API for building and executing SQL queries with enforced authorization. */ export class EnforcingSQLQueryBuilder< TFields extends Record, TIDField extends keyof NonNullable>, TViewerContext extends ViewerContext, TEntity extends ReadonlyEntity, TPrivacyPolicy extends EntityPrivacyPolicy< TFields, TIDField, TViewerContext, TEntity, TSelectedFields >, TSelectedFields extends keyof TFields, > extends BaseSQLQueryBuilder { constructor( private readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< TFields, TIDField, TViewerContext, TEntity, TPrivacyPolicy, TSelectedFields >, sqlFragment: SQLFragment>, modifiers: EntityLoaderQuerySelectionModifiers, ) { super(sqlFragment, modifiers); } /** * Execute the query. * @returns entities matching the query * @throws EntityNotAuthorizedError if viewer is not authorized to view any entity */ async executeInternalAsync(): Promise { const entityResults = await this.knexEntityLoader .loadManyBySQL(this.getSQLFragment(), this.getModifiers()) .executeAsync(); return entityResults.map((result) => result.enforceValue()); } }