From 4761cc2deabe962f00c4250eca97f19ff6e3192c Mon Sep 17 00:00:00 2001 From: Will Schurman Date: Thu, 20 Feb 2025 13:57:45 -0700 Subject: [PATCH] feat!: Add enforcing entity association loader --- ...ationResultBasedEntityAssociationLoader.ts | 527 ++++++++++++++++++ .../src/EnforcingEntityAssociationLoader.ts | 413 ++++++++++++++ .../entity/src/EntityAssociationLoader.ts | 522 +---------------- ...ResultBasedEntityAssociationLoader-test.ts | 354 ++++++++++++ .../EnforcingEntityAssociationLoader-test.ts | 288 ++++++++++ .../__tests__/EntityAssociationLoader-test.ts | 321 +---------- packages/entity/src/index.ts | 4 +- 7 files changed, 1624 insertions(+), 805 deletions(-) create mode 100644 packages/entity/src/AuthorizationResultBasedEntityAssociationLoader.ts create mode 100644 packages/entity/src/EnforcingEntityAssociationLoader.ts create mode 100644 packages/entity/src/__tests__/AuthorizationResultBasedEntityAssociationLoader-test.ts create mode 100644 packages/entity/src/__tests__/EnforcingEntityAssociationLoader-test.ts diff --git a/packages/entity/src/AuthorizationResultBasedEntityAssociationLoader.ts b/packages/entity/src/AuthorizationResultBasedEntityAssociationLoader.ts new file mode 100644 index 00000000..542b13c5 --- /dev/null +++ b/packages/entity/src/AuthorizationResultBasedEntityAssociationLoader.ts @@ -0,0 +1,527 @@ +import { Result, result } from '@expo/results'; + +import { IEntityClass } from './Entity'; +import EntityPrivacyPolicy from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import ReadonlyEntity from './ReadonlyEntity'; +import ViewerContext from './ViewerContext'; + +/** + * An association loader is a set of convenience methods for loading entities + * associated with an entity. In relational databases, these entities are often referenced + * by foreign keys. + */ +export default class AuthorizationResultBasedEntityAssociationLoader< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields, +> { + constructor(private readonly entity: TEntity) {} + + /** + * Load an associated entity identified by a field value of this entity. In a relational database, + * the field in this entity is a foreign key to the ID of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the ID of the associated entity + * @param associatedEntityClass - class of the associated entity + * @param queryContext - query context in which to perform the load + */ + async loadAssociatedEntityAsync< + TIdentifyingField extends keyof Pick, + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: TIdentifyingField, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + queryContext: EntityQueryContext = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getQueryContextProvider() + .getQueryContext(), + ): Promise< + Result + > { + const associatedEntityID = this.entity.getField(fieldIdentifyingAssociatedEntity); + if (!associatedEntityID) { + return result(null) as Result< + null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity + >; + } + + const loader = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getLoaderFactory() + .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); + + return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result< + null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity + >; + } + + /** + * Load many entities associated with this entity, often referred to as entites belonging + * to this entity. In a relational database, the field in the foreign entity is a + * foreign key to the ID of this entity. Also commonly referred to as a has many relationship, + * where this entity has many associated entities. + * @param associatedEntityClass - class of the associated entities + * @param associatedEntityFieldContainingThisID - field of associated entity which contains the ID of this entity + * @param queryContext - query context in which to perform the load + */ + async loadManyAssociatedEntitiesAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityFieldContainingThisID: keyof Pick, + queryContext: EntityQueryContext = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getQueryContextProvider() + .getQueryContext(), + ): Promise[]> { + const thisID = this.entity.getID(); + const loader = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getLoaderFactory() + .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); + return await loader.loadManyByFieldEqualingAsync( + associatedEntityFieldContainingThisID, + thisID as any, + ); + } + + /** + * Load an associated entity identified by a field value of this entity. In a relational database, + * the field in this entity is a foreign key to a unique field of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entity + * @param associatedEntityClass - class of the associated entity + * @param associatedEntityLookupByField - field of associated entity with which to look up the associated entity + * @param queryContext - query context in which to perform the load + */ + async loadAssociatedEntityByFieldEqualingAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: keyof Pick, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityLookupByField: keyof Pick, + queryContext: EntityQueryContext = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getQueryContextProvider() + .getQueryContext(), + ): Promise | null> { + const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity); + if (!associatedFieldValue) { + return null; + } + const loader = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getLoaderFactory() + .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); + return await loader.loadByFieldEqualingAsync( + associatedEntityLookupByField, + associatedFieldValue as any, + ); + } + + /** + * Load many associated entities identified by a field value of this entity. In a relational database, + * the field in this entity refers to a field of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entities + * @param associatedEntityClass - class of the associated entities + * @param associatedEntityLookupByField - field of associated entities with which to look up the associated entities + * @param queryContext - query context in which to perform the load + */ + async loadManyAssociatedEntitiesByFieldEqualingAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: keyof Pick, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityLookupByField: keyof Pick, + queryContext: EntityQueryContext = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getQueryContextProvider() + .getQueryContext(), + ): Promise[]> { + const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity); + if (!associatedFieldValue) { + return []; + } + + const loader = this.entity + .getViewerContext() + .getViewerScopedEntityCompanionForClass(associatedEntityClass) + .getLoaderFactory() + .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); + return await loader.loadManyByFieldEqualingAsync( + associatedEntityLookupByField, + associatedFieldValue as any, + ); + } + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + ], + queryContext?: EntityQueryContext, + ): Promise | null>; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TFields3 extends object, + TID3 extends NonNullable, + TEntity3 extends ReadonlyEntity, + TPrivacyPolicy3 extends EntityPrivacyPolicy< + TFields3, + TID3, + TViewerContext, + TEntity3, + TSelectedFields3 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + TSelectedFields3 extends keyof TFields3 = keyof TFields3, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields2, + TFields3, + TID3, + TEntity3, + TPrivacyPolicy3, + TSelectedFields2, + TSelectedFields3 + >, + ], + queryContext?: EntityQueryContext, + ): Promise | null>; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TFields3 extends object, + TID3 extends NonNullable, + TEntity3 extends ReadonlyEntity, + TPrivacyPolicy3 extends EntityPrivacyPolicy< + TFields3, + TID3, + TViewerContext, + TEntity3, + TSelectedFields3 + >, + TFields4 extends object, + TID4 extends NonNullable, + TEntity4 extends ReadonlyEntity, + TPrivacyPolicy4 extends EntityPrivacyPolicy< + TFields4, + TID4, + TViewerContext, + TEntity4, + TSelectedFields4 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + TSelectedFields3 extends keyof TFields3 = keyof TFields3, + TSelectedFields4 extends keyof TFields4 = keyof TFields4, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields2, + TFields3, + TID3, + TEntity3, + TPrivacyPolicy3, + TSelectedFields2, + TSelectedFields3 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields3, + TFields4, + TID4, + TEntity4, + TPrivacyPolicy4, + TSelectedFields3, + TSelectedFields4 + >, + ], + queryContext?: EntityQueryContext, + ): Promise | null>; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync( + loadDirectives: EntityLoadThroughDirective[], + queryContext?: EntityQueryContext, + ): Promise> | null>; + + async loadAssociatedEntityThroughAsync( + loadDirectives: EntityLoadThroughDirective[], + queryContext?: EntityQueryContext, + ): Promise> | null> { + let currentEntity: ReadonlyEntity = this.entity; + for (const loadDirective of loadDirectives) { + const { + associatedEntityClass, + fieldIdentifyingAssociatedEntity, + associatedEntityLookupByField, + } = loadDirective; + let associatedEntityResult: Result> | null; + if (associatedEntityLookupByField) { + associatedEntityResult = await currentEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityByFieldEqualingAsync( + fieldIdentifyingAssociatedEntity, + associatedEntityClass, + associatedEntityLookupByField, + queryContext, + ); + } else { + const associatedEntityResultLocal = await currentEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityAsync( + fieldIdentifyingAssociatedEntity, + associatedEntityClass, + queryContext, + ); + + if (associatedEntityResultLocal.ok && associatedEntityResultLocal.value === null) { + associatedEntityResult = null; + } else { + associatedEntityResult = associatedEntityResultLocal; + } + } + + if (!associatedEntityResult) { + return null; + } + + if (!associatedEntityResult.ok) { + return result(associatedEntityResult.reason); + } + currentEntity = associatedEntityResult.value; + } + return result(currentEntity); + } +} + +/** + * Instruction for each step of a load-associated-through method. + */ +export interface EntityLoadThroughDirective< + TViewerContext extends ViewerContext, + TFields, + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TSelectedFields extends keyof TFields = keyof TFields, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, +> { + /** + * Class of entity to load at this step. + */ + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >; + + /** + * Field of the current entity with which to load an instance of associatedEntityClass. + */ + fieldIdentifyingAssociatedEntity: keyof Pick; + + /** + * Field by which to load the instance of associatedEntityClass. If not provided, the + * associatedEntityClass instance is fetched by its ID. + */ + associatedEntityLookupByField?: keyof Pick; +} diff --git a/packages/entity/src/EnforcingEntityAssociationLoader.ts b/packages/entity/src/EnforcingEntityAssociationLoader.ts new file mode 100644 index 00000000..ae1a6567 --- /dev/null +++ b/packages/entity/src/EnforcingEntityAssociationLoader.ts @@ -0,0 +1,413 @@ +import { enforceAsyncResult } from '@expo/results'; + +import AuthorizationResultBasedEntityAssociationLoader, { + EntityLoadThroughDirective, +} from './AuthorizationResultBasedEntityAssociationLoader'; +import { IEntityClass } from './Entity'; +import EntityPrivacyPolicy from './EntityPrivacyPolicy'; +import { EntityQueryContext } from './EntityQueryContext'; +import ReadonlyEntity from './ReadonlyEntity'; +import ViewerContext from './ViewerContext'; +import { enforceResultsAsync } from './entityUtils'; + +/** + * An association loader is a set of convenience methods for loading entities + * associated with an entity. In relational databases, these entities are often referenced + * by foreign keys. + */ +export default class EnforcingEntityAssociationLoader< + TFields extends object, + TID extends NonNullable, + TViewerContext extends ViewerContext, + TEntity extends ReadonlyEntity, + TSelectedFields extends keyof TFields, +> { + constructor( + private readonly authorizationResultBasedEntityAssociationLoader: AuthorizationResultBasedEntityAssociationLoader< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields + >, + ) {} + + /** + * Load an associated entity identified by a field value of this entity. In a relational database, + * the field in this entity is a foreign key to the ID of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the ID of the associated entity + * @param associatedEntityClass - class of the associated entity + * @param queryContext - query context in which to perform the load + */ + async loadAssociatedEntityAsync< + TIdentifyingField extends keyof Pick, + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: TIdentifyingField, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + queryContext?: EntityQueryContext, + ): Promise< + null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity + > { + return await enforceAsyncResult( + this.authorizationResultBasedEntityAssociationLoader.loadAssociatedEntityAsync( + fieldIdentifyingAssociatedEntity, + associatedEntityClass, + queryContext, + ), + ); + } + + /** + * Load many entities associated with this entity, often referred to as entites belonging + * to this entity. In a relational database, the field in the foreign entity is a + * foreign key to the ID of this entity. Also commonly referred to as a has many relationship, + * where this entity has many associated entities. + * @param associatedEntityClass - class of the associated entities + * @param associatedEntityFieldContainingThisID - field of associated entity which contains the ID of this entity + * @param queryContext - query context in which to perform the load + */ + async loadManyAssociatedEntitiesAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityFieldContainingThisID: keyof Pick, + queryContext?: EntityQueryContext, + ): Promise { + return await enforceResultsAsync( + this.authorizationResultBasedEntityAssociationLoader.loadManyAssociatedEntitiesAsync( + associatedEntityClass, + associatedEntityFieldContainingThisID, + queryContext, + ), + ); + } + + /** + * Load an associated entity identified by a field value of this entity. In a relational database, + * the field in this entity is a foreign key to a unique field of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entity + * @param associatedEntityClass - class of the associated entity + * @param associatedEntityLookupByField - field of associated entity with which to look up the associated entity + * @param queryContext - query context in which to perform the load + */ + async loadAssociatedEntityByFieldEqualingAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: keyof Pick, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityLookupByField: keyof Pick, + queryContext?: EntityQueryContext, + ): Promise { + const result = + await this.authorizationResultBasedEntityAssociationLoader.loadAssociatedEntityByFieldEqualingAsync( + fieldIdentifyingAssociatedEntity, + associatedEntityClass, + associatedEntityLookupByField, + queryContext, + ); + return result?.enforceValue() ?? null; + } + + /** + * Load many associated entities identified by a field value of this entity. In a relational database, + * the field in this entity refers to a field of the associated entity. + * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entities + * @param associatedEntityClass - class of the associated entities + * @param associatedEntityLookupByField - field of associated entities with which to look up the associated entities + * @param queryContext - query context in which to perform the load + */ + async loadManyAssociatedEntitiesByFieldEqualingAsync< + TAssociatedFields extends object, + TAssociatedID extends NonNullable, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, + >( + fieldIdentifyingAssociatedEntity: keyof Pick, + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >, + associatedEntityLookupByField: keyof Pick, + queryContext?: EntityQueryContext, + ): Promise { + return await enforceResultsAsync( + this.authorizationResultBasedEntityAssociationLoader.loadManyAssociatedEntitiesByFieldEqualingAsync( + fieldIdentifyingAssociatedEntity, + associatedEntityClass, + associatedEntityLookupByField, + queryContext, + ), + ); + } + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + ], + queryContext?: EntityQueryContext, + ): Promise; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TFields3 extends object, + TID3 extends NonNullable, + TEntity3 extends ReadonlyEntity, + TPrivacyPolicy3 extends EntityPrivacyPolicy< + TFields3, + TID3, + TViewerContext, + TEntity3, + TSelectedFields3 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + TSelectedFields3 extends keyof TFields3 = keyof TFields3, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields2, + TFields3, + TID3, + TEntity3, + TPrivacyPolicy3, + TSelectedFields2, + TSelectedFields3 + >, + ], + queryContext?: EntityQueryContext, + ): Promise; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync< + TFields2 extends object, + TID2 extends NonNullable, + TEntity2 extends ReadonlyEntity, + TPrivacyPolicy2 extends EntityPrivacyPolicy< + TFields2, + TID2, + TViewerContext, + TEntity2, + TSelectedFields2 + >, + TFields3 extends object, + TID3 extends NonNullable, + TEntity3 extends ReadonlyEntity, + TPrivacyPolicy3 extends EntityPrivacyPolicy< + TFields3, + TID3, + TViewerContext, + TEntity3, + TSelectedFields3 + >, + TFields4 extends object, + TID4 extends NonNullable, + TEntity4 extends ReadonlyEntity, + TPrivacyPolicy4 extends EntityPrivacyPolicy< + TFields4, + TID4, + TViewerContext, + TEntity4, + TSelectedFields4 + >, + TSelectedFields2 extends keyof TFields2 = keyof TFields2, + TSelectedFields3 extends keyof TFields3 = keyof TFields3, + TSelectedFields4 extends keyof TFields4 = keyof TFields4, + >( + loadDirectives: [ + EntityLoadThroughDirective< + TViewerContext, + TFields, + TFields2, + TID2, + TEntity2, + TPrivacyPolicy2, + TSelectedFields, + TSelectedFields2 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields2, + TFields3, + TID3, + TEntity3, + TPrivacyPolicy3, + TSelectedFields2, + TSelectedFields3 + >, + EntityLoadThroughDirective< + TViewerContext, + TFields3, + TFields4, + TID4, + TEntity4, + TPrivacyPolicy4, + TSelectedFields3, + TSelectedFields4 + >, + ], + queryContext?: EntityQueryContext, + ): Promise; + + /** + * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each + * fold step, load an associated entity identified by a field value of the current fold value. + * @param loadDirectives - associated entity load directives instructing each step of the fold + * @param queryContext - query context in which to perform the loads + */ + async loadAssociatedEntityThroughAsync( + loadDirectives: EntityLoadThroughDirective[], + queryContext?: EntityQueryContext, + ): Promise | null>; + + async loadAssociatedEntityThroughAsync( + loadDirectives: EntityLoadThroughDirective[], + queryContext?: EntityQueryContext, + ): Promise | null> { + const result = + await this.authorizationResultBasedEntityAssociationLoader.loadAssociatedEntityThroughAsync( + loadDirectives, + queryContext, + ); + return result?.enforceValue() ?? null; + } +} diff --git a/packages/entity/src/EntityAssociationLoader.ts b/packages/entity/src/EntityAssociationLoader.ts index 4895b9f0..8f7c3106 100644 --- a/packages/entity/src/EntityAssociationLoader.ts +++ b/packages/entity/src/EntityAssociationLoader.ts @@ -1,8 +1,5 @@ -import { Result, result } from '@expo/results'; - -import { IEntityClass } from './Entity'; -import EntityPrivacyPolicy from './EntityPrivacyPolicy'; -import { EntityQueryContext } from './EntityQueryContext'; +import AuthorizationResultBasedEntityAssociationLoader from './AuthorizationResultBasedEntityAssociationLoader'; +import EnforcingEntityAssociationLoader from './EnforcingEntityAssociationLoader'; import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; @@ -21,505 +18,32 @@ export default class EntityAssociationLoader< constructor(private readonly entity: TEntity) {} /** - * Load an associated entity identified by a field value of this entity. In a relational database, - * the field in this entity is a foreign key to the ID of the associated entity. - * @param fieldIdentifyingAssociatedEntity - field of this entity containing the ID of the associated entity - * @param associatedEntityClass - class of the associated entity - * @param queryContext - query context in which to perform the load + * Enforcing entity association 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. */ - async loadAssociatedEntityAsync< - TIdentifyingField extends keyof Pick, - TAssociatedFields extends object, - TAssociatedID extends NonNullable, - TAssociatedEntity extends ReadonlyEntity< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedSelectedFields - >, - TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedSelectedFields - >, - TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, - >( - fieldIdentifyingAssociatedEntity: TIdentifyingField, - associatedEntityClass: IEntityClass< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedPrivacyPolicy, - TAssociatedSelectedFields - >, - queryContext: EntityQueryContext = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getQueryContextProvider() - .getQueryContext(), - ): Promise< - Result + enforcing(): EnforcingEntityAssociationLoader< + TFields, + TID, + TViewerContext, + TEntity, + TSelectedFields > { - const associatedEntityID = this.entity.getField(fieldIdentifyingAssociatedEntity); - if (!associatedEntityID) { - return result(null) as Result< - null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity - >; - } - - const loader = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getLoaderFactory() - .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - - return (await loader.loadByIDAsync(associatedEntityID as unknown as TAssociatedID)) as Result< - null extends TFields[TIdentifyingField] ? TAssociatedEntity | null : TAssociatedEntity - >; + return new EnforcingEntityAssociationLoader(this.withAuthorizationResults()); } /** - * Load many entities associated with this entity, often referred to as entites belonging - * to this entity. In a relational database, the field in the foreign entity is a - * foreign key to the ID of this entity. Also commonly referred to as a has many relationship, - * where this entity has many associated entities. - * @param associatedEntityClass - class of the associated entities - * @param associatedEntityFieldContainingThisID - field of associated entity which contains the ID of this entity - * @param queryContext - query context in which to perform the load + * Authorization-result-based entity loader. All loads through this + * loader are results, where an unsuccessful result + * means an authorization error or entity construction error occurred. Other errors are thrown. */ - async loadManyAssociatedEntitiesAsync< - TAssociatedFields extends object, - TAssociatedID extends NonNullable, - TAssociatedEntity extends ReadonlyEntity< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedSelectedFields - >, - TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedSelectedFields - >, - TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, - >( - associatedEntityClass: IEntityClass< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedPrivacyPolicy, - TAssociatedSelectedFields - >, - associatedEntityFieldContainingThisID: keyof Pick, - queryContext: EntityQueryContext = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getQueryContextProvider() - .getQueryContext(), - ): Promise[]> { - const thisID = this.entity.getID(); - const loader = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getLoaderFactory() - .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadManyByFieldEqualingAsync( - associatedEntityFieldContainingThisID, - thisID as any, - ); - } - - /** - * Load an associated entity identified by a field value of this entity. In a relational database, - * the field in this entity is a foreign key to a unique field of the associated entity. - * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entity - * @param associatedEntityClass - class of the associated entity - * @param associatedEntityLookupByField - field of associated entity with which to look up the associated entity - * @param queryContext - query context in which to perform the load - */ - async loadAssociatedEntityByFieldEqualingAsync< - TAssociatedFields extends object, - TAssociatedID extends NonNullable, - TAssociatedEntity extends ReadonlyEntity< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedSelectedFields - >, - TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedSelectedFields - >, - TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, - >( - fieldIdentifyingAssociatedEntity: keyof Pick, - associatedEntityClass: IEntityClass< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedPrivacyPolicy, - TAssociatedSelectedFields - >, - associatedEntityLookupByField: keyof Pick, - queryContext: EntityQueryContext = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getQueryContextProvider() - .getQueryContext(), - ): Promise | null> { - const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity); - if (!associatedFieldValue) { - return null; - } - const loader = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getLoaderFactory() - .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadByFieldEqualingAsync( - associatedEntityLookupByField, - associatedFieldValue as any, - ); - } - - /** - * Load many associated entities identified by a field value of this entity. In a relational database, - * the field in this entity refers to a field of the associated entity. - * @param fieldIdentifyingAssociatedEntity - field of this entity containing the value with which to look up associated entities - * @param associatedEntityClass - class of the associated entities - * @param associatedEntityLookupByField - field of associated entities with which to look up the associated entities - * @param queryContext - query context in which to perform the load - */ - async loadManyAssociatedEntitiesByFieldEqualingAsync< - TAssociatedFields extends object, - TAssociatedID extends NonNullable, - TAssociatedEntity extends ReadonlyEntity< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedSelectedFields - >, - TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedSelectedFields - >, - TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, - >( - fieldIdentifyingAssociatedEntity: keyof Pick, - associatedEntityClass: IEntityClass< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedPrivacyPolicy, - TAssociatedSelectedFields - >, - associatedEntityLookupByField: keyof Pick, - queryContext: EntityQueryContext = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getQueryContextProvider() - .getQueryContext(), - ): Promise[]> { - const associatedFieldValue = this.entity.getField(fieldIdentifyingAssociatedEntity); - if (!associatedFieldValue) { - return []; - } - - const loader = this.entity - .getViewerContext() - .getViewerScopedEntityCompanionForClass(associatedEntityClass) - .getLoaderFactory() - .forLoad(queryContext, { previousValue: null, cascadingDeleteCause: null }); - return await loader.loadManyByFieldEqualingAsync( - associatedEntityLookupByField, - associatedFieldValue as any, - ); - } - - /** - * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each - * fold step, load an associated entity identified by a field value of the current fold value. - * @param loadDirectives - associated entity load directives instructing each step of the fold - * @param queryContext - query context in which to perform the loads - */ - async loadAssociatedEntityThroughAsync< - TFields2 extends object, - TID2 extends NonNullable, - TEntity2 extends ReadonlyEntity, - TPrivacyPolicy2 extends EntityPrivacyPolicy< - TFields2, - TID2, - TViewerContext, - TEntity2, - TSelectedFields2 - >, - TSelectedFields2 extends keyof TFields2 = keyof TFields2, - >( - loadDirectives: [ - EntityLoadThroughDirective< - TViewerContext, - TFields, - TFields2, - TID2, - TEntity2, - TPrivacyPolicy2, - TSelectedFields, - TSelectedFields2 - >, - ], - queryContext?: EntityQueryContext, - ): Promise | null>; - - /** - * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each - * fold step, load an associated entity identified by a field value of the current fold value. - * @param loadDirectives - associated entity load directives instructing each step of the fold - * @param queryContext - query context in which to perform the loads - */ - async loadAssociatedEntityThroughAsync< - TFields2 extends object, - TID2 extends NonNullable, - TEntity2 extends ReadonlyEntity, - TPrivacyPolicy2 extends EntityPrivacyPolicy< - TFields2, - TID2, - TViewerContext, - TEntity2, - TSelectedFields2 - >, - TFields3 extends object, - TID3 extends NonNullable, - TEntity3 extends ReadonlyEntity, - TPrivacyPolicy3 extends EntityPrivacyPolicy< - TFields3, - TID3, - TViewerContext, - TEntity3, - TSelectedFields3 - >, - TSelectedFields2 extends keyof TFields2 = keyof TFields2, - TSelectedFields3 extends keyof TFields3 = keyof TFields3, - >( - loadDirectives: [ - EntityLoadThroughDirective< - TViewerContext, - TFields, - TFields2, - TID2, - TEntity2, - TPrivacyPolicy2, - TSelectedFields, - TSelectedFields2 - >, - EntityLoadThroughDirective< - TViewerContext, - TFields2, - TFields3, - TID3, - TEntity3, - TPrivacyPolicy3, - TSelectedFields2, - TSelectedFields3 - >, - ], - queryContext?: EntityQueryContext, - ): Promise | null>; - - /** - * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each - * fold step, load an associated entity identified by a field value of the current fold value. - * @param loadDirectives - associated entity load directives instructing each step of the fold - * @param queryContext - query context in which to perform the loads - */ - async loadAssociatedEntityThroughAsync< - TFields2 extends object, - TID2 extends NonNullable, - TEntity2 extends ReadonlyEntity, - TPrivacyPolicy2 extends EntityPrivacyPolicy< - TFields2, - TID2, - TViewerContext, - TEntity2, - TSelectedFields2 - >, - TFields3 extends object, - TID3 extends NonNullable, - TEntity3 extends ReadonlyEntity, - TPrivacyPolicy3 extends EntityPrivacyPolicy< - TFields3, - TID3, - TViewerContext, - TEntity3, - TSelectedFields3 - >, - TFields4 extends object, - TID4 extends NonNullable, - TEntity4 extends ReadonlyEntity, - TPrivacyPolicy4 extends EntityPrivacyPolicy< - TFields4, - TID4, - TViewerContext, - TEntity4, - TSelectedFields4 - >, - TSelectedFields2 extends keyof TFields2 = keyof TFields2, - TSelectedFields3 extends keyof TFields3 = keyof TFields3, - TSelectedFields4 extends keyof TFields4 = keyof TFields4, - >( - loadDirectives: [ - EntityLoadThroughDirective< - TViewerContext, - TFields, - TFields2, - TID2, - TEntity2, - TPrivacyPolicy2, - TSelectedFields, - TSelectedFields2 - >, - EntityLoadThroughDirective< - TViewerContext, - TFields2, - TFields3, - TID3, - TEntity3, - TPrivacyPolicy3, - TSelectedFields2, - TSelectedFields3 - >, - EntityLoadThroughDirective< - TViewerContext, - TFields3, - TFields4, - TID4, - TEntity4, - TPrivacyPolicy4, - TSelectedFields3, - TSelectedFields4 - >, - ], - queryContext?: EntityQueryContext, - ): Promise | null>; - - /** - * Load an associated entity by folding a sequence of EntityLoadThroughDirective. At each - * fold step, load an associated entity identified by a field value of the current fold value. - * @param loadDirectives - associated entity load directives instructing each step of the fold - * @param queryContext - query context in which to perform the loads - */ - async loadAssociatedEntityThroughAsync( - loadDirectives: EntityLoadThroughDirective[], - queryContext?: EntityQueryContext, - ): Promise> | null>; - - async loadAssociatedEntityThroughAsync( - loadDirectives: EntityLoadThroughDirective[], - queryContext?: EntityQueryContext, - ): Promise> | null> { - let currentEntity: ReadonlyEntity = this.entity; - for (const loadDirective of loadDirectives) { - const { - associatedEntityClass, - fieldIdentifyingAssociatedEntity, - associatedEntityLookupByField, - } = loadDirective; - let associatedEntityResult: Result> | null; - if (associatedEntityLookupByField) { - associatedEntityResult = await currentEntity - .associationLoader() - .loadAssociatedEntityByFieldEqualingAsync( - fieldIdentifyingAssociatedEntity, - associatedEntityClass, - associatedEntityLookupByField, - queryContext, - ); - } else { - const associatedEntityResultLocal = await currentEntity - .associationLoader() - .loadAssociatedEntityAsync( - fieldIdentifyingAssociatedEntity, - associatedEntityClass, - queryContext, - ); - - if (associatedEntityResultLocal.ok && associatedEntityResultLocal.value === null) { - associatedEntityResult = null; - } else { - associatedEntityResult = associatedEntityResultLocal; - } - } - - if (!associatedEntityResult) { - return null; - } - - if (!associatedEntityResult.ok) { - return result(associatedEntityResult.reason); - } - currentEntity = associatedEntityResult.value; - } - return result(currentEntity); - } -} - -/** - * Instruction for each step of a load-associated-through method. - */ -export interface EntityLoadThroughDirective< - TViewerContext extends ViewerContext, - TFields, - TAssociatedFields extends object, - TAssociatedID extends NonNullable, - TAssociatedEntity extends ReadonlyEntity< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedSelectedFields - >, - TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< - TAssociatedFields, - TAssociatedID, + withAuthorizationResults(): AuthorizationResultBasedEntityAssociationLoader< + TFields, + TID, TViewerContext, - TAssociatedEntity, - TAssociatedSelectedFields - >, - TSelectedFields extends keyof TFields = keyof TFields, - TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields, -> { - /** - * Class of entity to load at this step. - */ - associatedEntityClass: IEntityClass< - TAssociatedFields, - TAssociatedID, - TViewerContext, - TAssociatedEntity, - TAssociatedPrivacyPolicy, - TAssociatedSelectedFields - >; - - /** - * Field of the current entity with which to load an instance of associatedEntityClass. - */ - fieldIdentifyingAssociatedEntity: keyof Pick; - - /** - * Field by which to load the instance of associatedEntityClass. If not provided, the - * associatedEntityClass instance is fetched by its ID. - */ - associatedEntityLookupByField?: keyof Pick; + TEntity, + TSelectedFields + > { + return new AuthorizationResultBasedEntityAssociationLoader(this.entity); + } } diff --git a/packages/entity/src/__tests__/AuthorizationResultBasedEntityAssociationLoader-test.ts b/packages/entity/src/__tests__/AuthorizationResultBasedEntityAssociationLoader-test.ts new file mode 100644 index 00000000..8860d9d3 --- /dev/null +++ b/packages/entity/src/__tests__/AuthorizationResultBasedEntityAssociationLoader-test.ts @@ -0,0 +1,354 @@ +import { enforceAsyncResult } from '@expo/results'; +import { v4 as uuidv4 } from 'uuid'; + +import AuthorizationResultBasedEntityAssociationLoader from '../AuthorizationResultBasedEntityAssociationLoader'; +import { enforceResultsAsync } from '../entityUtils'; +import TestEntity from '../testfixtures/TestEntity'; +import TestEntity2 from '../testfixtures/TestEntity2'; +import TestViewerContext from '../testfixtures/TestViewerContext'; +import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; + +describe(AuthorizationResultBasedEntityAssociationLoader, () => { + describe('loadAssociatedEntityAsync', () => { + it('loads associated entities by ID and correctly handles a null value', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testOtherEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testOtherEntity.getID()) + .createAsync(), + ); + const loadedOther = await enforceAsyncResult( + testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityAsync('stringField', TestEntity), + ); + expect(loadedOther.getID()).toEqual(testOtherEntity.getID()); + + const loadedOther2 = await enforceAsyncResult( + testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityAsync('nullableField', TestEntity), + ); + expect(loadedOther2).toBeNull(); + }); + }); + + describe('loadManyAssociatedEntitiesAsync', () => { + it('loads many associated entities referencing this entity', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const testOtherEntity1 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testEntity.getID()) + .createAsync(), + ); + const testOtherEntity2 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testEntity.getID()) + .createAsync(), + ); + const loaded = await enforceResultsAsync( + testEntity + .associationLoader() + .withAuthorizationResults() + .loadManyAssociatedEntitiesAsync(TestEntity, 'stringField'), + ); + expect(loaded).toHaveLength(2); + expect(loaded.find((e) => e.getID() === testOtherEntity1.getID())).not.toBeUndefined(); + expect(loaded.find((e) => e.getID() === testOtherEntity2.getID())).not.toBeUndefined(); + }); + }); + + describe('loadAssociatedEntityByFieldEqualingAsync', () => { + it('loads associated entity by field equaling', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testOtherEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testOtherEntity.getID()) + .createAsync(), + ); + const loadedOtherResult = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField'); + expect(loadedOtherResult?.enforceValue().getID()).toEqual(testOtherEntity.getID()); + }); + + it('returns null when loading associated entities by field equaling a non existent association', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', uuidv4()) + .createAsync(), + ); + const loadedOtherResult = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField'); + expect(loadedOtherResult).toBeNull(); + }); + + it('returns null when load-by field is null', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', 'blah') + .createAsync(), + ); + const loadedOtherResult = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityByFieldEqualingAsync('nullableField', TestEntity, 'customIdField'); + expect(loadedOtherResult).toBeNull(); + }); + }); + + describe('loadManyAssociatedEntitiesByFieldEqualingAsync', () => { + it('loads many associated entities by field equaling', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const testOtherEntity1 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testEntity.getID()) + .createAsync(), + ); + const testOtherEntity2 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', testEntity.getID()) + .createAsync(), + ); + const loaded = await enforceResultsAsync( + testEntity + .associationLoader() + .withAuthorizationResults() + .loadManyAssociatedEntitiesByFieldEqualingAsync( + 'customIdField', + TestEntity, + 'stringField', + ), + ); + expect(loaded).toHaveLength(2); + expect(loaded.find((e) => e.getID() === testOtherEntity1.getID())).not.toBeUndefined(); + expect(loaded.find((e) => e.getID() === testOtherEntity2.getID())).not.toBeUndefined(); + }); + + it('returns empty results when field being queried by is null', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const loaded = await enforceResultsAsync( + testEntity + .associationLoader() + .withAuthorizationResults() + .loadManyAssociatedEntitiesByFieldEqualingAsync( + 'nullableField', + TestEntity, + 'stringField', + ), + ); + expect(loaded).toHaveLength(0); + }); + }); + + describe('loadAssociatedEntityThroughAsync', () => { + it('chain loads associated entities', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + const testEntity4 = await enforceAsyncResult( + TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + ); + const testEntity3 = await enforceAsyncResult( + TestEntity2.creator(viewerContext) + .withAuthorizationResults() + .setField('foreignKey', testEntity4.getID()) + .createAsync(), + ); + const testEntity2 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('testIndexedField', testEntity3.getID()) + .createAsync(), + ); + const testEntity = await enforceAsyncResult( + TestEntity2.creator(viewerContext) + .withAuthorizationResults() + .setField('foreignKey', testEntity2.getID()) + .createAsync(), + ); + + const loaded2Result = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + }, + ]); + expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID()); + + const loaded3Result = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + }, + { + associatedEntityClass: TestEntity2, + fieldIdentifyingAssociatedEntity: 'testIndexedField', + }, + ]); + expect(loaded3Result?.enforceValue().getID()).toEqual(testEntity3.getID()); + + const loaded4Result = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + }, + { + associatedEntityClass: TestEntity2, + fieldIdentifyingAssociatedEntity: 'testIndexedField', + }, + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + }, + ]); + expect(loaded4Result?.enforceValue().getID()).toEqual(testEntity4.getID()); + }); + + it('fails when chain loading associated entity fails', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const testEntity = await enforceAsyncResult( + TestEntity2.creator(viewerContext) + .withAuthorizationResults() + .setField('foreignKey', uuidv4()) + .createAsync(), + ); + + const loadResult = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + }, + ]); + expect(loadResult?.ok).toBe(false); + }); + + it('supports chain loading by field equality', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const fieldValue = uuidv4(); + const testEntity2 = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('stringField', fieldValue) + .createAsync(), + ); + const testEntity = await enforceAsyncResult( + TestEntity2.creator(viewerContext) + .withAuthorizationResults() + .setField('foreignKey', fieldValue) + .createAsync(), + ); + + const loaded2Result = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + associatedEntityLookupByField: 'stringField', + }, + ]); + expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID()); + }); + + it('returns null when chain loading by field equality returns null', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const testEntity = await enforceAsyncResult( + TestEntity2.creator(viewerContext) + .withAuthorizationResults() + .setField('foreignKey', uuidv4()) + .createAsync(), + ); + + const loaded2Result = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'foreignKey', + associatedEntityLookupByField: 'stringField', + }, + ]); + expect(loaded2Result).toBeNull(); + }); + + it('returns null when chain loading by field is null', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const testEntity = await enforceAsyncResult( + TestEntity.creator(viewerContext) + .withAuthorizationResults() + .setField('nullableField', null) + .createAsync(), + ); + + const loadedResult = await testEntity + .associationLoader() + .withAuthorizationResults() + .loadAssociatedEntityThroughAsync([ + { + associatedEntityClass: TestEntity, + fieldIdentifyingAssociatedEntity: 'nullableField', + }, + ]); + expect(loadedResult).toBeNull(); + }); + }); +}); diff --git a/packages/entity/src/__tests__/EnforcingEntityAssociationLoader-test.ts b/packages/entity/src/__tests__/EnforcingEntityAssociationLoader-test.ts new file mode 100644 index 00000000..ddafa2b0 --- /dev/null +++ b/packages/entity/src/__tests__/EnforcingEntityAssociationLoader-test.ts @@ -0,0 +1,288 @@ +import { result } from '@expo/results'; +import { mock, instance, when, anything } from 'ts-mockito'; + +import AuthorizationResultBasedEntityAssociationLoader from '../AuthorizationResultBasedEntityAssociationLoader'; +import EnforcingEntityAssociationLoader from '../EnforcingEntityAssociationLoader'; + +describe(EnforcingEntityAssociationLoader, () => { + describe('loadAssociatedEntityAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const rejection = new Error(); + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve(result(rejection)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityAssociationLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityAssociationLoader.loadAssociatedEntityAsync( + anything(), + anything(), + anything(), + ), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = {} as any; + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityAsync( + anything(), + anything(), + anything(), + ), + ).thenResolve(result(resolved)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadAssociatedEntityAsync(anything(), anything(), anything()), + ).resolves.toEqual(resolved); + }); + }); + + describe('loadManyAssociatedEntitiesAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const rejection = new Error(); + when( + nonEnforcingEntityAssociationLoaderMock.loadManyAssociatedEntitiesAsync( + anything(), + anything() as never, + anything(), + ), + ).thenResolve([result(rejection)]); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityAssociationLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityAssociationLoader.loadManyAssociatedEntitiesAsync( + anything(), + anything() as never, + anything(), + ), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = [] as any; + when( + nonEnforcingEntityAssociationLoaderMock.loadManyAssociatedEntitiesAsync( + anything(), + anything() as never, + anything(), + ), + ).thenResolve([result(resolved)]); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadManyAssociatedEntitiesAsync( + anything(), + anything() as never, + anything(), + ), + ).resolves.toEqual([resolved]); + }); + }); + + describe('loadAssociatedEntityByFieldEqualingAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const rejection = new Error(); + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).thenResolve(result(rejection)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityAssociationLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityAssociationLoader.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = {} as any; + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).thenResolve(result(resolved)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).resolves.toEqual(resolved); + }); + + it('returns null when result is successful but null', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = null; + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).thenResolve(resolved); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadAssociatedEntityByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).resolves.toEqual(resolved); + }); + }); + + describe('loadManyAssociatedEntitiesByFieldEqualingAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const rejection = new Error(); + when( + nonEnforcingEntityAssociationLoaderMock.loadManyAssociatedEntitiesByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).thenResolve([result(rejection)]); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityAssociationLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityAssociationLoader.loadManyAssociatedEntitiesByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = [] as any; + when( + nonEnforcingEntityAssociationLoaderMock.loadManyAssociatedEntitiesByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).thenResolve([result(resolved)]); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadManyAssociatedEntitiesByFieldEqualingAsync( + anything(), + anything(), + anything() as never, + anything(), + ), + ).resolves.toEqual([resolved]); + }); + }); + + describe('loadAssociatedEntityThroughAsync', () => { + it('throws when result is unsuccessful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const rejection = new Error(); + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityThroughAsync( + anything(), + anything(), + ), + ).thenResolve(result(rejection)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityAssociationLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityAssociationLoader.loadAssociatedEntityThroughAsync(anything(), anything()), + ).rejects.toThrow(rejection); + }); + + it('returns value when result is successful', async () => { + const nonEnforcingEntityAssociationLoaderMock = + mock>(); + const resolved = {} as any; + when( + nonEnforcingEntityAssociationLoaderMock.loadAssociatedEntityThroughAsync( + anything(), + anything(), + ), + ).thenResolve(result(resolved)); + const nonEnforcingEntityAssociationLoader = instance(nonEnforcingEntityAssociationLoaderMock); + const enforcingEntityLoader = new EnforcingEntityAssociationLoader( + nonEnforcingEntityAssociationLoader, + ); + await expect( + enforcingEntityLoader.loadAssociatedEntityThroughAsync(anything(), anything()), + ).resolves.toEqual(resolved); + }); + }); + + it('has the same method names as AuthorizationResultBasedEntityAssociationLoader', () => { + const enforcingLoaderProperties = Object.getOwnPropertyNames( + EnforcingEntityAssociationLoader.prototype, + ); + const nonEnforcingLoaderProperties = Object.getOwnPropertyNames( + AuthorizationResultBasedEntityAssociationLoader.prototype, + ); + + expect(enforcingLoaderProperties).toEqual(nonEnforcingLoaderProperties); + }); +}); diff --git a/packages/entity/src/__tests__/EntityAssociationLoader-test.ts b/packages/entity/src/__tests__/EntityAssociationLoader-test.ts index 81ea7546..e485a5eb 100644 --- a/packages/entity/src/__tests__/EntityAssociationLoader-test.ts +++ b/packages/entity/src/__tests__/EntityAssociationLoader-test.ts @@ -1,319 +1,30 @@ -import { enforceAsyncResult } from '@expo/results'; -import { v4 as uuidv4 } from 'uuid'; - +import AuthorizationResultBasedEntityAssociationLoader from '../AuthorizationResultBasedEntityAssociationLoader'; +import EnforcingEntityAssociationLoader from '../EnforcingEntityAssociationLoader'; import EntityAssociationLoader from '../EntityAssociationLoader'; -import { enforceResultsAsync } from '../entityUtils'; -import TestEntity from '../testfixtures/TestEntity'; -import TestEntity2 from '../testfixtures/TestEntity2'; -import TestViewerContext from '../testfixtures/TestViewerContext'; +import ViewerContext from '../ViewerContext'; +import SimpleTestEntity from '../testfixtures/SimpleTestEntity'; import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; describe(EntityAssociationLoader, () => { - describe('loadAssociatedEntityAsync', () => { - it('loads associated entities by ID and correctly handles a null value', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testOtherEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), - ); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testOtherEntity.getID()) - .createAsync(), - ); - const loadedOther = await enforceAsyncResult( - testEntity.associationLoader().loadAssociatedEntityAsync('stringField', TestEntity), - ); - expect(loadedOther.getID()).toEqual(testOtherEntity.getID()); - - const loadedOther2 = await enforceAsyncResult( - testEntity.associationLoader().loadAssociatedEntityAsync('nullableField', TestEntity), - ); - expect(loadedOther2).toBeNull(); - }); - }); - - describe('loadManyAssociatedEntitiesAsync', () => { - it('loads many associated entities referencing this entity', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), - ); - const testOtherEntity1 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testEntity.getID()) - .createAsync(), - ); - const testOtherEntity2 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testEntity.getID()) - .createAsync(), - ); - const loaded = await enforceResultsAsync( - testEntity.associationLoader().loadManyAssociatedEntitiesAsync(TestEntity, 'stringField'), - ); - expect(loaded).toHaveLength(2); - expect(loaded.find((e) => e.getID() === testOtherEntity1.getID())).not.toBeUndefined(); - expect(loaded.find((e) => e.getID() === testOtherEntity2.getID())).not.toBeUndefined(); - }); - }); - - describe('loadAssociatedEntityByFieldEqualingAsync', () => { - it('loads associated entity by field equaling', async () => { + describe('enforcing', () => { + it('creates a new EnforcingEntityLoader', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testOtherEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestEntity.creator(viewerContext).enforcing().createAsync(); + expect(testEntity.associationLoader().enforcing()).toBeInstanceOf( + EnforcingEntityAssociationLoader, ); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testOtherEntity.getID()) - .createAsync(), - ); - const loadedOtherResult = await testEntity - .associationLoader() - .loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField'); - expect(loadedOtherResult?.enforceValue().getID()).toEqual(testOtherEntity.getID()); - }); - - it('returns null when loading associated entities by field equaling a non existent association', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', uuidv4()) - .createAsync(), - ); - const loadedOtherResult = await testEntity - .associationLoader() - .loadAssociatedEntityByFieldEqualingAsync('stringField', TestEntity, 'customIdField'); - expect(loadedOtherResult).toBeNull(); - }); - - it('returns null when load-by field is null', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', 'blah') - .createAsync(), - ); - const loadedOtherResult = await testEntity - .associationLoader() - .loadAssociatedEntityByFieldEqualingAsync('nullableField', TestEntity, 'customIdField'); - expect(loadedOtherResult).toBeNull(); }); }); - describe('loadManyAssociatedEntitiesByFieldEqualingAsync', () => { - it('loads many associated entities by field equaling', async () => { + describe('withAuthorizationResults', () => { + it('creates a new AuthorizationResultBasedEntityAssociationLoader', async () => { const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), + const viewerContext = new ViewerContext(companionProvider); + const testEntity = await SimpleTestEntity.creator(viewerContext).enforcing().createAsync(); + expect(testEntity.associationLoader().withAuthorizationResults()).toBeInstanceOf( + AuthorizationResultBasedEntityAssociationLoader, ); - const testOtherEntity1 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testEntity.getID()) - .createAsync(), - ); - const testOtherEntity2 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', testEntity.getID()) - .createAsync(), - ); - const loaded = await enforceResultsAsync( - testEntity - .associationLoader() - .loadManyAssociatedEntitiesByFieldEqualingAsync( - 'customIdField', - TestEntity, - 'stringField', - ), - ); - expect(loaded).toHaveLength(2); - expect(loaded.find((e) => e.getID() === testOtherEntity1.getID())).not.toBeUndefined(); - expect(loaded.find((e) => e.getID() === testOtherEntity2.getID())).not.toBeUndefined(); - }); - - it('returns empty results when field being queried by is null', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), - ); - const loaded = await enforceResultsAsync( - testEntity - .associationLoader() - .loadManyAssociatedEntitiesByFieldEqualingAsync( - 'nullableField', - TestEntity, - 'stringField', - ), - ); - expect(loaded).toHaveLength(0); - }); - }); - - describe('loadAssociatedEntityThroughAsync', () => { - it('chain loads associated entities', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - const testEntity4 = await enforceAsyncResult( - TestEntity.creator(viewerContext).withAuthorizationResults().createAsync(), - ); - const testEntity3 = await enforceAsyncResult( - TestEntity2.creator(viewerContext) - .withAuthorizationResults() - .setField('foreignKey', testEntity4.getID()) - .createAsync(), - ); - const testEntity2 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('testIndexedField', testEntity3.getID()) - .createAsync(), - ); - const testEntity = await enforceAsyncResult( - TestEntity2.creator(viewerContext) - .withAuthorizationResults() - .setField('foreignKey', testEntity2.getID()) - .createAsync(), - ); - - const loaded2Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - }, - ]); - expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID()); - - const loaded3Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - }, - { - associatedEntityClass: TestEntity2, - fieldIdentifyingAssociatedEntity: 'testIndexedField', - }, - ]); - expect(loaded3Result?.enforceValue().getID()).toEqual(testEntity3.getID()); - - const loaded4Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - }, - { - associatedEntityClass: TestEntity2, - fieldIdentifyingAssociatedEntity: 'testIndexedField', - }, - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - }, - ]); - expect(loaded4Result?.enforceValue().getID()).toEqual(testEntity4.getID()); - }); - - it('fails when chain loading associated entity fails', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - - const testEntity = await enforceAsyncResult( - TestEntity2.creator(viewerContext) - .withAuthorizationResults() - .setField('foreignKey', uuidv4()) - .createAsync(), - ); - - const loadResult = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - }, - ]); - expect(loadResult?.ok).toBe(false); - }); - - it('supports chain loading by field equality', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - - const fieldValue = uuidv4(); - const testEntity2 = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('stringField', fieldValue) - .createAsync(), - ); - const testEntity = await enforceAsyncResult( - TestEntity2.creator(viewerContext) - .withAuthorizationResults() - .setField('foreignKey', fieldValue) - .createAsync(), - ); - - const loaded2Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - associatedEntityLookupByField: 'stringField', - }, - ]); - expect(loaded2Result?.enforceValue().getID()).toEqual(testEntity2.getID()); - }); - - it('returns null when chain loading by field equality returns null', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - - const testEntity = await enforceAsyncResult( - TestEntity2.creator(viewerContext) - .withAuthorizationResults() - .setField('foreignKey', uuidv4()) - .createAsync(), - ); - - const loaded2Result = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'foreignKey', - associatedEntityLookupByField: 'stringField', - }, - ]); - expect(loaded2Result).toBeNull(); - }); - - it('returns null when chain loading by field is null', async () => { - const companionProvider = createUnitTestEntityCompanionProvider(); - const viewerContext = new TestViewerContext(companionProvider); - - const testEntity = await enforceAsyncResult( - TestEntity.creator(viewerContext) - .withAuthorizationResults() - .setField('nullableField', null) - .createAsync(), - ); - - const loadedResult = await testEntity.associationLoader().loadAssociatedEntityThroughAsync([ - { - associatedEntityClass: TestEntity, - fieldIdentifyingAssociatedEntity: 'nullableField', - }, - ]); - expect(loadedResult).toBeNull(); }); }); }); diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 0e922ead..66c78d62 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -4,10 +4,13 @@ * @module @expo/entity */ +export { default as AuthorizationResultBasedEntityAssociationLoader } from './AuthorizationResultBasedEntityAssociationLoader'; +export * from './AuthorizationResultBasedEntityAssociationLoader'; export { default as AuthorizationResultBasedEntityLoader } from './AuthorizationResultBasedEntityLoader'; export * from './AuthorizationResultBasedEntityMutator'; export { default as ComposedEntityCacheAdapter } from './ComposedEntityCacheAdapter'; export { default as ComposedSecondaryEntityCache } from './ComposedSecondaryEntityCache'; +export { default as EnforcingEntityAssociationLoader } from './EnforcingEntityAssociationLoader'; export { default as EnforcingEntityCreator } from './EnforcingEntityCreator'; export { default as EnforcingEntityDeleter } from './EnforcingEntityDeleter'; export { default as EnforcingEntityLoader } from './EnforcingEntityLoader'; @@ -15,7 +18,6 @@ export { default as EnforcingEntityUpdater } from './EnforcingEntityUpdater'; export { default as Entity } from './Entity'; export * from './Entity'; export { default as EntityAssociationLoader } from './EntityAssociationLoader'; -export * from './EntityAssociationLoader'; export { default as EntityCompanion } from './EntityCompanion'; export * from './EntityCompanion'; export { default as EntityCompanionProvider } from './EntityCompanionProvider';