From a0423c59abd8e5d40002d46bc7677bc7d0d6a03c Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Fri, 30 Jan 2026 11:22:17 -0700 Subject: [PATCH] feat!: Move knex-specific loader methods to knexLoader --- .../PostgresEntityIntegration-test.ts | 67 ++-- .../LocalMemorySecondaryEntityCache-test.ts | 4 +- ...isSecondaryEntityCache-integration-test.ts | 4 +- .../AuthorizationResultBasedEntityLoader.ts | 84 +---- ...uthorizationResultBasedKnexEntityLoader.ts | 109 ++++++ packages/entity/src/EnforcingEntityLoader.ts | 94 ----- .../entity/src/EnforcingKnexEntityLoader.ts | 126 +++++++ packages/entity/src/EntityCompanion.ts | 28 ++ packages/entity/src/EntityLoaderUtils.ts | 23 ++ .../entity/src/EntitySecondaryCacheLoader.ts | 9 + packages/entity/src/KnexEntityLoader.ts | 75 ++++ .../entity/src/KnexEntityLoaderFactory.ts | 83 +++++ packages/entity/src/ReadonlyEntity.ts | 91 +++++ .../entity/src/ViewerScopedEntityCompanion.ts | 18 + .../ViewerScopedKnexEntityLoaderFactory.ts | 60 ++++ ...thorizationResultBasedEntityLoader-test.ts | 306 ---------------- ...izationResultBasedKnexEntityLoader-test.ts | 330 ++++++++++++++++++ .../__tests__/EnforcingEntityLoader-test.ts | 134 ------- .../EnforcingKnexEntityLoader-test.ts | 154 ++++++++ .../EntitySecondaryCacheLoader-test.ts | 15 +- .../GenericSecondaryEntityCache-test.ts | 4 +- .../src/__tests__/ReadonlyEntity-test.ts | 20 ++ .../TwoEntitySameTableDisjointRows-test.ts | 2 +- packages/entity/src/index.ts | 5 + .../entity/src/utils/EntityPrivacyUtils.ts | 18 +- 25 files changed, 1201 insertions(+), 662 deletions(-) create mode 100644 packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts create mode 100644 packages/entity/src/EnforcingKnexEntityLoader.ts create mode 100644 packages/entity/src/KnexEntityLoader.ts create mode 100644 packages/entity/src/KnexEntityLoaderFactory.ts create mode 100644 packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts create mode 100644 packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts create mode 100644 packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts index 386ff5008..1c3134089 100644 --- a/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/PostgresEntityIntegration-test.ts @@ -390,7 +390,9 @@ describe('postgres entity integration', () => { .createAsync(), ); - const results = await PostgresTestEntity.loader(vc1).loadManyByFieldEqualityConjunctionAsync([ + const results = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByFieldEqualityConjunctionAsync([ { fieldName: 'hasACat', fieldValue: false, @@ -403,9 +405,11 @@ describe('postgres entity integration', () => { expect(results).toHaveLength(2); - const results2 = await PostgresTestEntity.loader(vc1).loadManyByFieldEqualityConjunctionAsync( - [{ fieldName: 'hasADog', fieldValues: [true, false] }], - ); + const results2 = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'hasADog', fieldValues: [true, false] }, + ]); expect(results2).toHaveLength(3); }); @@ -424,19 +428,18 @@ describe('postgres entity integration', () => { PostgresTestEntity.creatorWithAuthorizationResults(vc1).setField('name', 'c').createAsync(), ); - const results = await PostgresTestEntity.loader(vc1).loadManyByFieldEqualityConjunctionAsync( - [], - { - limit: 2, - offset: 1, - orderBy: [ - { - fieldName: 'name', - order: OrderByOrdering.DESCENDING, - }, - ], - }, - ); + const results = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByFieldEqualityConjunctionAsync([], { + limit: 2, + offset: 1, + orderBy: [ + { + fieldName: 'name', + order: OrderByOrdering.DESCENDING, + }, + ], + }); expect(results).toHaveLength(2); expect(results.map((e) => e.getField('name'))).toEqual(['b', 'a']); }); @@ -468,13 +471,15 @@ describe('postgres entity integration', () => { .createAsync(), ); - const results = await PostgresTestEntity.loader(vc1).loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'name', fieldValue: null }, - ]); + const results = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByFieldEqualityConjunctionAsync([{ fieldName: 'name', fieldValue: null }]); expect(results).toHaveLength(2); expect(results[0]!.getField('name')).toBeNull(); - const results2 = await PostgresTestEntity.loader(vc1).loadManyByFieldEqualityConjunctionAsync( + const results2 = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByFieldEqualityConjunctionAsync( [ { fieldName: 'name', fieldValues: ['a', null] }, { fieldName: 'hasADog', fieldValue: true }, @@ -504,7 +509,7 @@ describe('postgres entity integration', () => { .createAsync(), ); - const results = await PostgresTestEntity.loader(vc1).loadManyByRawWhereClauseAsync( + const results = await PostgresTestEntity.knexLoader(vc1).loadManyByRawWhereClauseAsync( 'name = ?', ['hello'], ); @@ -523,7 +528,7 @@ describe('postgres entity integration', () => { ); await expect( - PostgresTestEntity.loader(vc1).loadManyByRawWhereClauseAsync('invalid_column = ?', [ + PostgresTestEntity.knexLoader(vc1).loadManyByRawWhereClauseAsync('invalid_column = ?', [ 'hello', ]), ).rejects.toThrow(); @@ -553,7 +558,7 @@ describe('postgres entity integration', () => { .createAsync(), ); - const results = await PostgresTestEntity.loader(vc1).loadManyByRawWhereClauseAsync( + const results = await PostgresTestEntity.knexLoader(vc1).loadManyByRawWhereClauseAsync( 'has_a_dog = ?', [true], { @@ -571,7 +576,7 @@ describe('postgres entity integration', () => { expect(results).toHaveLength(2); expect(results.map((e) => e.getField('name'))).toEqual(['b', 'c']); - const resultsMultipleOrderBy = await PostgresTestEntity.loader( + const resultsMultipleOrderBy = await PostgresTestEntity.knexLoader( vc1, ).loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], { orderBy: [ @@ -589,13 +594,11 @@ describe('postgres entity integration', () => { expect(resultsMultipleOrderBy).toHaveLength(3); expect(resultsMultipleOrderBy.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); - const resultsOrderByRaw = await PostgresTestEntity.loader(vc1).loadManyByRawWhereClauseAsync( - 'has_a_dog = ?', - [true], - { - orderByRaw: 'has_a_dog ASC, name DESC', - }, - ); + const resultsOrderByRaw = await PostgresTestEntity.knexLoader( + vc1, + ).loadManyByRawWhereClauseAsync('has_a_dog = ?', [true], { + orderByRaw: 'has_a_dog ASC, name DESC', + }); expect(resultsOrderByRaw).toHaveLength(3); expect(resultsOrderByRaw.map((e) => e.getField('name'))).toEqual(['c', 'b', 'a']); diff --git a/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts b/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts index b4c61a59b..f430dd3e4 100644 --- a/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts +++ b/packages/entity-secondary-cache-local-memory/src/__tests__/LocalMemorySecondaryEntityCache-test.ts @@ -173,7 +173,7 @@ class TestSecondaryLocalMemoryCacheLoader extends EntitySecondaryCacheLoader< } return nullthrows( ( - await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([ + await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ { fieldName: 'id', fieldValue: loadParams.id }, ]) )[0], @@ -198,6 +198,7 @@ describe(LocalMemorySecondaryEntityCache, () => { createTTLCache(), ), LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext), + LocalMemoryTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: createdEntity.getID() }; @@ -234,6 +235,7 @@ describe(LocalMemorySecondaryEntityCache, () => { createTTLCache(), ), LocalMemoryTestEntity.loaderWithAuthorizationResults(viewerContext), + LocalMemoryTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: FAKE_ID }; diff --git a/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts b/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts index 6bd1f9dde..32e424e16 100644 --- a/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts +++ b/packages/entity-secondary-cache-redis/src/__integration-tests__/RedisSecondaryEntityCache-integration-test.ts @@ -45,7 +45,7 @@ class TestSecondaryRedisCacheLoader extends EntitySecondaryCacheLoader< } return nullthrows( ( - await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([ + await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ { fieldName: 'id', fieldValue: loadParams.id }, ]) )[0], @@ -98,6 +98,7 @@ describe(RedisSecondaryEntityCache, () => { (loadParams) => `test-key-${loadParams.id}`, ), RedisTestEntity.loaderWithAuthorizationResults(viewerContext), + RedisTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: createdEntity.getID() }; @@ -137,6 +138,7 @@ describe(RedisSecondaryEntityCache, () => { (loadParams) => `test-key-${loadParams.id}`, ), RedisTestEntity.loaderWithAuthorizationResults(viewerContext), + RedisTestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { id: FAKE_ID }; diff --git a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts index 94744102b..b10f8ca0a 100644 --- a/packages/entity/src/AuthorizationResultBasedEntityLoader.ts +++ b/packages/entity/src/AuthorizationResultBasedEntityLoader.ts @@ -8,18 +8,11 @@ import { EntityCompositeFieldValue, EntityConfiguration, } from './EntityConfiguration'; -import { - FieldEqualityCondition, - isSingleValueFieldEqualityCondition, - QuerySelectionModifiers, - QuerySelectionModifiersWithOrderByRaw, -} from './EntityDatabaseAdapter'; import { EntityLoaderUtils } from './EntityLoaderUtils'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; -import { EntityInvalidFieldValueError } from './errors/EntityInvalidFieldValueError'; import { EntityNotFoundError } from './errors/EntityNotFoundError'; import { CompositeFieldHolder, CompositeFieldValueHolder } from './internal/CompositeFieldHolder'; import { CompositeFieldValueMap } from './internal/CompositeFieldValueMap'; @@ -265,79 +258,6 @@ export class AuthorizationResultBasedEntityLoader< }); } - /** - * Authorization-result-based version of the EnforcingEntityLoader method by the same name. - * @returns the first entity results that matches the query, where result error can be - * UnauthorizedError - */ - async loadFirstByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: Omit, 'limit'> & - Required, 'orderBy'>>, - ): Promise | null> { - const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, { - ...querySelectionModifiers, - limit: 1, - }); - return results[0] ?? null; - } - - /** - * Authorization-result-based version of the EnforcingEntityLoader method by the same name. - * @returns array of entity results that match the query, where result error can be UnauthorizedError - */ - async loadManyByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: QuerySelectionModifiers = {}, - ): Promise[]> { - for (const fieldEqualityOperand of fieldEqualityOperands) { - const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand) - ? [fieldEqualityOperand.fieldValue] - : fieldEqualityOperand.fieldValues; - this.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues); - } - - const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( - this.queryContext, - fieldEqualityOperands, - querySelectionModifiers, - ); - return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); - } - - /** - * Authorization-result-based version of the EnforcingEntityLoader method by the same name. - * @returns array of entity results that match the query, where result error can be UnauthorizedError - * @throws Error when rawWhereClause or bindings are invalid - */ - async loadManyByRawWhereClauseAsync( - rawWhereClause: string, - bindings: any[] | object, - querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, - ): Promise[]> { - const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( - this.queryContext, - rawWhereClause, - bindings, - querySelectionModifiers, - ); - return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); - } - - private validateFieldAndValues>( - fieldName: N, - fieldValues: readonly TFields[N][], - ): void { - const fieldDefinition = this.entityConfiguration.schema.get(fieldName); - invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`); - for (const fieldValue of fieldValues) { - const isInputValid = fieldDefinition.validateInputValue(fieldValue); - if (!isInputValid) { - throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue); - } - } - } - private validateFieldAndValuesAndConvertToHolders>( fieldName: N, fieldValues: readonly NonNullable[], @@ -345,7 +265,7 @@ export class AuthorizationResultBasedEntityLoader< loadKey: SingleFieldHolder; loadValues: readonly SingleFieldValueHolder[]; } { - this.validateFieldAndValues(fieldName, fieldValues); + this.utils.validateFieldAndValues(fieldName, fieldValues); return { loadKey: new SingleFieldHolder(fieldName), @@ -388,7 +308,7 @@ export class AuthorizationResultBasedEntityLoader< ); for (const field of compositeField) { const fieldValue = compositeFieldValueHolder.compositeFieldValue[field]; - this.validateFieldAndValues(field, [fieldValue]); + this.utils.validateFieldAndValues(field, [fieldValue]); } } diff --git a/packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts new file mode 100644 index 000000000..1bf25b13a --- /dev/null +++ b/packages/entity/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -0,0 +1,109 @@ +import { Result } from '@expo/results'; + +import { + FieldEqualityCondition, + isSingleValueFieldEqualityCondition, + QuerySelectionModifiers, + QuerySelectionModifiersWithOrderByRaw, +} from './EntityDatabaseAdapter'; +import { EntityLoaderUtils } from './EntityLoaderUtils'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; +import { EntityDataManager } from './internal/EntityDataManager'; +import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; + +/** + * Authorization-result-based knex entity loader for non-data-loader-based load methods. + * 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. + */ +export class AuthorizationResultBasedKnexEntityLoader< + 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 queryContext: EntityQueryContext, + private readonly dataManager: EntityDataManager, + protected readonly metricsAdapter: IEntityMetricsAdapter, + public readonly utils: EntityLoaderUtils< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + ) {} + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns the first entity results that matches the query, where result error can be + * UnauthorizedError + */ + async loadFirstByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: Omit, 'limit'> & + Required, 'orderBy'>>, + ): Promise | null> { + const results = await this.loadManyByFieldEqualityConjunctionAsync(fieldEqualityOperands, { + ...querySelectionModifiers, + limit: 1, + }); + return results[0] ?? null; + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns array of entity results that match the query, where result error can be UnauthorizedError + */ + async loadManyByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: QuerySelectionModifiers = {}, + ): Promise[]> { + for (const fieldEqualityOperand of fieldEqualityOperands) { + const fieldValues = isSingleValueFieldEqualityCondition(fieldEqualityOperand) + ? [fieldEqualityOperand.fieldValue] + : fieldEqualityOperand.fieldValues; + this.utils.validateFieldAndValues(fieldEqualityOperand.fieldName, fieldValues); + } + + const fieldObjects = await this.dataManager.loadManyByFieldEqualityConjunctionAsync( + this.queryContext, + fieldEqualityOperands, + querySelectionModifiers, + ); + return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns array of entity results that match the query, where result error can be UnauthorizedError + * @throws Error when rawWhereClause or bindings are invalid + */ + async loadManyByRawWhereClauseAsync( + rawWhereClause: string, + bindings: any[] | object, + querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, + ): Promise[]> { + const fieldObjects = await this.dataManager.loadManyByRawWhereClauseAsync( + this.queryContext, + rawWhereClause, + bindings, + querySelectionModifiers, + ); + return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects); + } +} diff --git a/packages/entity/src/EnforcingEntityLoader.ts b/packages/entity/src/EnforcingEntityLoader.ts index f654f18c6..256b91b3c 100644 --- a/packages/entity/src/EnforcingEntityLoader.ts +++ b/packages/entity/src/EnforcingEntityLoader.ts @@ -1,10 +1,5 @@ import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; import { EntityCompositeField, EntityCompositeFieldValue } from './EntityConfiguration'; -import { - FieldEqualityCondition, - QuerySelectionModifiers, - QuerySelectionModifiersWithOrderByRaw, -} from './EntityDatabaseAdapter'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; @@ -219,93 +214,4 @@ export class EnforcingEntityLoader< const entityResults = await this.entityLoader.loadManyByIDsNullableAsync(ids); return mapMap(entityResults, (result) => result?.enforceValue() ?? null); } - - /** - * Loads the first entity matching the selection constructed from the conjunction of specified - * operands, or null if no matching entity exists. Entities loaded using this method are not - * batched or cached. - * - * This is a convenience method for {@link loadManyByFieldEqualityConjunctionAsync}. However, the - * `orderBy` option must be specified to define what "first" means. If ordering doesn't matter, - * explicitly pass in an empty array. - * - * @param fieldEqualityOperands - list of field equality selection operand specifications - * @param querySelectionModifiers - orderBy and optional offset for the query - * @returns the first entity that matches the query or null if no entity matches the query - * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity - */ - async loadFirstByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: Omit, 'limit'> & - Required, 'orderBy'>>, - ): Promise { - const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync( - fieldEqualityOperands, - querySelectionModifiers, - ); - return entityResult ? entityResult.enforceValue() : null; - } - - /** - * Loads many entities matching the selection constructed from the conjunction of specified operands. - * Entities loaded using this method are not batched or cached. - * - * @example - * fieldEqualityOperands: - * `[{fieldName: 'hello', fieldValue: 1}, {fieldName: 'world', fieldValues: [2, 3]}]` - * Entities returned with a SQL EntityDatabaseAdapter: - * `WHERE hello = 1 AND world = ANY({2, 3})` - * - * @param fieldEqualityOperands - list of field equality selection operand specifications - * @param querySelectionModifiers - limit, offset, and orderBy for the query - * @returns array of entities that match the query - * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities - */ - async loadManyByFieldEqualityConjunctionAsync>( - fieldEqualityOperands: FieldEqualityCondition[], - querySelectionModifiers: QuerySelectionModifiers = {}, - ): Promise { - const entityResults = await this.entityLoader.loadManyByFieldEqualityConjunctionAsync( - fieldEqualityOperands, - querySelectionModifiers, - ); - return entityResults.map((result) => result.enforceValue()); - } - - /** - * Loads many entities matching the raw WHERE clause. Corresponds to the knex `whereRaw` argument format. - * - * @remarks - * Important notes: - * - Fields in clause are database column names instead of transformed entity field names. - * - Entities loaded using this method are not batched or cached. - * - Not all database adapters implement the ability to execute this method of fetching entities. - * - * @example - * rawWhereClause: `id = ?` - * bindings: `[1]` - * Entites returned `WHERE id = 1` - * - * http://knexjs.org/#Builder-whereRaw - * http://knexjs.org/#Raw-Bindings - * - * @param rawWhereClause - parameterized SQL WHERE clause with positional binding placeholders or named binding placeholders - * @param bindings - array of positional bindings or object of named bindings - * @param querySelectionModifiers - limit, offset, orderBy, and orderByRaw for the query - * @returns array of entities that match the query - * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities - * @throws Error when rawWhereClause or bindings are invalid - */ - async loadManyByRawWhereClauseAsync( - rawWhereClause: string, - bindings: any[] | object, - querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, - ): Promise { - const entityResults = await this.entityLoader.loadManyByRawWhereClauseAsync( - rawWhereClause, - bindings, - querySelectionModifiers, - ); - return entityResults.map((result) => result.enforceValue()); - } } diff --git a/packages/entity/src/EnforcingKnexEntityLoader.ts b/packages/entity/src/EnforcingKnexEntityLoader.ts new file mode 100644 index 000000000..d78fe33b8 --- /dev/null +++ b/packages/entity/src/EnforcingKnexEntityLoader.ts @@ -0,0 +1,126 @@ +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { + FieldEqualityCondition, + QuerySelectionModifiers, + QuerySelectionModifiersWithOrderByRaw, +} from './EntityDatabaseAdapter'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * 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 + >, + ) {} + + /** + * 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, '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: QuerySelectionModifiers = {}, + ): Promise { + const entityResults = await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync( + fieldEqualityOperands, + querySelectionModifiers, + ); + return entityResults.map((result) => result.enforceValue()); + } + + /** + * Load entities with a raw SQL WHERE clause. + * + * @example + * Load entities with SQL function + * ```typescript + * const entitiesWithJsonKey = await ExampleEntity.loader(vc) + * .loadManyByRawWhereClauseAsync( + * "json_column->>'key_name' = ?", + * ['value'], + * ); + * ``` + * + * @example + * Load entities with tuple matching + * ```typescript + * const entities = await ExampleEntity.loader(vc) + * .loadManyByRawWhereClauseAsync( + * '(column_1, column_2) IN ((?, ?), (?, ?))', + * [value1, value2, value3, value4], + * ); + * ``` + * @param rawWhereClause - SQL WHERE clause. Interpolated values should be specified as ?-placeholders or :key_name + * @param bindings - values to bind to the placeholders in the WHERE clause + * @param querySelectionModifiers - limit, offset, and orderBy for the query. If orderBy is specified + * as orderByRaw, specify as string orderBy SQL clause with uncheckd literal values or ?-placeholders + * @returns entities matching the WHERE clause + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + * @throws Error when rawWhereClause or bindings are invalid + */ + async loadManyByRawWhereClauseAsync( + rawWhereClause: string, + bindings: any[] | object, + querySelectionModifiers: QuerySelectionModifiersWithOrderByRaw = {}, + ): Promise { + const entityResults = await this.knexEntityLoader.loadManyByRawWhereClauseAsync( + rawWhereClause, + bindings, + querySelectionModifiers, + ); + return entityResults.map((result) => result.enforceValue()); + } +} diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index b63981fc8..82cf9be82 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -3,6 +3,7 @@ import { EntityLoaderFactory } from './EntityLoaderFactory'; import { EntityMutatorFactory } from './EntityMutatorFactory'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContextProvider } from './EntityQueryContextProvider'; +import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { EntityTableDataCoordinator } from './internal/EntityTableDataCoordinator'; @@ -40,6 +41,14 @@ export class EntityCompanion< TPrivacyPolicy, TSelectedFields >; + private readonly knexEntityLoaderFactory: KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >; private readonly entityMutatorFactory: EntityMutatorFactory< TFields, TIDField, @@ -71,6 +80,14 @@ export class EntityCompanion< TPrivacyPolicy, TSelectedFields >(this, tableDataCoordinator.dataManager, metricsAdapter); + this.knexEntityLoaderFactory = new KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >(this, tableDataCoordinator.dataManager, metricsAdapter); this.entityMutatorFactory = new EntityMutatorFactory( entityCompanionProvider, tableDataCoordinator.entityConfiguration, @@ -98,6 +115,17 @@ export class EntityCompanion< return this.entityLoaderFactory; } + getKnexLoaderFactory(): KnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return this.knexEntityLoaderFactory; + } + getMutatorFactory(): EntityMutatorFactory< TFields, TIDField, diff --git a/packages/entity/src/EntityLoaderUtils.ts b/packages/entity/src/EntityLoaderUtils.ts index b3afddace..61ebb6255 100644 --- a/packages/entity/src/EntityLoaderUtils.ts +++ b/packages/entity/src/EntityLoaderUtils.ts @@ -1,4 +1,5 @@ import { Result, asyncResult, result } from '@expo/results'; +import invariant from 'invariant'; import nullthrows from 'nullthrows'; import { IEntityClass } from './Entity'; @@ -8,6 +9,7 @@ import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQue import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { pick } from './entityUtils'; +import { EntityInvalidFieldValueError } from './errors/EntityInvalidFieldValueError'; import { EntityDataManager } from './internal/EntityDataManager'; import { LoadPair } from './internal/EntityLoadInterfaces'; import { SingleFieldHolder, SingleFieldValueHolder } from './internal/SingleFieldHolder'; @@ -191,6 +193,27 @@ export class EntityLoaderUtils< ); } + /** + * Validate that field values are valid according to the field's validation function. + * + * @param fieldName - field name to validate + * @param fieldValues - field values to validate + * @throws EntityInvalidFieldValueError when a field value is invalid + */ + public validateFieldAndValues>( + fieldName: N, + fieldValues: readonly TFields[N][], + ): void { + const fieldDefinition = this.entityConfiguration.schema.get(fieldName); + invariant(fieldDefinition, `must have field definition for field = ${String(fieldName)}`); + for (const fieldValue of fieldValues) { + const isInputValid = fieldDefinition.validateInputValue(fieldValue); + if (!isInputValid) { + throw new EntityInvalidFieldValueError(this.entityClass, fieldName, fieldValue); + } + } + } + private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result[] { return fieldsObjects.map((fieldsObject) => { try { diff --git a/packages/entity/src/EntitySecondaryCacheLoader.ts b/packages/entity/src/EntitySecondaryCacheLoader.ts index a636b3721..24ddb1957 100644 --- a/packages/entity/src/EntitySecondaryCacheLoader.ts +++ b/packages/entity/src/EntitySecondaryCacheLoader.ts @@ -1,6 +1,7 @@ import { Result } from '@expo/results'; import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; @@ -68,6 +69,14 @@ export abstract class EntitySecondaryCacheLoader< TPrivacyPolicy, TSelectedFields >, + protected readonly knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, ) {} /** diff --git a/packages/entity/src/KnexEntityLoader.ts b/packages/entity/src/KnexEntityLoader.ts new file mode 100644 index 000000000..ab74756c3 --- /dev/null +++ b/packages/entity/src/KnexEntityLoader.ts @@ -0,0 +1,75 @@ +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; +import { IEntityClass } from './Entity'; +import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * 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, + TIDField extends keyof NonNullable>, + TViewerContext extends ViewerContext, + TViewerContext2 extends TViewerContext, + TEntity extends ReadonlyEntity, + 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 new EnforcingKnexEntityLoader(this.withAuthorizationResults()); + } + + /** + * 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 }); + } +} diff --git a/packages/entity/src/KnexEntityLoaderFactory.ts b/packages/entity/src/KnexEntityLoaderFactory.ts new file mode 100644 index 000000000..6c36d617b --- /dev/null +++ b/packages/entity/src/KnexEntityLoaderFactory.ts @@ -0,0 +1,83 @@ +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EntityCompanion } from './EntityCompanion'; +import { EntityLoaderUtils } from './EntityLoaderUtils'; +import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; +import { EntityDataManager } from './internal/EntityDataManager'; +import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; + +/** + * The primary entry point for loading entities via knex queries (non-data-loader methods). + */ +export class KnexEntityLoaderFactory< + 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 entityCompanion: EntityCompanion< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, + private readonly dataManager: EntityDataManager, + protected readonly metricsAdapter: IEntityMetricsAdapter, + ) {} + + /** + * Vend knex loader for loading an entity in a given query context. + * @param viewerContext - viewer context of loading user + * @param queryContext - query context in which to perform the load + */ + forLoad( + viewerContext: TViewerContext, + queryContext: EntityQueryContext, + privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext< + TFields, + TIDField, + TViewerContext, + TEntity, + TSelectedFields + >, + ): AuthorizationResultBasedKnexEntityLoader< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + const utils = new EntityLoaderUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + this.entityCompanion.entityCompanionDefinition.entityConfiguration, + this.entityCompanion.entityCompanionDefinition.entityClass, + this.entityCompanion.entityCompanionDefinition.entitySelectedFields, + this.entityCompanion.privacyPolicy, + this.dataManager, + this.metricsAdapter, + ); + + return new AuthorizationResultBasedKnexEntityLoader( + queryContext, + this.dataManager, + this.metricsAdapter, + utils, + ); + } +} diff --git a/packages/entity/src/ReadonlyEntity.ts b/packages/entity/src/ReadonlyEntity.ts index fe1da7b1e..8c7cfb5e6 100644 --- a/packages/entity/src/ReadonlyEntity.ts +++ b/packages/entity/src/ReadonlyEntity.ts @@ -2,14 +2,17 @@ import invariant from 'invariant'; import { AuthorizationResultBasedEntityAssociationLoader } from './AuthorizationResultBasedEntityAssociationLoader'; import { AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; import { EnforcingEntityAssociationLoader } from './EnforcingEntityAssociationLoader'; import { EnforcingEntityLoader } from './EnforcingEntityLoader'; +import { EnforcingKnexEntityLoader } from './EnforcingKnexEntityLoader'; import { IEntityClass } from './Entity'; import { EntityAssociationLoader } from './EntityAssociationLoader'; import { EntityLoader } from './EntityLoader'; import { EntityLoaderUtils } from './EntityLoaderUtils'; import { EntityPrivacyPolicy } from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; +import { KnexEntityLoader } from './KnexEntityLoader'; import { ViewerContext } from './ViewerContext'; /** @@ -272,4 +275,92 @@ export abstract class ReadonlyEntity< > { return new EntityLoader(viewerContext, queryContext, this).utils(); } + + /** + * 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>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + 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>, + TMViewerContext extends ViewerContext, + TMViewerContext2 extends TMViewerContext, + TMEntity extends ReadonlyEntity, + 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(); + } } diff --git a/packages/entity/src/ViewerScopedEntityCompanion.ts b/packages/entity/src/ViewerScopedEntityCompanion.ts index cb65b3bfa..04934074b 100644 --- a/packages/entity/src/ViewerScopedEntityCompanion.ts +++ b/packages/entity/src/ViewerScopedEntityCompanion.ts @@ -5,6 +5,7 @@ import { ReadonlyEntity } from './ReadonlyEntity'; import { ViewerContext } from './ViewerContext'; import { ViewerScopedEntityLoaderFactory } from './ViewerScopedEntityLoaderFactory'; import { ViewerScopedEntityMutatorFactory } from './ViewerScopedEntityMutatorFactory'; +import { ViewerScopedKnexEntityLoaderFactory } from './ViewerScopedKnexEntityLoaderFactory'; import { IEntityMetricsAdapter } from './metrics/IEntityMetricsAdapter'; /** @@ -54,6 +55,23 @@ export class ViewerScopedEntityCompanion< ); } + /** + * Vend a viewer-scoped knex entity loader. + */ + getKnexLoaderFactory(): ViewerScopedKnexEntityLoaderFactory< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + > { + return new ViewerScopedKnexEntityLoaderFactory( + this.entityCompanion.getKnexLoaderFactory(), + this.viewerContext, + ); + } + /** * Vend a viewer-scoped entity mutator factory. */ diff --git a/packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts b/packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts new file mode 100644 index 000000000..08447dc81 --- /dev/null +++ b/packages/entity/src/ViewerScopedKnexEntityLoaderFactory.ts @@ -0,0 +1,60 @@ +import { AuthorizationResultBasedKnexEntityLoader } from './AuthorizationResultBasedKnexEntityLoader'; +import { EntityPrivacyPolicy, EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import { KnexEntityLoaderFactory } from './KnexEntityLoaderFactory'; +import { ReadonlyEntity } from './ReadonlyEntity'; +import { ViewerContext } from './ViewerContext'; + +/** + * Provides a cleaner API for loading entities via knex by passing through the ViewerContext. + */ +export class ViewerScopedKnexEntityLoaderFactory< + 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 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, + ); + } +} diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts index 9b329ca17..c3297d6dc 100644 --- a/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts +++ b/packages/entity/src/__tests__/AuthorizationResultBasedEntityLoader-test.ts @@ -4,7 +4,6 @@ import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mock import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; -import { OrderByOrdering } from '../EntityDatabaseAdapter'; import { EntityLoaderUtils } from '../EntityLoaderUtils'; import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; import { ViewerContext } from '../ViewerContext'; @@ -322,311 +321,6 @@ describe(AuthorizationResultBasedEntityLoader, () => { ).toEqual([id3]); }); - it('loads entities with loadManyByFieldEqualityConjunction', async () => { - const privacyPolicy = new TestEntityPrivacyPolicy(); - const spiedPrivacyPolicy = spy(privacyPolicy); - const viewerContext = instance(mock(ViewerContext)); - const privacyPolicyEvaluationContext = - instance( - mock< - EntityPrivacyPolicyEvaluationContext< - TestFields, - 'customIdField', - ViewerContext, - TestEntity - > - >(), - ); - const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); - - const id1 = uuidv4(); - const id2 = uuidv4(); - const id3 = uuidv4(); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: id1, - stringField: 'huh', - intField: 4, - testIndexedField: '4', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id2, - stringField: 'huh', - intField: 4, - testIndexedField: '5', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id3, - stringField: 'huh2', - intField: 4, - testIndexedField: '6', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), - ), - ); - const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); - const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); - const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter); - const dataManager = new EntityDataManager( - databaseAdapter, - entityCache, - new StubQueryContextProvider(), - instance(mock()), - TestEntity.name, - ); - const utils = new EntityLoaderUtils( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - testEntityConfiguration, - TestEntity, - /* entitySelectedFields */ undefined, - privacyPolicy, - dataManager, - metricsAdapter, - ); - const entityLoader = new AuthorizationResultBasedEntityLoader( - queryContext, - testEntityConfiguration, - TestEntity, - dataManager, - metricsAdapter, - utils, - ); - const entityResults = await enforceResultsAsync( - entityLoader.loadManyByFieldEqualityConjunctionAsync([ - { - fieldName: 'stringField', - fieldValue: 'huh', - }, - { - fieldName: 'intField', - fieldValues: [4], - }, - ]), - ); - expect(entityResults).toHaveLength(2); - verify( - spiedPrivacyPolicy.authorizeReadAsync( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - anyOfClass(TestEntity), - anything(), - ), - ).twice(); - - await expect( - entityLoader.loadManyByFieldEqualityConjunctionAsync([ - { fieldName: 'customIdField', fieldValue: 'not-a-uuid' }, - ]), - ).rejects.toThrow('Entity field not valid: TestEntity (customIdField = not-a-uuid)'); - }); - - it('loads entities with loadFirstByFieldEqualityConjunction', async () => { - const privacyPolicy = new TestEntityPrivacyPolicy(); - const spiedPrivacyPolicy = spy(privacyPolicy); - const viewerContext = instance(mock(ViewerContext)); - const privacyPolicyEvaluationContext = - instance( - mock< - EntityPrivacyPolicyEvaluationContext< - TestFields, - 'customIdField', - ViewerContext, - TestEntity - > - >(), - ); - const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); - - const id1 = uuidv4(); - const id2 = uuidv4(); - const id3 = uuidv4(); - const databaseAdapter = new StubDatabaseAdapter( - testEntityConfiguration, - StubDatabaseAdapter.convertFieldObjectsToDataStore( - testEntityConfiguration, - new Map([ - [ - testEntityConfiguration.tableName, - [ - { - customIdField: id1, - stringField: 'huh', - intField: 4, - testIndexedField: '4', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id2, - stringField: 'huh', - intField: 4, - testIndexedField: '5', - dateField: new Date(), - nullableField: null, - }, - { - customIdField: id3, - stringField: 'huh2', - intField: 4, - testIndexedField: '6', - dateField: new Date(), - nullableField: null, - }, - ], - ], - ]), - ), - ); - const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); - const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); - const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter); - const dataManager = new EntityDataManager( - databaseAdapter, - entityCache, - new StubQueryContextProvider(), - instance(mock()), - TestEntity.name, - ); - const utils = new EntityLoaderUtils( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - testEntityConfiguration, - TestEntity, - /* entitySelectedFields */ undefined, - privacyPolicy, - dataManager, - metricsAdapter, - ); - const entityLoader = new AuthorizationResultBasedEntityLoader( - queryContext, - testEntityConfiguration, - TestEntity, - dataManager, - metricsAdapter, - utils, - ); - const result = await entityLoader.loadFirstByFieldEqualityConjunctionAsync( - [ - { - fieldName: 'stringField', - fieldValue: 'huh', - }, - { - fieldName: 'intField', - fieldValue: 4, - }, - ], - { orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }] }, - ); - expect(result).not.toBeNull(); - expect(result!.ok).toBe(true); - expect(result!.enforceValue().getField('testIndexedField')).toEqual('5'); - verify( - spiedPrivacyPolicy.authorizeReadAsync( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - anyOfClass(TestEntity), - anything(), - ), - ).once(); - }); - - it('loads entities with loadManyByRawWhereClauseAsync', async () => { - const privacyPolicy = new TestEntityPrivacyPolicy(); - const spiedPrivacyPolicy = spy(privacyPolicy); - const viewerContext = instance(mock(ViewerContext)); - const privacyPolicyEvaluationContext = - instance( - mock< - EntityPrivacyPolicyEvaluationContext< - TestFields, - 'customIdField', - ViewerContext, - TestEntity - > - >(), - ); - const metricsAdapter = instance(mock()); - const queryContext = new StubQueryContextProvider().getQueryContext(); - - const dataManagerMock = mock>(EntityDataManager); - when( - dataManagerMock.loadManyByRawWhereClauseAsync( - queryContext, - anything(), - anything(), - anything(), - ), - ).thenResolve([ - { - customIdField: 'id', - stringField: 'huh', - intField: 4, - testIndexedField: '4', - dateField: new Date(), - nullableField: null, - }, - ]); - const dataManager = instance(dataManagerMock); - const utils = new EntityLoaderUtils( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - testEntityConfiguration, - TestEntity, - /* entitySelectedFields */ undefined, - privacyPolicy, - dataManager, - metricsAdapter, - ); - const entityLoader = new AuthorizationResultBasedEntityLoader( - queryContext, - testEntityConfiguration, - TestEntity, - dataManager, - metricsAdapter, - utils, - ); - const result = await entityLoader.loadManyByRawWhereClauseAsync('id = ?', [1], { - orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }], - }); - expect(result).toHaveLength(1); - expect(result[0]).not.toBeNull(); - expect(result[0]!.ok).toBe(true); - expect(result[0]!.enforceValue().getField('testIndexedField')).toEqual('4'); - verify( - spiedPrivacyPolicy.authorizeReadAsync( - viewerContext, - queryContext, - privacyPolicyEvaluationContext, - anyOfClass(TestEntity), - anything(), - ), - ).once(); - }); - it('authorizes loaded entities', async () => { const privacyPolicy = new TestEntityPrivacyPolicy(); const spiedPrivacyPolicy = spy(privacyPolicy); diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts new file mode 100644 index 000000000..98c15eba6 --- /dev/null +++ b/packages/entity/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from '@jest/globals'; +import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mockito'; +import { v4 as uuidv4 } from 'uuid'; + +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { OrderByOrdering } from '../EntityDatabaseAdapter'; +import { EntityLoaderUtils } from '../EntityLoaderUtils'; +import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; +import { ViewerContext } from '../ViewerContext'; +import { enforceResultsAsync } from '../entityUtils'; +import { EntityDataManager } from '../internal/EntityDataManager'; +import { ReadThroughEntityCache } from '../internal/ReadThroughEntityCache'; +import { IEntityMetricsAdapter } from '../metrics/IEntityMetricsAdapter'; +import { NoCacheStubCacheAdapterProvider } from '../utils/__testfixtures__/StubCacheAdapter'; +import { StubDatabaseAdapter } from '../utils/__testfixtures__/StubDatabaseAdapter'; +import { StubQueryContextProvider } from '../utils/__testfixtures__/StubQueryContextProvider'; +import { + TestEntity, + TestEntityPrivacyPolicy, + testEntityConfiguration, + TestFields, +} from '../utils/__testfixtures__/TestEntity'; + +describe(AuthorizationResultBasedKnexEntityLoader, () => { + it('loads entities with loadManyByFieldEqualityConjunction', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const spiedPrivacyPolicy = spy(privacyPolicy); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const id3 = uuidv4(); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: id1, + stringField: 'huh', + intField: 4, + testIndexedField: '4', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'huh', + intField: 4, + testIndexedField: '5', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id3, + stringField: 'huh2', + intField: 4, + testIndexedField: '6', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); + const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); + const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter); + const dataManager = new EntityDataManager( + databaseAdapter, + entityCache, + new StubQueryContextProvider(), + instance(mock()), + TestEntity.name, + ); + const utils = new EntityLoaderUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + dataManager, + metricsAdapter, + ); + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + dataManager, + metricsAdapter, + utils, + ); + const entityResults = await enforceResultsAsync( + knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ + { + fieldName: 'stringField', + fieldValue: 'huh', + }, + { + fieldName: 'intField', + fieldValues: [4], + }, + ]), + ); + expect(entityResults).toHaveLength(2); + verify( + spiedPrivacyPolicy.authorizeReadAsync( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + anyOfClass(TestEntity), + anything(), + ), + ).twice(); + + await expect( + knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ + { fieldName: 'customIdField', fieldValue: 'not-a-uuid' }, + ]), + ).rejects.toThrow('Entity field not valid: TestEntity (customIdField = not-a-uuid)'); + }); + + it('loads entities with loadFirstByFieldEqualityConjunction', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const spiedPrivacyPolicy = spy(privacyPolicy); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const id1 = uuidv4(); + const id2 = uuidv4(); + const id3 = uuidv4(); + const databaseAdapter = new StubDatabaseAdapter( + testEntityConfiguration, + StubDatabaseAdapter.convertFieldObjectsToDataStore( + testEntityConfiguration, + new Map([ + [ + testEntityConfiguration.tableName, + [ + { + customIdField: id1, + stringField: 'huh', + intField: 4, + testIndexedField: '4', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id2, + stringField: 'huh', + intField: 4, + testIndexedField: '5', + dateField: new Date(), + nullableField: null, + }, + { + customIdField: id3, + stringField: 'huh2', + intField: 4, + testIndexedField: '6', + dateField: new Date(), + nullableField: null, + }, + ], + ], + ]), + ), + ); + const cacheAdapterProvider = new NoCacheStubCacheAdapterProvider(); + const cacheAdapter = cacheAdapterProvider.getCacheAdapter(testEntityConfiguration); + const entityCache = new ReadThroughEntityCache(testEntityConfiguration, cacheAdapter); + const dataManager = new EntityDataManager( + databaseAdapter, + entityCache, + new StubQueryContextProvider(), + instance(mock()), + TestEntity.name, + ); + const utils = new EntityLoaderUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + dataManager, + metricsAdapter, + ); + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + dataManager, + metricsAdapter, + utils, + ); + const result = await knexEntityLoader.loadFirstByFieldEqualityConjunctionAsync( + [ + { + fieldName: 'stringField', + fieldValue: 'huh', + }, + { + fieldName: 'intField', + fieldValue: 4, + }, + ], + { orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }] }, + ); + expect(result).not.toBeNull(); + expect(result!.ok).toBe(true); + + const resultEntity = result?.enforceValue(); + expect(resultEntity).toBeInstanceOf(TestEntity); + expect(resultEntity!.getField('testIndexedField')).toEqual('5'); + + verify( + spiedPrivacyPolicy.authorizeReadAsync( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + anyOfClass(TestEntity), + anything(), + ), + ).once(); + }); + + it('loads entities with loadManyByRawWhereClauseAsync', async () => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const spiedPrivacyPolicy = spy(privacyPolicy); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = new StubQueryContextProvider().getQueryContext(); + + const dataManagerMock = mock>(EntityDataManager); + when( + dataManagerMock.loadManyByRawWhereClauseAsync( + queryContext, + anything(), + anything(), + anything(), + ), + ).thenResolve([ + { + customIdField: 'id', + stringField: 'huh', + intField: 4, + testIndexedField: '4', + dateField: new Date(), + nullableField: null, + }, + ]); + const dataManager = instance(dataManagerMock); + + const utils = new EntityLoaderUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + dataManager, + metricsAdapter, + ); + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + dataManager, + metricsAdapter, + utils, + ); + + const result = await knexEntityLoader.loadManyByRawWhereClauseAsync('id = ?', [1], { + orderBy: [{ fieldName: 'testIndexedField', order: OrderByOrdering.DESCENDING }], + }); + expect(result).toHaveLength(1); + expect(result[0]).not.toBeNull(); + expect(result[0]!.ok).toBe(true); + expect(result[0]!.enforceValue().getField('testIndexedField')).toEqual('4'); + + verify( + spiedPrivacyPolicy.authorizeReadAsync( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + anyOfClass(TestEntity), + anything(), + ), + ).once(); + }); +}); diff --git a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts index 0e0c83d9e..03411db55 100644 --- a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts +++ b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts @@ -439,139 +439,6 @@ describe(EnforcingEntityLoader, () => { }); }); - describe('loadFirstByFieldEqualityConjunction', () => { - it('throws when result is unsuccessful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const rejection = new Error(); - when( - nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( - anything(), - anything(), - ), - ).thenResolve(result(rejection)); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), - ).rejects.toThrow(rejection); - }); - - it('returns value when result is successful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const resolved = {}; - when( - nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( - anything(), - anything(), - ), - ).thenResolve(result(resolved)); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), - ).resolves.toEqual(resolved); - }); - - it('returns null when the query is successful but no rows match', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - when( - nonEnforcingEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( - anything(), - anything(), - ), - ).thenResolve(null); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), - ).resolves.toBeNull(); - }); - }); - - describe('loadManyByFieldEqualityConjunction', () => { - it('throws when result is unsuccessful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const rejection = new Error(); - when( - nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( - anything(), - anything(), - ), - ).thenResolve([result(rejection)]); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), - ).rejects.toThrow(rejection); - }); - - it('returns value when result is successful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const resolved = {}; - when( - nonEnforcingEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( - anything(), - anything(), - ), - ).thenResolve([result(resolved)]); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), - ).resolves.toEqual([resolved]); - }); - }); - - describe('loadManyByRawWhereClause', () => { - it('throws when result is unsuccessful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const rejection = new Error(); - when( - nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync( - anything(), - anything(), - anything(), - ), - ).thenResolve([result(rejection)]); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), - ).rejects.toThrow(rejection); - }); - - it('returns value when result is successful', async () => { - const nonEnforcingEntityLoaderMock = mock< - AuthorizationResultBasedEntityLoader - >(AuthorizationResultBasedEntityLoader); - const resolved = {}; - when( - nonEnforcingEntityLoaderMock.loadManyByRawWhereClauseAsync( - anything(), - anything(), - anything(), - ), - ).thenResolve([result(resolved)]); - const nonEnforcingEntityLoader = instance(nonEnforcingEntityLoaderMock); - const enforcingEntityLoader = new EnforcingEntityLoader(nonEnforcingEntityLoader); - await expect( - enforcingEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), - ).resolves.toEqual([resolved]); - }); - }); - it('has the same method names as EntityLoader', () => { const enforcingLoaderProperties = Object.getOwnPropertyNames(EnforcingEntityLoader.prototype); const nonEnforcingLoaderProperties = Object.getOwnPropertyNames( @@ -580,7 +447,6 @@ describe(EnforcingEntityLoader, () => { // ensure known differences still exist for sanity check const knownLoaderOnlyDifferences = [ - 'validateFieldAndValues', 'validateFieldAndValuesAndConvertToHolders', 'validateCompositeFieldAndValuesAndConvertToHolders', 'constructAndAuthorizeEntitiesFromCompositeFieldValueHolderMapAsync', diff --git a/packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts new file mode 100644 index 000000000..fa9b745b6 --- /dev/null +++ b/packages/entity/src/__tests__/EnforcingKnexEntityLoader-test.ts @@ -0,0 +1,154 @@ +import { result } from '@expo/results'; +import { describe, expect, it } from '@jest/globals'; +import { anything, instance, mock, when } from 'ts-mockito'; + +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; +import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; + +describe(EnforcingKnexEntityLoader, () => { + describe('loadFirstByFieldEqualityConjunction', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), + ).thenResolve(result(rejection)); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), + ).thenResolve(result(resolved)); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + ).resolves.toEqual(resolved); + }); + + it('returns null when the query is successful but no rows match', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + when( + nonEnforcingKnexEntityLoaderMock.loadFirstByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), + ).thenResolve(null); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()), + ).resolves.toBeNull(); + }); + }); + + describe('loadManyByFieldEqualityConjunction', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), + ).thenResolve([result(rejection)]); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + ), + ).thenResolve([result(resolved)]); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadManyByFieldEqualityConjunctionAsync(anything(), anything()), + ).resolves.toEqual([resolved]); + }); + }); + + describe('loadManyByRawWhereClause', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([result(rejection)]); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock< + AuthorizationResultBasedKnexEntityLoader + >(AuthorizationResultBasedKnexEntityLoader); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadManyByRawWhereClauseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([result(resolved)]); + const nonEnforcingKnexEntityLoader = instance(nonEnforcingKnexEntityLoaderMock); + const enforcingKnexEntityLoader = new EnforcingKnexEntityLoader(nonEnforcingKnexEntityLoader); + await expect( + enforcingKnexEntityLoader.loadManyByRawWhereClauseAsync(anything(), anything(), anything()), + ).resolves.toEqual([resolved]); + }); + }); + + it('has the same method names as AuthorizationResultBasedKnexEntityLoader', () => { + const enforcingKnexLoaderProperties = Object.getOwnPropertyNames( + EnforcingKnexEntityLoader.prototype, + ); + const nonEnforcingKnexLoaderProperties = Object.getOwnPropertyNames( + AuthorizationResultBasedKnexEntityLoader.prototype, + ); + + // The knex loaders don't have the internal validation methods that regular loaders have, + // so we just check that all methods match without any exclusions + expect(enforcingKnexLoaderProperties).toEqual(nonEnforcingKnexLoaderProperties); + }); +}); diff --git a/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts b/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts index d868ee9a0..abbc689a5 100644 --- a/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts +++ b/packages/entity/src/__tests__/EntitySecondaryCacheLoader-test.ts @@ -47,6 +47,7 @@ describe(EntitySecondaryCacheLoader, () => { const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( secondaryEntityCache, SimpleTestEntity.loaderWithAuthorizationResults(vc1), + SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1), ); await secondaryCacheLoader.loadManyAsync([loadParams]); @@ -70,8 +71,13 @@ describe(EntitySecondaryCacheLoader, () => { const secondaryEntityCache = instance(secondaryEntityCacheMock); const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1); + const knexLoader = SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1); const spiedPrivacyPolicy = spy(loader.utils['privacyPolicy']); - const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader); + const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( + secondaryEntityCache, + loader, + knexLoader, + ); const result = await secondaryCacheLoader.loadManyAsync([loadParams]); expect(result.get(loadParams)?.enforceValue().getID()).toEqual(createdEntity.getID()); @@ -99,7 +105,12 @@ describe(EntitySecondaryCacheLoader, () => { mock>(); const secondaryEntityCache = instance(secondaryEntityCacheMock); const loader = SimpleTestEntity.loaderWithAuthorizationResults(vc1); - const secondaryCacheLoader = new TestSecondaryRedisCacheLoader(secondaryEntityCache, loader); + const knexLoader = SimpleTestEntity.knexLoaderWithAuthorizationResults(vc1); + const secondaryCacheLoader = new TestSecondaryRedisCacheLoader( + secondaryEntityCache, + loader, + knexLoader, + ); await secondaryCacheLoader.invalidateManyAsync([loadParams]); verify(secondaryEntityCacheMock.invalidateManyAsync(deepEqual([loadParams]))).once(); diff --git a/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts b/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts index b24da3d54..72c97a2bc 100644 --- a/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts +++ b/packages/entity/src/__tests__/GenericSecondaryEntityCache-test.ts @@ -109,7 +109,7 @@ class TestSecondaryCacheLoader extends EntitySecondaryCacheLoader< return await mapMapAsync(emptyMap, async (_value, loadParams) => { return ( ( - await this.entityLoader.loadManyByFieldEqualityConjunctionAsync([ + await this.knexEntityLoader.loadManyByFieldEqualityConjunctionAsync([ { fieldName: 'intField', fieldValue: loadParams.intValue }, ]) )[0] @@ -134,6 +134,7 @@ describe(GenericSecondaryEntityCache, () => { (params) => `intValue.${params.intValue}`, ), TestEntity.loaderWithAuthorizationResults(viewerContext), + TestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { intValue: 1 }; @@ -170,6 +171,7 @@ describe(GenericSecondaryEntityCache, () => { (params) => `intValue.${params.intValue}`, ), TestEntity.loaderWithAuthorizationResults(viewerContext), + TestEntity.knexLoaderWithAuthorizationResults(viewerContext), ); const loadParams = { intValue: 2 }; diff --git a/packages/entity/src/__tests__/ReadonlyEntity-test.ts b/packages/entity/src/__tests__/ReadonlyEntity-test.ts index e85c2865c..97942f691 100644 --- a/packages/entity/src/__tests__/ReadonlyEntity-test.ts +++ b/packages/entity/src/__tests__/ReadonlyEntity-test.ts @@ -3,8 +3,10 @@ import { instance, mock } from 'ts-mockito'; import { AuthorizationResultBasedEntityAssociationLoader } from '../AuthorizationResultBasedEntityAssociationLoader'; import { AuthorizationResultBasedEntityLoader } from '../AuthorizationResultBasedEntityLoader'; +import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader'; import { EnforcingEntityAssociationLoader } from '../EnforcingEntityAssociationLoader'; import { EnforcingEntityLoader } from '../EnforcingEntityLoader'; +import { EnforcingKnexEntityLoader } from '../EnforcingKnexEntityLoader'; import { EntityLoaderUtils } from '../EntityLoaderUtils'; import { ReadonlyEntity } from '../ReadonlyEntity'; import { ViewerContext } from '../ViewerContext'; @@ -214,6 +216,24 @@ describe(ReadonlyEntity, () => { }); }); + describe('knexLoader', () => { + it('creates a new EnforcingKnexEntityLoader', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(SimpleTestEntity.knexLoader(viewerContext)).toBeInstanceOf(EnforcingKnexEntityLoader); + }); + }); + + describe('knexLoaderWithAuthorizationResults', () => { + it('creates a new AuthorizationResultBasedEntityLoader', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new ViewerContext(companionProvider); + expect(SimpleTestEntity.knexLoaderWithAuthorizationResults(viewerContext)).toBeInstanceOf( + AuthorizationResultBasedKnexEntityLoader, + ); + }); + }); + describe('loaderUtils', () => { it('creates a new EntityLoaderUtils', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); diff --git a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts index 832442f7e..bd7226f06 100644 --- a/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts +++ b/packages/entity/src/__tests__/cases/TwoEntitySameTableDisjointRows-test.ts @@ -51,7 +51,7 @@ describe('Two entities backed by the same table', () => { 'OneTestEntity must be instantiated with one data', ); - const fieldEqualityConjunctionResults = await OneTestEntity.loaderWithAuthorizationResults( + const fieldEqualityConjunctionResults = await OneTestEntity.knexLoaderWithAuthorizationResults( viewerContext, ).loadManyByFieldEqualityConjunctionAsync([ { diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index a3c45af1d..35f695888 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -7,6 +7,7 @@ export * from './AuthorizationResultBasedEntityAssociationLoader'; export * from './AuthorizationResultBasedEntityLoader'; export * from './AuthorizationResultBasedEntityMutator'; +export * from './AuthorizationResultBasedKnexEntityLoader'; export * from './ComposedEntityCacheAdapter'; export * from './ComposedSecondaryEntityCache'; export * from './EnforcingEntityAssociationLoader'; @@ -14,6 +15,7 @@ export * from './EnforcingEntityCreator'; export * from './EnforcingEntityDeleter'; export * from './EnforcingEntityLoader'; export * from './EnforcingEntityUpdater'; +export * from './EnforcingKnexEntityLoader'; export * from './Entity'; export * from './EntityAssociationLoader'; export * from './EntityCompanion'; @@ -43,12 +45,15 @@ export * from './IEntityCacheAdapter'; export * from './IEntityCacheAdapterProvider'; export * from './IEntityDatabaseAdapterProvider'; export * from './IEntityGenericCacher'; +export * from './KnexEntityLoader'; +export * from './KnexEntityLoaderFactory'; export * from './ReadonlyEntity'; export * from './ViewerContext'; export * from './ViewerScopedEntityCompanion'; export * from './ViewerScopedEntityCompanionProvider'; export * from './ViewerScopedEntityLoaderFactory'; export * from './ViewerScopedEntityMutatorFactory'; +export * from './ViewerScopedKnexEntityLoaderFactory'; export * from './errors/EntityCacheAdapterError'; export * from './errors/EntityDatabaseAdapterError'; export * from './errors/EntityError'; diff --git a/packages/entity/src/utils/EntityPrivacyUtils.ts b/packages/entity/src/utils/EntityPrivacyUtils.ts index f66da3047..28fb7cff3 100644 --- a/packages/entity/src/utils/EntityPrivacyUtils.ts +++ b/packages/entity/src/utils/EntityPrivacyUtils.ts @@ -354,13 +354,15 @@ async function canViewerDeleteInternalAsync< entityCompanionProvider.getCompanionForEntity(inboundEdge).entityCompanionDefinition .entityConfiguration; - const loader = viewerContext - .getViewerScopedEntityCompanionForClass(inboundEdge) - .getLoaderFactory() - .forLoad(queryContext, { - previousValue: null, - cascadingDeleteCause: newCascadingDeleteCause, - }); + const loaderFactory = viewerContext.getViewerScopedEntityCompanionForClass(inboundEdge); + const loader = loaderFactory.getLoaderFactory().forLoad(queryContext, { + previousValue: null, + cascadingDeleteCause: newCascadingDeleteCause, + }); + const knexLoader = loaderFactory.getKnexLoaderFactory().forLoad(queryContext, { + previousValue: null, + cascadingDeleteCause: newCascadingDeleteCause, + }); for (const [fieldName, fieldDefinition] of configurationForInboundEdge.schema) { const association = fieldDefinition.association; @@ -385,7 +387,7 @@ async function canViewerDeleteInternalAsync< EntityEdgeDeletionAuthorizationInferenceBehavior.ONE_IMPLIES_ALL ) { const singleEntityResultToTestForInboundEdge = - await loader.loadFirstByFieldEqualityConjunctionAsync( + await knexLoader.loadFirstByFieldEqualityConjunctionAsync( [ { fieldName,