diff --git a/packages/entity/src/EnforcingEntityLoader.ts b/packages/entity/src/EnforcingEntityLoader.ts index 5618da14..798623ba 100644 --- a/packages/entity/src/EnforcingEntityLoader.ts +++ b/packages/entity/src/EnforcingEntityLoader.ts @@ -114,6 +114,22 @@ export default class EnforcingEntityLoader< return mapMap(entityResults, (result) => result.enforceValue()); } + /** + * Enforcing version of entity loader method by the same name. + * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities + */ + async loadFirstByFieldEqualityConjunctionAsync>( + fieldEqualityOperands: FieldEqualityCondition[], + querySelectionModifiers: Omit, 'limit'> & + Required, 'orderBy'>> + ): Promise { + const entityResult = await this.entityLoader.loadFirstByFieldEqualityConjunctionAsync( + fieldEqualityOperands, + querySelectionModifiers + ); + return entityResult ? entityResult.enforceValue() : null; + } + /** * Enforcing version of entity loader method by the same name. * @throws EntityNotAuthorizedError when viewer is not authorized to view one or more of the returned entities diff --git a/packages/entity/src/EntityLoader.ts b/packages/entity/src/EntityLoader.ts index 2b63cf45..0622d842 100644 --- a/packages/entity/src/EntityLoader.ts +++ b/packages/entity/src/EntityLoader.ts @@ -182,6 +182,32 @@ export default class EntityLoader< }); } + /** + * 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 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; + } + /** * Loads many entities matching the selection constructed from the conjunction of specified operands. * Entities loaded using this method are not batched or cached. diff --git a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts index c04fc0ea..34447872 100644 --- a/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts +++ b/packages/entity/src/__tests__/EnforcingEntityLoader-test.ts @@ -222,6 +222,46 @@ describe(EnforcingEntityLoader, () => { }); }); + describe('loadFirstByFieldEqualityConjunction', () => { + it('throws when result is unsuccessful', async () => { + const entityLoaderMock = mock>(EntityLoader); + const rejection = new Error(); + when( + entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).thenResolve(result(rejection)); + const entityLoader = instance(entityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + await expect( + enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const entityLoaderMock = mock>(EntityLoader); + const resolved = {}; + when( + entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).thenResolve(result(resolved)); + const entityLoader = instance(entityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + await expect( + enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).resolves.toEqual(resolved); + }); + + it('returns null when the query is successful but no rows match', async () => { + const entityLoaderMock = mock>(EntityLoader); + when( + entityLoaderMock.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).thenResolve(null); + const entityLoader = instance(entityLoaderMock); + const enforcingEntityLoader = new EnforcingEntityLoader(entityLoader); + await expect( + enforcingEntityLoader.loadFirstByFieldEqualityConjunctionAsync(anything(), anything()) + ).resolves.toBeNull(); + }); + }); + describe('loadManyByFieldEqualityConjunction', () => { it('throws when result is unsuccessful', async () => { const entityLoaderMock = mock>(EntityLoader); diff --git a/packages/entity/src/__tests__/EntityLoader-test.ts b/packages/entity/src/__tests__/EntityLoader-test.ts index 2b381cf1..62f1820d 100644 --- a/packages/entity/src/__tests__/EntityLoader-test.ts +++ b/packages/entity/src/__tests__/EntityLoader-test.ts @@ -2,6 +2,7 @@ import { enforceAsyncResult } from '@expo/results'; import { mock, instance, verify, spy, deepEqual, anyOfClass, anything, when } from 'ts-mockito'; import { v4 as uuidv4 } from 'uuid'; +import { OrderByOrdering } from '../EntityDatabaseAdapter'; import EntityLoader from '../EntityLoader'; import { EntityPrivacyPolicyEvaluationContext } from '../EntityPrivacyPolicy'; import ViewerContext from '../ViewerContext'; @@ -210,6 +211,101 @@ describe(EntityLoader, () => { ).rejects.toThrowError('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()); + const metricsAdapter = instance(mock()); + const queryContext = 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, + StubQueryContextProvider, + instance(mock()), + TestEntity.name + ); + const entityLoader = new EntityLoader( + viewerContext, + queryContext, + privacyPolicyEvaluationContext, + testEntityConfiguration, + TestEntity, + privacyPolicy, + dataManager, + metricsAdapter + ); + 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('authorizes loaded entities', async () => { const privacyPolicy = new TestEntityPrivacyPolicy(); const spiedPrivacyPolicy = spy(privacyPolicy);