Skip to content

Commit

Permalink
chore: move entity loader utils into their own object (#239)
Browse files Browse the repository at this point in the history
  • Loading branch information
wschurman authored Jun 12, 2024
1 parent 2edc7af commit 93905be
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 164 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe(GenericLocalMemoryCacher, () => {

// invalidate from cache to ensure it invalidates correctly
await LocalMemoryTestEntity.loader(viewerContext)
.withAuthorizationResults()
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
const cachedResultMiss = await entitySpecificGenericCacher.loadManyAsync([
cacheKeyMaker('id', entity1.getID()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ describe(GenericRedisCacher, () => {

// invalidate from cache to ensure it invalidates correctly in both caches
await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
await expect(redis.get(cacheKeyEntity1)).resolves.toBeNull();
await expect(redis.get(cacheKeyEntity1NameField)).resolves.toBeNull();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe(GenericRedisCacher, () => {

// invalidate from cache to ensure it invalidates correctly
await RedisTestEntity.loader(viewerContext)
.withAuthorizationResults()
.utils()
.invalidateFieldsAsync(entity1.getAllFields());
const cachedValueNull = await (genericRedisCacheContext.redisClient as Redis).get(
cacheKeyMaker('id', entity1.getID()),
Expand Down
108 changes: 13 additions & 95 deletions packages/entity/src/AuthorizationResultBasedEntityLoader.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Result, asyncResult, result } from '@expo/results';
import { Result, result } from '@expo/results';
import invariant from 'invariant';
import nullthrows from 'nullthrows';

Expand All @@ -10,16 +10,16 @@ import {
isSingleValueFieldEqualityCondition,
QuerySelectionModifiersWithOrderByRaw,
} from './EntityDatabaseAdapter';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import EntityLoaderUtils from './EntityLoaderUtils';
import EntityPrivacyPolicy from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
import ViewerContext from './ViewerContext';
import { pick } from './entityUtils';
import EntityInvalidFieldValueError from './errors/EntityInvalidFieldValueError';
import EntityNotFoundError from './errors/EntityNotFoundError';
import EntityDataManager from './internal/EntityDataManager';
import IEntityMetricsAdapter from './metrics/IEntityMetricsAdapter';
import { mapMap, mapMapAsync } from './utils/collections/maps';
import { mapMap } from './utils/collections/maps';

/**
* Authorization-result-based entity loader. All normal loads are batched,
Expand All @@ -42,28 +42,26 @@ export default class AuthorizationResultBasedEntityLoader<
TSelectedFields extends keyof TFields,
> {
constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
private readonly privacyPolicyEvaluationContext: EntityPrivacyPolicyEvaluationContext<
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly entityConfiguration: EntityConfiguration<TFields>,
private readonly entityClass: IEntityClass<
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
private readonly utils: EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>,
private readonly entitySelectedFields: TSelectedFields[] | undefined,
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}

/**
Expand All @@ -85,7 +83,7 @@ export default class AuthorizationResultBasedEntityLoader<
fieldValues,
);

return await this.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
return await this.utils.constructAndAuthorizeEntitiesAsync(fieldValuesToFieldObjects);
}

/**
Expand Down Expand Up @@ -243,7 +241,7 @@ export default class AuthorizationResultBasedEntityLoader<
fieldEqualityOperands,
querySelectionModifiers,
);
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}

/**
Expand Down Expand Up @@ -280,87 +278,7 @@ export default class AuthorizationResultBasedEntityLoader<
bindings,
querySelectionModifiers,
);
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}

/**
* Invalidate all caches for an entity's fields. Exposed primarily for internal use by EntityMutator.
* @param objectFields - entity data object to be invalidated
*/
async invalidateFieldsAsync(objectFields: Readonly<TFields>): Promise<void> {
await this.dataManager.invalidateObjectFieldsAsync(objectFields);
}

/**
* Invalidate all caches for an entity. One potential use case would be to keep the entity
* framework in sync with changes made to data outside of the framework.
* @param entity - entity to be invalidated
*/
async invalidateEntityAsync(entity: TEntity): Promise<void> {
await this.invalidateFieldsAsync(entity.getAllDatabaseFields());
}

private tryConstructEntities(fieldsObjects: readonly TFields[]): readonly Result<TEntity>[] {
return fieldsObjects.map((fieldsObject) => {
try {
return result(this.constructEntity(fieldsObject));
} catch (e) {
if (!(e instanceof Error)) {
throw e;
}
return result(e);
}
});
}

public constructEntity(fieldsObject: TFields): TEntity {
const idField = this.entityConfiguration.idField;
const id = nullthrows(fieldsObject[idField], 'must provide ID to create an entity');
const entitySelectedFields =
this.entitySelectedFields ?? Array.from(this.entityConfiguration.schema.keys());
const selectedFields = pick(fieldsObject, entitySelectedFields);
return new this.entityClass({
viewerContext: this.viewerContext,
id: id as TID,
databaseFields: fieldsObject,
selectedFields,
});
}

/**
* Construct and authorize entities from fields map, returning error results for entities that fail
* to construct or fail to authorize.
*
* @param map - map from an arbitrary key type to an array of entity field objects
*/
public async constructAndAuthorizeEntitiesAsync<K>(
map: ReadonlyMap<K, readonly Readonly<TFields>[]>,
): Promise<ReadonlyMap<K, readonly Result<TEntity>[]>> {
return await mapMapAsync(map, async (fieldObjects) => {
return await this.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
});
}

private async constructAndAuthorizeEntitiesArrayAsync(
fieldObjects: readonly Readonly<TFields>[],
): Promise<readonly Result<TEntity>[]> {
const uncheckedEntityResults = this.tryConstructEntities(fieldObjects);
return await Promise.all(
uncheckedEntityResults.map(async (uncheckedEntityResult) => {
if (!uncheckedEntityResult.ok) {
return uncheckedEntityResult;
}
return await asyncResult(
this.privacyPolicy.authorizeReadAsync(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
uncheckedEntityResult.value,
this.metricsAdapter,
),
);
}),
);
return await this.utils.constructAndAuthorizeEntitiesArrayAsync(fieldObjects);
}

private validateFieldValues<N extends keyof Pick<TFields, TSelectedFields>>(
Expand Down
44 changes: 39 additions & 5 deletions packages/entity/src/EntityLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AuthorizationResultBasedEntityLoader from './AuthorizationResultBasedEnti
import EnforcingEntityLoader from './EnforcingEntityLoader';
import { IEntityClass } from './Entity';
import EntityConfiguration from './EntityConfiguration';
import EntityLoaderUtils from './EntityLoaderUtils';
import EntityPrivacyPolicy, { EntityPrivacyPolicyEvaluationContext } from './EntityPrivacyPolicy';
import { EntityQueryContext } from './EntityQueryContext';
import ReadonlyEntity from './ReadonlyEntity';
Expand All @@ -27,6 +28,15 @@ export default class EntityLoader<
>,
TSelectedFields extends keyof TFields,
> {
private readonly utilsPrivate: EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
>;

constructor(
private readonly viewerContext: TViewerContext,
private readonly queryContext: EntityQueryContext,
Expand All @@ -50,7 +60,19 @@ export default class EntityLoader<
private readonly privacyPolicy: TPrivacyPolicy,
private readonly dataManager: EntityDataManager<TFields>,
protected readonly metricsAdapter: IEntityMetricsAdapter,
) {}
) {
this.utilsPrivate = new EntityLoaderUtils(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.entitySelectedFields,
this.privacyPolicy,
this.dataManager,
this.metricsAdapter,
);
}

/**
* Enforcing entity loader. All loads through this loader are
Expand Down Expand Up @@ -82,15 +104,27 @@ export default class EntityLoader<
TSelectedFields
> {
return new AuthorizationResultBasedEntityLoader(
this.viewerContext,
this.queryContext,
this.privacyPolicyEvaluationContext,
this.entityConfiguration,
this.entityClass,
this.entitySelectedFields,
this.privacyPolicy,
this.dataManager,
this.metricsAdapter,
this.utilsPrivate,
);
}

/**
* Entity loader utilities for things like cache invalidation, entity construction, and authorization.
* Calling into these should only be necessary in rare cases.
*/
public utils(): EntityLoaderUtils<
TFields,
TID,
TViewerContext,
TEntity,
TPrivacyPolicy,
TSelectedFields
> {
return this.utilsPrivate;
}
}
Loading

0 comments on commit 93905be

Please sign in to comment.