Skip to content

Commit

Permalink
[rfc] loadFirstByFieldEqualityConjunction, a convenience method (#206)
Browse files Browse the repository at this point in the history
  • Loading branch information
ide authored Jan 25, 2023
1 parent 92f5529 commit 1934216
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 0 deletions.
16 changes: 16 additions & 0 deletions packages/entity/src/EnforcingEntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
): Promise<TEntity | null> {
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
Expand Down
26 changes: 26 additions & 0 deletions packages/entity/src/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<N extends keyof Pick<TFields, TSelectedFields>>(
fieldEqualityOperands: FieldEqualityCondition<TFields, N>[],
querySelectionModifiers: Omit<QuerySelectionModifiers<TFields>, 'limit'> &
Required<Pick<QuerySelectionModifiers<TFields>, 'orderBy'>>
): Promise<Result<TEntity> | 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.
Expand Down
40 changes: 40 additions & 0 deletions packages/entity/src/__tests__/EnforcingEntityLoader-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,46 @@ describe(EnforcingEntityLoader, () => {
});
});

describe('loadFirstByFieldEqualityConjunction', () => {
it('throws when result is unsuccessful', async () => {
const entityLoaderMock = mock<EntityLoader<any, any, any, any, any, any>>(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<any, any, any, any, any, any>>(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<any, any, any, any, any, any>>(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<any, any, any, any, any, any>>(EntityLoader);
Expand Down
96 changes: 96 additions & 0 deletions packages/entity/src/__tests__/EntityLoader-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<EntityPrivacyPolicyEvaluationContext>());
const metricsAdapter = instance(mock<IEntityMetricsAdapter>());
const queryContext = StubQueryContextProvider.getQueryContext();

const id1 = uuidv4();
const id2 = uuidv4();
const id3 = uuidv4();
const databaseAdapter = new StubDatabaseAdapter<TestFields>(
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<IEntityMetricsAdapter>()),
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);
Expand Down

0 comments on commit 1934216

Please sign in to comment.