From 9d9d299a5cf845792b300aeeeafb89232d940cbb Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Fri, 25 Sep 2026 11:13:45 -0700 Subject: [PATCH] feat: add ID and field knex database loaders --- ...uthorizationResultBasedKnexEntityLoader.ts | 177 +++++++++ .../src/EnforcingKnexEntityLoader.ts | 137 +++++++ .../src/KnexEntityLoaderFactory.ts | 2 + .../PostgresEntityIntegration-test.ts | 199 ++++++++++ ...izationResultBasedKnexEntityLoader-test.ts | 355 +++++++++++++++++- .../EnforcingKnexEntityLoader-test.ts | 290 +++++++++++++- 6 files changed, 1157 insertions(+), 3 deletions(-) diff --git a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts index d9d2b3bda9..09e4cf5308 100644 --- a/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/AuthorizationResultBasedKnexEntityLoader.ts @@ -1,12 +1,17 @@ import type { + EntityConfiguration, EntityConstructionUtils, EntityPrivacyPolicy, EntityQueryContext, + IEntityClass, IEntityMetricsAdapter, ReadonlyEntity, ViewerContext, } from '@expo/entity'; +import { EntityNotFoundError, mapMap } from '@expo/entity'; import type { Result } from '@expo/results'; +import { result } from '@expo/results'; +import assert from 'assert'; import type { FieldEqualityCondition, @@ -64,6 +69,48 @@ export type EntityLoaderOrderByClause< | EntityLoaderFieldNameOrderByClause | EntityLoaderFieldFragmentOrderByClause; +/** + * Row locking modifier that locks the selected rows for update using `SELECT ... FOR UPDATE`. + * The lock is held until the end of the transaction, so the query context must be transactional. + */ +export interface EntityLoaderForUpdateRowLockingModifier { + forUpdate: true; + forShare?: never; +} + +/** + * Row locking modifier that locks the selected rows in share mode using `SELECT ... FOR SHARE`. + * The lock is held until the end of the transaction, so the query context must be transactional. + */ +export interface EntityLoaderForShareRowLockingModifier { + forShare: true; + forUpdate?: never; +} + +/** + * Row locking modifiers for load methods that never report a missing row as an error. Exactly one of + * `forUpdate` or `forShare` is required. `skipLocked` may be set to skip rows already locked by another + * transaction (using `SKIP LOCKED`) instead of waiting for them; skipped rows are reported as missing. + */ +export type EntityLoaderRowLockingModifiers = ( + | EntityLoaderForUpdateRowLockingModifier + | EntityLoaderForShareRowLockingModifier +) & { + skipLocked?: boolean; +}; + +/** + * Row locking modifiers for load methods that throw when a row is missing. Exactly one of `forUpdate` + * or `forShare` is required. `skipLocked` is not permitted since a row skipped because another transaction + * holds a lock on it would be indistinguishable from a row that does not exist. + */ +export type EntityLoaderRowLockingModifiersWithoutSkipLocked = ( + | EntityLoaderForUpdateRowLockingModifier + | EntityLoaderForShareRowLockingModifier +) & { + skipLocked?: never; +}; + /** * SQL modifiers that only affect the selection but not the projection. */ @@ -375,6 +422,15 @@ export class AuthorizationResultBasedKnexEntityLoader< > { constructor( private readonly queryContext: EntityQueryContext, + private readonly entityConfiguration: EntityConfiguration, + private readonly entityClass: IEntityClass< + TFields, + TIDField, + TViewerContext, + TEntity, + TPrivacyPolicy, + TSelectedFields + >, private readonly knexDataManager: EntityKnexDataManager, protected readonly metricsAdapter: IEntityMetricsAdapter, private readonly constructionUtils: EntityConstructionUtils< @@ -387,6 +443,127 @@ export class AuthorizationResultBasedKnexEntityLoader< >, ) {} + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns entity result for matching ID, where result error can be UnauthorizedError or EntityNotFoundError. + */ + async loadByIDFromDatabaseAsync( + id: TFields[TIDField], + modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked, + ): Promise> { + const entityResults = await this.loadManyByIDsFromDatabaseAsync([id], modifiers); + const entityResult = entityResults.get(id); + assert(entityResult !== undefined, `${id} should be guaranteed to be present in returned map`); + return entityResult; + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns entity result for matching ID, or null if no entity exists for ID, where result error can be UnauthorizedError. + */ + async loadByIDNullableFromDatabaseAsync( + id: TFields[TIDField], + modifiers: EntityLoaderRowLockingModifiers, + ): Promise | null> { + const entityResults = await this.loadManyByIDsNullableFromDatabaseAsync([id], modifiers); + return entityResults.get(id) ?? null; + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns map from ID to corresponding entity result, where result error can be UnauthorizedError or EntityNotFoundError. + */ + async loadManyByIDsFromDatabaseAsync( + ids: readonly TFields[TIDField][], + modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked, + ): Promise>> { + const entityResults = await this.loadManyByIDsNullableFromDatabaseAsync(ids, modifiers); + return mapMap( + entityResults, + (entityResult, id) => + entityResult ?? + result( + new EntityNotFoundError({ + entityClass: this.entityClass, + fieldName: this.entityConfiguration.idField, + fieldValue: id, + }), + ), + ); + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns map from ID to nullable corresponding entity result, where result error can be UnauthorizedError. + */ + async loadManyByIDsNullableFromDatabaseAsync( + ids: readonly TFields[TIDField][], + modifiers: EntityLoaderRowLockingModifiers, + ): Promise | null>> { + const idField = this.entityConfiguration.idField; + this.constructionUtils.validateFieldAndValues(idField, ids); + + const fieldObjects = + ids.length > 0 + ? await this.knexDataManager.loadManyByFieldEqualityConjunctionAsync( + this.queryContext, + [{ fieldName: idField, fieldValues: ids }], + modifiers, + ) + : []; + + const idsToFieldObjects = new Map[]>( + ids.map((id) => [id, []]), + ); + for (const fieldObject of fieldObjects) { + const id = fieldObject[idField]; + idsToFieldObjects.set(id, [...(idsToFieldObjects.get(id) ?? []), fieldObject]); + } + + const idsToEntityResults = + await this.constructionUtils.constructAndAuthorizeEntitiesAsync(idsToFieldObjects); + return mapMap(idsToEntityResults, (entityResults) => entityResults[0] ?? null); + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns entity result where uniqueFieldName equals fieldValue, or null if no entity matches the condition, where result error can be UnauthorizedError. + * @throws when multiple entities match the condition + */ + async loadByFieldEqualingFromDatabaseAsync>( + uniqueFieldName: N, + fieldValue: NonNullable, + modifiers: EntityLoaderRowLockingModifiers, + ): Promise | null> { + const entityResults = await this.loadManyByFieldEqualingFromDatabaseAsync( + uniqueFieldName, + fieldValue, + modifiers, + ); + assert( + entityResults.length <= 1, + `loadByFieldEqualingFromDatabase: Multiple entities of type ${this.entityClass.name} found for ${String( + uniqueFieldName, + )}=${fieldValue}`, + ); + return entityResults[0] ?? null; + } + + /** + * Authorization-result-based version of the EnforcingKnexEntityLoader method by the same name. + * @returns array of entity results where fieldName equals fieldValue, where result error can be UnauthorizedError + */ + async loadManyByFieldEqualingFromDatabaseAsync>( + fieldName: N, + fieldValue: NonNullable, + modifiers: EntityLoaderRowLockingModifiers, + ): Promise[]> { + return await this.loadManyByFieldEqualityConjunctionAsync( + [{ fieldName, fieldValue }], + modifiers, + ); + } + /** * 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 diff --git a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts index 652f4c0fb2..143b0730b4 100644 --- a/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts +++ b/packages/entity-database-adapter-knex/src/EnforcingKnexEntityLoader.ts @@ -6,11 +6,14 @@ import type { ReadonlyEntity, ViewerContext, } from '@expo/entity'; +import { mapMap } from '@expo/entity'; import type { AuthorizationResultBasedKnexEntityLoader, EntityLoaderLoadPageArgs, EntityLoaderQuerySelectionModifiers, + EntityLoaderRowLockingModifiers, + EntityLoaderRowLockingModifiersWithoutSkipLocked, } from './AuthorizationResultBasedKnexEntityLoader.ts'; import type { FieldEqualityCondition } from './BasePostgresEntityDatabaseAdapter.ts'; import { BaseSQLQueryBuilder } from './BaseSQLQueryBuilder.ts'; @@ -57,6 +60,140 @@ export class EnforcingKnexEntityLoader< >, ) {} + /** + * Load an entity by ID directly from the database, bypassing the dataloader and cache. + * + * Unlike {@link "@expo/entity"!EnforcingEntityLoader.loadByIDAsync | EnforcingEntityLoader.loadByIDAsync}, this + * issues one database query per call and does not read from or write to the entity cache. It exists + * for cases that need row locking modifiers, which are required to make this intent explicit. + * `skipLocked` is not permitted here since a skipped row would be reported as not found. + * Use {@link loadByIDNullableFromDatabaseAsync} with `skipLocked`. + * + * @param id - ID of the entity + * @param modifiers - row locking modifiers for the query + * @returns entity matching ID + * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity + * @throws EntityNotFoundError when no entity exists for ID + */ + async loadByIDFromDatabaseAsync( + id: TFields[TIDField], + modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked, + ): Promise { + const entityResult = await this.knexEntityLoader.loadByIDFromDatabaseAsync(id, modifiers); + return entityResult.enforceValue(); + } + + /** + * Load an entity by ID directly from the database, or return null if non-existent. + * See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader. + * + * @param id - ID of the entity + * @param modifiers - row locking modifiers for the query + * @returns entity for matching ID, or null if no entity exists for ID + * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity + */ + async loadByIDNullableFromDatabaseAsync( + id: TFields[TIDField], + modifiers: EntityLoaderRowLockingModifiers, + ): Promise { + const entityResult = await this.knexEntityLoader.loadByIDNullableFromDatabaseAsync( + id, + modifiers, + ); + return entityResult ? entityResult.enforceValue() : null; + } + + /** + * Load many entities for a list of IDs directly from the database. + * See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader. + * + * `skipLocked` is not permitted here since a skipped row would be reported as not found. + * Use {@link loadManyByIDsNullableFromDatabaseAsync} with `skipLocked`. + * + * @param ids - IDs of the entities to load + * @param modifiers - row locking modifiers for the query + * @returns map from ID to corresponding entity + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + * @throws EntityNotFoundError when no entity exists for one or more of the IDs + */ + async loadManyByIDsFromDatabaseAsync( + ids: readonly TFields[TIDField][], + modifiers: EntityLoaderRowLockingModifiersWithoutSkipLocked, + ): Promise> { + const entityResults = await this.knexEntityLoader.loadManyByIDsFromDatabaseAsync( + ids, + modifiers, + ); + return mapMap(entityResults, (entityResult) => entityResult.enforceValue()); + } + + /** + * Load many entities for a list of IDs directly from the database, returning null for any IDs that are non-existent. + * See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader. + * + * @param ids - IDs of the entities to load + * @param modifiers - row locking modifiers for the query + * @returns map from ID to nullable corresponding entity + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + */ + async loadManyByIDsNullableFromDatabaseAsync( + ids: readonly TFields[TIDField][], + modifiers: EntityLoaderRowLockingModifiers, + ): Promise> { + const entityResults = await this.knexEntityLoader.loadManyByIDsNullableFromDatabaseAsync( + ids, + modifiers, + ); + return mapMap(entityResults, (entityResult) => entityResult?.enforceValue() ?? null); + } + + /** + * Load an entity where uniqueFieldName equals fieldValue directly from the database, or null if no entity matches. + * See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader. + * + * @param uniqueFieldName - entity field being queried + * @param fieldValue - uniqueFieldName field value being queried + * @param modifiers - row locking modifiers for the query + * @returns entity where uniqueFieldName equals fieldValue, or null if no entity matches the condition + * @throws when multiple entities match the condition + * @throws EntityNotAuthorizedError when viewer is not authorized to view the returned entity + */ + async loadByFieldEqualingFromDatabaseAsync>( + uniqueFieldName: N, + fieldValue: NonNullable, + modifiers: EntityLoaderRowLockingModifiers, + ): Promise { + const entityResult = await this.knexEntityLoader.loadByFieldEqualingFromDatabaseAsync( + uniqueFieldName, + fieldValue, + modifiers, + ); + return entityResult ? entityResult.enforceValue() : null; + } + + /** + * Load many entities where fieldName equals fieldValue directly from the database. + * See {@link loadByIDFromDatabaseAsync} for how this differs from the standard loader. + * + * @param fieldName - entity field being queried + * @param fieldValue - fieldName field value being queried + * @param modifiers - row locking modifiers for the query + * @returns array of entities where fieldName equals fieldValue + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + */ + async loadManyByFieldEqualingFromDatabaseAsync>( + fieldName: N, + fieldValue: NonNullable, + modifiers: EntityLoaderRowLockingModifiers, + ): Promise { + const entityResults = await this.knexEntityLoader.loadManyByFieldEqualingFromDatabaseAsync( + fieldName, + fieldValue, + modifiers, + ); + return entityResults.map((entityResult) => entityResult.enforceValue()); + } + /** * Load the first entity matching the conjunction of field equality operands and * query modifiers. diff --git a/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts index 1455a51ade..2696c88b59 100644 --- a/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts +++ b/packages/entity-database-adapter-knex/src/KnexEntityLoaderFactory.ts @@ -79,6 +79,8 @@ export class KnexEntityLoaderFactory< return new AuthorizationResultBasedKnexEntityLoader( queryContext, + this.entityCompanion.entityCompanionDefinition.entityConfiguration, + this.entityCompanion.entityCompanionDefinition.entityClass, this.knexDataManager, this.metricsAdapter, constructionUtils, 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 f384dd8fa7..6e34c1cc1f 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 @@ -1,5 +1,6 @@ import { EntityDatabaseAdapterEmptyUpdateResultError, + EntityNotFoundError, TransactionIsolationLevel, ViewerContext, } from '@expo/entity'; @@ -498,6 +499,204 @@ describe('postgres entity integration', () => { }); }); + describe('FromDatabase load methods', () => { + const tryLockRowFromOtherConnectionAsync = async (id: string): Promise => { + try { + await knexInstance.raw( + 'SELECT * FROM postgres_test_entities WHERE id = ? FOR UPDATE NOWAIT', + [id], + ); + return null; + } catch (e) { + return (e as any).code ?? null; + } + }; + + it('loadByIDFromDatabaseAsync locks the row and throws EntityNotFoundError when missing', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entity = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'by-id') + .createAsync(), + ); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + const loaded = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadByIDFromDatabaseAsync(entity.getID(), { forUpdate: true }); + expect(loaded.getID()).toBe(entity.getID()); + expect(loaded.getField('name')).toBe('by-id'); + expect(await tryLockRowFromOtherConnectionAsync(entity.getID())).toBe('55P03'); + + await expect( + PostgresTestEntity.knexLoader(vc1, queryContext).loadByIDFromDatabaseAsync( + '00000000-0000-0000-0000-000000000000', + { forUpdate: true }, + ), + ).rejects.toThrow(EntityNotFoundError); + + const nullable = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadByIDNullableFromDatabaseAsync('00000000-0000-0000-0000-000000000000', { + forUpdate: true, + }); + expect(nullable).toBeNull(); + }); + + expect(await tryLockRowFromOtherConnectionAsync(entity.getID())).toBeNull(); + }); + + it('loadManyByIDsFromDatabaseAsync returns a map and throws for missing IDs', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entityA = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1).setField('name', 'a').createAsync(), + ); + const entityB = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1).setField('name', 'b').createAsync(), + ); + const missingId = '00000000-0000-0000-0000-000000000000'; + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + const loaded = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadManyByIDsFromDatabaseAsync([entityA.getID(), entityB.getID()], { forShare: true }); + expect(loaded.size).toBe(2); + expect(loaded.get(entityA.getID())!.getField('name')).toBe('a'); + expect(loaded.get(entityB.getID())!.getField('name')).toBe('b'); + + await expect( + PostgresTestEntity.knexLoader(vc1, queryContext).loadManyByIDsFromDatabaseAsync( + [entityA.getID(), missingId], + { forShare: true }, + ), + ).rejects.toThrow(EntityNotFoundError); + + const nullable = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadManyByIDsNullableFromDatabaseAsync([entityA.getID(), missingId], { + forShare: true, + }); + expect(nullable.size).toBe(2); + expect(nullable.get(entityA.getID())!.getField('name')).toBe('a'); + expect(nullable.get(missingId)).toBeNull(); + }); + }); + + it('loadManyByIDsNullableFromDatabaseAsync with skipLocked returns null for locked rows', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const entityA = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1).setField('name', 'a').createAsync(), + ); + const entityB = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1).setField('name', 'b').createAsync(), + ); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync( + 'postgres', + async (outerQueryContext) => { + await PostgresTestEntity.knexLoader(vc1, outerQueryContext).loadByIDFromDatabaseAsync( + entityA.getID(), + { forUpdate: true }, + ); + + const vc2 = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance), + ); + await vc2.runInTransactionForDatabaseAdapterFlavorAsync( + 'postgres', + async (innerQueryContext) => { + const results = await PostgresTestEntity.knexLoader( + vc2, + innerQueryContext, + ).loadManyByIDsNullableFromDatabaseAsync([entityA.getID(), entityB.getID()], { + forUpdate: true, + skipLocked: true, + }); + expect(results.get(entityA.getID())).toBeNull(); + expect(results.get(entityB.getID())!.getField('name')).toBe('b'); + + // the throwing variants do not permit skipLocked since a skipped row would be reported as not found + await expect( + PostgresTestEntity.knexLoader(vc2, innerQueryContext).loadByIDFromDatabaseAsync( + entityB.getID(), + // @ts-expect-error skipLocked is not permitted for throwing FromDatabase methods + { forUpdate: true, skipLocked: true }, + ), + ).resolves.toBeDefined(); + }, + ); + }, + ); + }); + + it('loadByFieldEqualingFromDatabaseAsync and loadManyByFieldEqualingFromDatabaseAsync lock matching rows', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + const unique = await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'unique') + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'dup') + .createAsync(), + ); + await enforceAsyncResult( + PostgresTestEntity.creatorWithAuthorizationResults(vc1) + .setField('name', 'dup') + .createAsync(), + ); + + await vc1.runInTransactionForDatabaseAdapterFlavorAsync('postgres', async (queryContext) => { + const loaded = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadByFieldEqualingFromDatabaseAsync('name', 'unique', { forUpdate: true }); + expect(loaded!.getID()).toBe(unique.getID()); + expect(await tryLockRowFromOtherConnectionAsync(unique.getID())).toBe('55P03'); + + const missing = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadByFieldEqualingFromDatabaseAsync('name', 'nope', { forUpdate: true }); + expect(missing).toBeNull(); + + await expect( + PostgresTestEntity.knexLoader(vc1, queryContext).loadByFieldEqualingFromDatabaseAsync( + 'name', + 'dup', + { forUpdate: true }, + ), + ).rejects.toThrow('Multiple entities of type PostgresTestEntity found for name=dup'); + + const many = await PostgresTestEntity.knexLoader( + vc1, + queryContext, + ).loadManyByFieldEqualingFromDatabaseAsync('name', 'dup', { forUpdate: true }); + expect(many).toHaveLength(2); + for (const e of many) { + expect(await tryLockRowFromOtherConnectionAsync(e.getID())).toBe('55P03'); + } + }); + + expect(await tryLockRowFromOtherConnectionAsync(unique.getID())).toBeNull(); + }); + + it('FromDatabase methods require a transaction when a lock is requested', async () => { + const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); + await expect( + PostgresTestEntity.knexLoader(vc1).loadByIDFromDatabaseAsync( + '00000000-0000-0000-0000-000000000000', + { forUpdate: true }, + ), + ).rejects.toThrow('require a transactional query context'); + }); + }); + describe('JSON fields', () => { it('supports both types of array fields', async () => { const vc1 = new ViewerContext(createKnexIntegrationTestEntityCompanionProvider(knexInstance)); diff --git a/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts index 3b0161b457..cb950a9df6 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/AuthorizationResultBasedKnexEntityLoader-test.ts @@ -3,9 +3,14 @@ import type { EntityQueryContext, IEntityMetricsAdapter, } from '@expo/entity'; -import { enforceResultsAsync, EntityConstructionUtils, ViewerContext } from '@expo/entity'; +import { + enforceResultsAsync, + EntityConstructionUtils, + EntityNotFoundError, + ViewerContext, +} from '@expo/entity'; import { describe, expect, it } from '@jest/globals'; -import { anyOfClass, anything, instance, mock, spy, verify, when } from 'ts-mockito'; +import { anyOfClass, anything, deepEqual, instance, mock, spy, verify, when } from 'ts-mockito'; import { v4 as uuidv4 } from 'uuid'; import { AuthorizationResultBasedKnexEntityLoader } from '../AuthorizationResultBasedKnexEntityLoader.ts'; @@ -87,6 +92,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { ); const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -170,6 +177,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { ); const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -263,6 +272,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -344,6 +355,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -430,6 +443,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -545,6 +560,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -631,6 +648,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testEntityConfiguration, + TestEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -731,6 +750,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -825,6 +846,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -916,6 +939,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -980,6 +1005,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -1067,6 +1094,8 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( queryContext, + testPaginationEntityConfiguration, + TestPaginationEntity, instance(knexDataManagerMock), metricsAdapter, constructionUtils, @@ -1087,4 +1116,326 @@ describe(AuthorizationResultBasedKnexEntityLoader, () => { expect(connection.pageInfo.endCursor).toBeNull(); }); }); + + describe('FromDatabase load methods', () => { + const makeFieldObject = (id: string, testIndexedField: string): TestFields => ({ + customIdField: id, + stringField: 'huh', + intField: 4, + testIndexedField, + dateField: new Date(), + nullableField: null, + }); + + const setup = (): { + knexEntityLoader: AuthorizationResultBasedKnexEntityLoader< + TestFields, + 'customIdField', + ViewerContext, + TestEntity, + TestEntityPrivacyPolicy, + keyof TestFields + >; + knexDataManagerMock: EntityKnexDataManager; + queryContext: EntityQueryContext; + } => { + const privacyPolicy = new TestEntityPrivacyPolicy(); + const viewerContext = instance(mock(ViewerContext)); + const privacyPolicyEvaluationContext = + instance( + mock< + EntityPrivacyPolicyEvaluationContext< + TestFields, + 'customIdField', + ViewerContext, + TestEntity + > + >(), + ); + const metricsAdapter = instance(mock()); + const queryContext = instance(mock()); + const knexDataManagerMock = + mock>(EntityKnexDataManager); + const constructionUtils = new EntityConstructionUtils( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + /* entitySelectedFields */ undefined, + privacyPolicy, + metricsAdapter, + ); + const knexEntityLoader = new AuthorizationResultBasedKnexEntityLoader( + queryContext, + testEntityConfiguration, + TestEntity, + instance(knexDataManagerMock), + metricsAdapter, + constructionUtils, + ); + return { knexEntityLoader, knexDataManagerMock, queryContext }; + }; + + it('loads entities with loadManyByIDsNullableFromDatabaseAsync', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const id2 = uuidv4(); + const missingId = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1'), makeFieldObject(id2, '2')]); + + const results = await knexEntityLoader.loadManyByIDsNullableFromDatabaseAsync( + [id1, id2, missingId], + { forUpdate: true }, + ); + expect(results.size).toBe(3); + expect(results.get(id1)!.enforceValue().getID()).toBe(id1); + expect(results.get(id2)!.enforceValue().getID()).toBe(id2); + expect(results.get(missingId)).toBeNull(); + + verify( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'customIdField', fieldValues: [id1, id2, missingId] }]), + deepEqual({ forUpdate: true }), + ), + ).once(); + }); + + it('returns an empty map and does not query for an empty list of IDs', async () => { + const { knexEntityLoader, knexDataManagerMock } = setup(); + const results = await knexEntityLoader.loadManyByIDsNullableFromDatabaseAsync([], { + forUpdate: true, + }); + expect(results.size).toBe(0); + verify( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).never(); + }); + + it('returns EntityNotFoundError results for missing IDs with loadManyByIDsFromDatabaseAsync', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const missingId = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1')]); + + const results = await knexEntityLoader.loadManyByIDsFromDatabaseAsync([id1, missingId], { + forShare: true, + }); + expect(results.size).toBe(2); + expect(results.get(id1)!.ok).toBe(true); + const missingResult = results.get(missingId)!; + expect(missingResult.ok).toBe(false); + expect(missingResult.enforceError()).toBeInstanceOf(EntityNotFoundError); + }); + + it('loads an entity with loadByIDFromDatabaseAsync', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const missingId = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'customIdField', fieldValues: [id1] }]), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1')]); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'customIdField', fieldValues: [missingId] }]), + anything(), + ), + ).thenResolve([]); + + const found = await knexEntityLoader.loadByIDFromDatabaseAsync(id1, { forUpdate: true }); + expect(found.enforceValue().getID()).toBe(id1); + + const missing = await knexEntityLoader.loadByIDFromDatabaseAsync(missingId, { + forUpdate: true, + }); + expect(missing.ok).toBe(false); + expect(missing.enforceError()).toBeInstanceOf(EntityNotFoundError); + }); + + it('loads an entity or null with loadByIDNullableFromDatabaseAsync', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const missingId = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'customIdField', fieldValues: [id1] }]), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1')]); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'customIdField', fieldValues: [missingId] }]), + anything(), + ), + ).thenResolve([]); + + const found = await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { + forUpdate: true, + skipLocked: true, + }); + expect(found!.enforceValue().getID()).toBe(id1); + + const missing = await knexEntityLoader.loadByIDNullableFromDatabaseAsync(missingId, { + forUpdate: true, + skipLocked: true, + }); + expect(missing).toBeNull(); + }); + + it('requires exactly one lock mode at the type level', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1')]); + + // valid: exactly one lock mode, optional skipLocked on nullable variants + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { forUpdate: true }); + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { forShare: true }); + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { + forShare: true, + skipLocked: true, + }); + await knexEntityLoader.loadByIDFromDatabaseAsync(id1, { forUpdate: true }); + + // @ts-expect-error a lock mode is required + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, {}); + // @ts-expect-error skipLocked alone is not a lock mode + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { skipLocked: true }); + // @ts-expect-error forUpdate must be true + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { forUpdate: false }); + await knexEntityLoader.loadByIDNullableFromDatabaseAsync(id1, { + forUpdate: true, + // @ts-expect-error forUpdate and forShare are mutually exclusive + forShare: true, + }); + // @ts-expect-error skipLocked is not permitted on throwing variants + await knexEntityLoader.loadByIDFromDatabaseAsync(id1, { forUpdate: true, skipLocked: true }); + await knexEntityLoader.loadManyByIDsFromDatabaseAsync([id1], { + forShare: true, + // @ts-expect-error skipLocked is not permitted on throwing variants + skipLocked: true, + }); + }); + + it('validates ID values for FromDatabase ID loads', async () => { + const { knexEntityLoader, knexDataManagerMock } = setup(); + await expect( + knexEntityLoader.loadByIDFromDatabaseAsync('not-a-uuid', { forUpdate: true }), + ).rejects.toThrow('Entity field not valid: TestEntity (customIdField = not-a-uuid)'); + verify( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + anything(), + anything(), + anything(), + ), + ).never(); + }); + + it('loads entities with loadManyByFieldEqualingFromDatabaseAsync', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const id2 = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + anything(), + anything(), + ), + ).thenResolve([makeFieldObject(id1, '1'), makeFieldObject(id2, '2')]); + + const results = await knexEntityLoader.loadManyByFieldEqualingFromDatabaseAsync( + 'stringField', + 'huh', + { forUpdate: true }, + ); + expect(results).toHaveLength(2); + expect(results.map((r) => r.enforceValue().getID())).toEqual([id1, id2]); + + verify( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'stringField', fieldValue: 'huh' }]), + deepEqual({ forUpdate: true }), + ), + ).once(); + }); + + it('loads an entity or null with loadByFieldEqualingFromDatabaseAsync and throws on multiple matches', async () => { + const { knexEntityLoader, knexDataManagerMock, queryContext } = setup(); + const id1 = uuidv4(); + const id2 = uuidv4(); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'testIndexedField', fieldValue: 'one' }]), + anything(), + ), + ).thenResolve([makeFieldObject(id1, 'one')]); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'testIndexedField', fieldValue: 'none' }]), + anything(), + ), + ).thenResolve([]); + when( + knexDataManagerMock.loadManyByFieldEqualityConjunctionAsync( + queryContext, + deepEqual([{ fieldName: 'testIndexedField', fieldValue: 'many' }]), + anything(), + ), + ).thenResolve([makeFieldObject(id1, 'many'), makeFieldObject(id2, 'many')]); + + const found = await knexEntityLoader.loadByFieldEqualingFromDatabaseAsync( + 'testIndexedField', + 'one', + { forShare: true }, + ); + expect(found!.enforceValue().getID()).toBe(id1); + + const missing = await knexEntityLoader.loadByFieldEqualingFromDatabaseAsync( + 'testIndexedField', + 'none', + { forShare: true }, + ); + expect(missing).toBeNull(); + + await expect( + knexEntityLoader.loadByFieldEqualingFromDatabaseAsync('testIndexedField', 'many', { + forShare: true, + }), + ).rejects.toThrow( + 'loadByFieldEqualingFromDatabase: Multiple entities of type TestEntity found for testIndexedField=many', + ); + }); + }); }); diff --git a/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts index 7781366110..ab3ff00a34 100644 --- a/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts +++ b/packages/entity-database-adapter-knex/src/__tests__/EnforcingKnexEntityLoader-test.ts @@ -2,7 +2,7 @@ import type { IEntityMetricsAdapter } from '@expo/entity'; import { EntityConstructionUtils, EntityQueryContext } from '@expo/entity'; import { result } from '@expo/results'; import { describe, expect, it } from '@jest/globals'; -import { anything, instance, mock, when } from 'ts-mockito'; +import { anything, deepEqual, instance, mock, when } from 'ts-mockito'; import { AuthorizationResultBasedKnexEntityLoader, @@ -198,6 +198,294 @@ describe(EnforcingKnexEntityLoader, () => { }); }); + describe('FromDatabase load methods', () => { + const makeLoader = ( + nonEnforcingKnexEntityLoaderMock: AuthorizationResultBasedKnexEntityLoader< + any, + any, + any, + any, + any, + any + >, + ): EnforcingKnexEntityLoader => + new EnforcingKnexEntityLoader( + instance(nonEnforcingKnexEntityLoaderMock), + instance(mock(EntityQueryContext)), + instance(mock(EntityKnexDataManager)), + instance(mock()), + instance(mock(EntityConstructionUtils)), + ); + + describe('loadByIDFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadByIDFromDatabaseAsync(anything(), anything()), + ).thenResolve(result(rejection)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByIDFromDatabaseAsync('id', { forUpdate: true }), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadByIDFromDatabaseAsync( + 'id', + deepEqual({ forUpdate: true }), + ), + ).thenResolve(result(resolved)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByIDFromDatabaseAsync('id', { forUpdate: true }), + ).resolves.toEqual(resolved); + }); + }); + + describe('loadByIDNullableFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadByIDNullableFromDatabaseAsync( + anything(), + anything(), + ), + ).thenResolve(result(rejection)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByIDNullableFromDatabaseAsync('id', { forUpdate: true }), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadByIDNullableFromDatabaseAsync( + anything(), + anything(), + ), + ).thenResolve(result(resolved)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByIDNullableFromDatabaseAsync('id', { forUpdate: true }), + ).resolves.toEqual(resolved); + }); + + it('returns null when result is null', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + when( + nonEnforcingKnexEntityLoaderMock.loadByIDNullableFromDatabaseAsync( + anything(), + anything(), + ), + ).thenResolve(null); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByIDNullableFromDatabaseAsync('id', { forUpdate: true }), + ).resolves.toBeNull(); + }); + }); + + describe('loadManyByIDsFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadManyByIDsFromDatabaseAsync(anything(), anything()), + ).thenResolve(new Map([['id', result(rejection)]])); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByIDsFromDatabaseAsync(['id'], { forUpdate: true }), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadManyByIDsFromDatabaseAsync(anything(), anything()), + ).thenResolve(new Map([['id', result(resolved)]])); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByIDsFromDatabaseAsync(['id'], { forUpdate: true }), + ).resolves.toEqual(new Map([['id', resolved]])); + }); + }); + + describe('loadManyByIDsNullableFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadManyByIDsNullableFromDatabaseAsync( + anything(), + anything(), + ), + ).thenResolve(new Map([['id', result(rejection)]])); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByIDsNullableFromDatabaseAsync(['id'], { + forUpdate: true, + }), + ).rejects.toThrow(rejection); + }); + + it('returns values and nulls when results are successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadManyByIDsNullableFromDatabaseAsync( + anything(), + anything(), + ), + ).thenResolve( + new Map([ + ['id', result(resolved)], + ['missing', null], + ]), + ); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByIDsNullableFromDatabaseAsync(['id', 'missing'], { + forUpdate: true, + }), + ).resolves.toEqual( + new Map([ + ['id', resolved], + ['missing', null], + ]), + ); + }); + }); + + describe('loadByFieldEqualingFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadByFieldEqualingFromDatabaseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve(result(rejection)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByFieldEqualingFromDatabaseAsync('field', 'value', { + forUpdate: true, + }), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadByFieldEqualingFromDatabaseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve(result(resolved)); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByFieldEqualingFromDatabaseAsync('field', 'value', { + forUpdate: true, + }), + ).resolves.toEqual(resolved); + }); + + it('returns null when result is null', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + when( + nonEnforcingKnexEntityLoaderMock.loadByFieldEqualingFromDatabaseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve(null); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadByFieldEqualingFromDatabaseAsync('field', 'value', { + forUpdate: true, + }), + ).resolves.toBeNull(); + }); + }); + + describe('loadManyByFieldEqualingFromDatabaseAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const rejection = new Error(); + when( + nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualingFromDatabaseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([result(rejection)]); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByFieldEqualingFromDatabaseAsync('field', 'value', { + forUpdate: true, + }), + ).rejects.toThrow(rejection); + }); + + it('returns values when results are successful', async () => { + const nonEnforcingKnexEntityLoaderMock = mock( + AuthorizationResultBasedKnexEntityLoader, + ); + const resolved = {}; + when( + nonEnforcingKnexEntityLoaderMock.loadManyByFieldEqualingFromDatabaseAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve([result(resolved)]); + const enforcingKnexEntityLoader = makeLoader(nonEnforcingKnexEntityLoaderMock); + await expect( + enforcingKnexEntityLoader.loadManyByFieldEqualingFromDatabaseAsync('field', 'value', { + forUpdate: true, + }), + ).resolves.toEqual([resolved]); + }); + }); + }); + describe('loadPageAsync', () => { it('throws when result is unsuccessful', async () => { const queryContext = instance(mock(EntityQueryContext));