From 7f8b0fbbb97a90d78519f9bab2ef86391656539c Mon Sep 17 00:00:00 2001 From: William Schurman Date: Fri, 10 Jul 2020 16:25:58 -0700 Subject: [PATCH] [www] Add support for reference deletion --- .../EntityEdgesIntegration-test.ts | 184 ++++++++++ ...xIntegrationTestEntityCompanionProvider.ts | 4 +- packages/entity/src/EntityCompanion.ts | 2 +- packages/entity/src/EntityConfiguration.ts | 6 + packages/entity/src/EntityFields.ts | 78 +++- packages/entity/src/EntityMutator.ts | 120 +++++- packages/entity/src/EntityMutatorFactory.ts | 9 +- packages/entity/src/ReadonlyEntity.ts | 19 +- .../entity/src/__tests__/EntityEdges-test.ts | 347 ++++++++++++++++++ .../src/__tests__/EntityMutator-test.ts | 6 +- .../src/utils/testing/StubCacheAdapter.ts | 2 +- resources/jest-integration.config.js | 7 + resources/jest.config.js | 7 + 13 files changed, 767 insertions(+), 24 deletions(-) create mode 100644 packages/entity-database-adapter-knex/src/__integration-tests__/EntityEdgesIntegration-test.ts create mode 100644 packages/entity/src/__tests__/EntityEdges-test.ts diff --git a/packages/entity-database-adapter-knex/src/__integration-tests__/EntityEdgesIntegration-test.ts b/packages/entity-database-adapter-knex/src/__integration-tests__/EntityEdgesIntegration-test.ts new file mode 100644 index 00000000..5c8613c7 --- /dev/null +++ b/packages/entity-database-adapter-knex/src/__integration-tests__/EntityEdgesIntegration-test.ts @@ -0,0 +1,184 @@ +import { + EntityPrivacyPolicy, + ViewerContext, + AlwaysAllowPrivacyPolicyRule, + Entity, + EntityCompanionDefinition, + EntityConfiguration, + DatabaseAdapterFlavor, + CacheAdapterFlavor, + UUIDField, + EntityEdgeDeletionBehavior, +} from '@expo/entity'; +import Knex from 'knex'; + +import { createKnexIntegrationTestEntityCompanionProvider } from '../testfixtures/createKnexIntegrationTestEntityCompanionProvider'; + +interface ParentFields { + id: string; +} + +interface ChildFields { + id: string; + parent_id: string; +} + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy { + protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; +} + +class ParentEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + string, + ViewerContext, + ParentEntity, + TestEntityPrivacyPolicy + > { + return parentEntityCompanion; + } +} + +class ChildEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ChildFields, + string, + ViewerContext, + ChildEntity, + TestEntityPrivacyPolicy + > { + return childEntityCompanion; + } +} + +const parentEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'parents', + inboundEdgeEntities: [ChildEntity], + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, +}); + +const childEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'children', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + parent_id: new UUIDField({ + columnName: 'parent_id', + cache: true, + association: { + associatedEntityClass: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.INVALIDATE_CACHE, + }, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, +}); + +const parentEntityCompanion = new EntityCompanionDefinition({ + entityClass: ParentEntity, + entityConfiguration: parentEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, +}); + +const childEntityCompanion = new EntityCompanionDefinition({ + entityClass: ChildEntity, + entityConfiguration: childEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, +}); + +async function createOrTruncatePostgresTables(knex: Knex): Promise { + await knex.raw('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); // for uuid_generate_v4() + + await knex.schema.createTable('parents', (table) => { + table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); + }); + await knex.into('parents').truncate(); + + await knex.schema.createTable('children', (table) => { + table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary(); + table.uuid('parent_id').references('id').inTable('parents').onDelete('cascade').unique(); + }); + await knex.into('children').truncate(); +} + +async function dropPostgresTable(knex: Knex): Promise { + if (await knex.schema.hasTable('children')) { + await knex.schema.dropTable('children'); + } + if (await knex.schema.hasTable('parents')) { + await knex.schema.dropTable('parents'); + } +} + +describe('EntityMutator.processEntityDeletionForAllEntitiesReferencingEntity', () => { + let knexInstance: Knex; + + beforeAll(() => { + knexInstance = Knex({ + client: 'pg', + connection: { + user: process.env.PGUSER, + password: process.env.PGPASSWORD, + host: 'localhost', + port: parseInt(process.env.PGPORT!, 10), + database: process.env.PGDATABASE, + }, + }); + }); + + beforeEach(async () => { + await createOrTruncatePostgresTables(knexInstance); + }); + + afterAll(async () => { + await dropPostgresTable(knexInstance); + knexInstance.destroy(); + }); + + describe('EntityEdgeDeletionBehavior.INVALIDATE_CACHE', () => { + it('invalidates the cache', async () => { + const viewerContext = new ViewerContext( + createKnexIntegrationTestEntityCompanionProvider(knexInstance) + ); + + const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync(); + const child = await ChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .enforceCreateAsync(); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.not.toBeNull(); + await expect( + ChildEntity.loader(viewerContext) + .enforcing() + .loadByFieldEqualingAsync('parent_id', parent.getID()) + ).resolves.not.toBeNull(); + + await ParentEntity.enforceDeleteAsync(parent); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.toBeNull(); + + await expect( + ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()) + ).resolves.toBeNull(); + }); + }); +}); diff --git a/packages/entity-database-adapter-knex/src/testfixtures/createKnexIntegrationTestEntityCompanionProvider.ts b/packages/entity-database-adapter-knex/src/testfixtures/createKnexIntegrationTestEntityCompanionProvider.ts index 06464089..62946240 100644 --- a/packages/entity-database-adapter-knex/src/testfixtures/createKnexIntegrationTestEntityCompanionProvider.ts +++ b/packages/entity-database-adapter-knex/src/testfixtures/createKnexIntegrationTestEntityCompanionProvider.ts @@ -4,7 +4,7 @@ import { EntityCompanionProvider, CacheAdapterFlavor, DatabaseAdapterFlavor, - NoCacheStubCacheAdapterProvider, + InMemoryFullCacheStubCacheAdapterProvider, } from '@expo/entity'; import Knex from 'knex'; @@ -25,7 +25,7 @@ export const createKnexIntegrationTestEntityCompanionProvider = ( }, { [CacheAdapterFlavor.REDIS]: { - cacheAdapterProvider: new NoCacheStubCacheAdapterProvider(), + cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(), }, } ); diff --git a/packages/entity/src/EntityCompanion.ts b/packages/entity/src/EntityCompanion.ts index c564c121..f21a7887 100644 --- a/packages/entity/src/EntityCompanion.ts +++ b/packages/entity/src/EntityCompanion.ts @@ -67,7 +67,7 @@ export default class EntityCompanion< tableDataCoordinator.dataManager ); this.entityMutatorFactory = new EntityMutatorFactory( - tableDataCoordinator.entityConfiguration.idField, + tableDataCoordinator.entityConfiguration, entityClass, privacyPolicy, this.entityLoaderFactory, diff --git a/packages/entity/src/EntityConfiguration.ts b/packages/entity/src/EntityConfiguration.ts index 101dcce0..33dc0577 100644 --- a/packages/entity/src/EntityConfiguration.ts +++ b/packages/entity/src/EntityConfiguration.ts @@ -1,3 +1,4 @@ +import { IEntityClass } from './Entity'; import { DatabaseAdapterFlavor, CacheAdapterFlavor } from './EntityCompanionProvider'; import { EntityFieldDefinition } from './EntityFields'; import { mapMap, invertMap, reduceMap } from './utils/collections/maps'; @@ -12,6 +13,7 @@ export default class EntityConfiguration { readonly cacheableKeys: ReadonlySet; readonly cacheKeyVersion: number; + readonly inboundEdges: IEntityClass[]; readonly schema: ReadonlyMap; readonly entityToDBFieldsKeyMapping: ReadonlyMap; readonly dbToEntityFieldsKeyMapping: ReadonlyMap; @@ -23,6 +25,7 @@ export default class EntityConfiguration { idField, tableName, schema, + inboundEdges = [], cacheKeyVersion = 0, databaseAdapterFlavor, cacheAdapterFlavor, @@ -30,6 +33,7 @@ export default class EntityConfiguration { idField: keyof TFields; tableName: string; schema: Record; + inboundEdges?: IEntityClass[]; cacheKeyVersion?: number; databaseAdapterFlavor: DatabaseAdapterFlavor; cacheAdapterFlavor: CacheAdapterFlavor; @@ -40,6 +44,8 @@ export default class EntityConfiguration { this.databaseAdapterFlavor = databaseAdapterFlavor; this.cacheAdapterFlavor = cacheAdapterFlavor; + this.inboundEdges = inboundEdges; + // external schema is a Record to typecheck that all fields have FieldDefinitions, // but internally the most useful representation is a map for lookups // TODO(wschurman): validate schema diff --git a/packages/entity/src/EntityFields.ts b/packages/entity/src/EntityFields.ts index 1801e90b..ef0baf9e 100644 --- a/packages/entity/src/EntityFields.ts +++ b/packages/entity/src/EntityFields.ts @@ -1,6 +1,73 @@ +import { IEntityClass } from './Entity'; +import EntityPrivacyPolicy from './EntityPrivacyPolicy'; +import ReadonlyEntity from './ReadonlyEntity'; +import ViewerContext from './ViewerContext'; + +export enum EntityEdgeDeletionBehavior { + /** + * Default. This is useful when the deletion behavior is executed by the database and all that is needed is + * to invalidate related caches for consistency. + */ + INVALIDATE_CACHE, + + /** + * Delete the referencing entity when the referenced entity is deleted. + */ + CASCADE_DELETE, + + /** + * Set this field referencing the referenced entity to null when the referenced entity is deleted. + */ + SET_NULL, +} + +export interface EntityAssociationDefinition< + TViewerContext extends ViewerContext, + TAssociatedFields, + TAssociatedID, + TAssociatedEntity extends ReadonlyEntity< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedSelectedFields + >, + TAssociatedPrivacyPolicy extends EntityPrivacyPolicy< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedSelectedFields + >, + TAssociatedSelectedFields extends keyof TAssociatedFields = keyof TAssociatedFields +> { + /** + * Class of entity on the other end of this edge. + */ + associatedEntityClass: IEntityClass< + TAssociatedFields, + TAssociatedID, + TViewerContext, + TAssociatedEntity, + TAssociatedPrivacyPolicy, + TAssociatedSelectedFields + >; + + /** + * Field by which to load the instance of associatedEntityClass. If not provided, the + * associatedEntityClass instance is fetched by its ID. + */ + associatedEntityLookupByField?: keyof TAssociatedFields; + + /** + * What to do on the current entity when the entity on the other end of this edge is deleted. + */ + edgeDeletionBehavior?: EntityEdgeDeletionBehavior; +} + export abstract class EntityFieldDefinition { readonly columnName: string; readonly cache: boolean; + readonly association?: EntityAssociationDefinition; /** * * @param columnName - Column name in the database. @@ -8,9 +75,18 @@ export abstract class EntityFieldDefinition { * used to derive a cache key for the cache entry. If true, this column must be able uniquely * identify the entity. */ - constructor({ columnName, cache = false }: { columnName: string; cache?: boolean }) { + constructor({ + columnName, + cache = false, + association, + }: { + columnName: string; + cache?: boolean; + association?: EntityAssociationDefinition; + }) { this.columnName = columnName; this.cache = cache; + this.association = association; } } diff --git a/packages/entity/src/EntityMutator.ts b/packages/entity/src/EntityMutator.ts index 8ed2c2da..df114366 100644 --- a/packages/entity/src/EntityMutator.ts +++ b/packages/entity/src/EntityMutator.ts @@ -2,13 +2,17 @@ import { Result, asyncResult, result, enforceAsyncResult } from '@expo/results'; import _ from 'lodash'; import Entity, { IEntityClass } from './Entity'; +import EntityConfiguration from './EntityConfiguration'; import EntityDatabaseAdapter from './EntityDatabaseAdapter'; +import { EntityEdgeDeletionBehavior } from './EntityFields'; import EntityLoaderFactory from './EntityLoaderFactory'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import { EntityQueryContext } from './EntityQueryContext'; +import ReadonlyEntity from './ReadonlyEntity'; import ViewerContext from './ViewerContext'; import { timeAndLogMutationEventAsync } from './metrics/EntityMetricsUtils'; import IEntityMetricsAdapter, { EntityMetricsMutationType } from './metrics/IEntityMetricsAdapter'; +import { mapMapAsync } from './utils/collections/maps'; abstract class BaseMutator< TFields, @@ -27,7 +31,7 @@ abstract class BaseMutator< constructor( protected readonly viewerContext: TViewerContext, protected readonly queryContext: EntityQueryContext, - protected readonly idField: keyof TFields, + protected readonly entityConfiguration: EntityConfiguration, protected readonly entityClass: IEntityClass< TFields, TID, @@ -100,7 +104,7 @@ export class CreateMutator< private async createInternalAsync(): Promise> { const temporaryEntityForPrivacyCheck = new this.entityClass(this.viewerContext, ({ - [this.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID + [this.entityConfiguration.idField]: '00000000-0000-0000-0000-000000000000', // zero UUID ...this.fieldsForEntity, } as unknown) as TFields); @@ -152,7 +156,7 @@ export class UpdateMutator< constructor( viewerContext: TViewerContext, queryContext: EntityQueryContext, - idField: keyof TFields, + entityConfiguration: EntityConfiguration, entityClass: IEntityClass< TFields, TID, @@ -177,7 +181,7 @@ export class UpdateMutator< super( viewerContext, queryContext, - idField, + entityConfiguration, entityClass, privacyPolicy, entityLoaderFactory, @@ -234,7 +238,7 @@ export class UpdateMutator< const updateResult = await this.databaseAdapter.updateAsync( this.queryContext, - this.idField, + this.entityConfiguration.idField, entityAboutToBeUpdated.getID(), this.updatedFields ); @@ -269,7 +273,7 @@ export class DeleteMutator< constructor( viewerContext: TViewerContext, queryContext: EntityQueryContext, - idField: keyof TFields, + entityConfiguration: EntityConfiguration, entityClass: IEntityClass< TFields, TID, @@ -294,7 +298,7 @@ export class DeleteMutator< super( viewerContext, queryContext, - idField, + entityConfiguration, entityClass, privacyPolicy, entityLoaderFactory, @@ -331,11 +335,111 @@ export class DeleteMutator< const id = this.entity.getID(); - await this.databaseAdapter.deleteAsync(this.queryContext, this.idField, id); + await this.processEntityDeletionForAllEntitiesReferencingEntity(this.entity); + + await this.databaseAdapter.deleteAsync(this.queryContext, this.entityConfiguration.idField, id); const entityLoader = this.entityLoaderFactory.forLoad(this.viewerContext, this.queryContext); await entityLoader.invalidateFieldsAsync(this.entity.getAllDatabaseFields()); return result(); } + + /** + * Finds all entities referencing specified entity and either deletes, nullifies, or + * invalidates the cache depending on the {@link OnDeleteBehavior} of the field referencing + * the specified entity. + * + * @remarks + * This works by doing reverse fan-out queries: + * 1. Load all entity configurations of entity types that reference this type of entity + * 2. For each entity configuration, find all fields that contain edges to this type of entity + * 3. For each edge field, load all entities with an edge from target entity to this entity via that field + * 4. Perform desired OnDeleteBehavior for entities + * + * @param entity - entity to find all references to + */ + private async processEntityDeletionForAllEntitiesReferencingEntity( + entity: TEntity + ): Promise { + const inboundEdges = this.entityConfiguration.inboundEdges; + await Promise.all( + inboundEdges.map(async (entityClass) => { + return await mapMapAsync( + entityClass.getCompanionDefinition().entityConfiguration.schema, + async (fieldDefinition, fieldName) => { + const association = fieldDefinition.association; + if (!association) { + return; + } + + const associatedConfiguration = association.associatedEntityClass.getCompanionDefinition() + .entityConfiguration; + if (associatedConfiguration !== this.entityConfiguration) { + return; + } + + const associatedEntityLookupByField = association.associatedEntityLookupByField; + let inboundReferenceEntities: readonly ReadonlyEntity[]; + + const loaderFactory = this.viewerContext + .getViewerScopedEntityCompanionForClass(entityClass) + .getLoaderFactory(); + const mutatorFactory = this.viewerContext + .getViewerScopedEntityCompanionForClass(entityClass) + .getMutatorFactory(); + + if (associatedEntityLookupByField) { + inboundReferenceEntities = await loaderFactory + .forLoad(this.queryContext) + .enforcing() + .loadManyByFieldEqualingAsync( + fieldName, + entity.getField(associatedEntityLookupByField as any) + ); + } else { + inboundReferenceEntities = await loaderFactory + .forLoad(this.queryContext) + .enforcing() + .loadManyByFieldEqualingAsync(fieldName, entity.getID()); + } + + switch (association.edgeDeletionBehavior) { + case undefined: + case EntityEdgeDeletionBehavior.INVALIDATE_CACHE: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + loaderFactory + .forLoad(this.queryContext) + .invalidateFieldsAsync(inboundReferenceEntity.getAllDatabaseFields()) + ) + ); + break; + } + case EntityEdgeDeletionBehavior.SET_NULL: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + mutatorFactory + .forUpdate(inboundReferenceEntity, this.queryContext) + .setField(fieldName, null) + .enforceUpdateAsync() + ) + ); + break; + } + case EntityEdgeDeletionBehavior.CASCADE_DELETE: { + await Promise.all( + inboundReferenceEntities.map((inboundReferenceEntity) => + mutatorFactory + .forDelete(inboundReferenceEntity, this.queryContext) + .enforceDeleteAsync() + ) + ); + } + } + } + ); + }) + ); + } } diff --git a/packages/entity/src/EntityMutatorFactory.ts b/packages/entity/src/EntityMutatorFactory.ts index d0513669..956d7588 100644 --- a/packages/entity/src/EntityMutatorFactory.ts +++ b/packages/entity/src/EntityMutatorFactory.ts @@ -1,4 +1,5 @@ import Entity, { IEntityClass } from './Entity'; +import EntityConfiguration from './EntityConfiguration'; import EntityDatabaseAdapter from './EntityDatabaseAdapter'; import EntityLoaderFactory from './EntityLoaderFactory'; import { CreateMutator, UpdateMutator, DeleteMutator } from './EntityMutator'; @@ -25,7 +26,7 @@ export default class EntityMutatorFactory< TSelectedFields extends keyof TFields = keyof TFields > { constructor( - private readonly idField: keyof TFields, + private readonly entityConfiguration: EntityConfiguration, private readonly entityClass: IEntityClass< TFields, TID, @@ -60,7 +61,7 @@ export default class EntityMutatorFactory< return new CreateMutator( viewerContext, queryContext, - this.idField, + this.entityConfiguration, this.entityClass, this.privacyPolicy, this.entityLoaderFactory, @@ -82,7 +83,7 @@ export default class EntityMutatorFactory< return new UpdateMutator( existingEntity.getViewerContext(), queryContext, - this.idField, + this.entityConfiguration, this.entityClass, this.privacyPolicy, this.entityLoaderFactory, @@ -104,7 +105,7 @@ export default class EntityMutatorFactory< return new DeleteMutator( existingEntity.getViewerContext(), queryContext, - this.idField, + this.entityConfiguration, this.entityClass, this.privacyPolicy, this.entityLoaderFactory, diff --git a/packages/entity/src/ReadonlyEntity.ts b/packages/entity/src/ReadonlyEntity.ts index 3e17aa3f..5c47bde3 100644 --- a/packages/entity/src/ReadonlyEntity.ts +++ b/packages/entity/src/ReadonlyEntity.ts @@ -3,6 +3,7 @@ import { pick } from 'lodash'; import { IEntityClass } from './Entity'; import EntityAssociationLoader from './EntityAssociationLoader'; +import { EntityCompanionDefinition } from './EntityCompanionProvider'; import EntityLoader from './EntityLoader'; import EntityPrivacyPolicy from './EntityPrivacyPolicy'; import { EntityQueryContext, EntityTransactionalQueryContext } from './EntityQueryContext'; @@ -36,14 +37,24 @@ export default abstract class ReadonlyEntity< private readonly viewerContext: TViewerContext, private readonly databaseFields: Readonly ) { - const idField = (this.constructor as any).getCompanionDefinition().entityConfiguration - .idField as keyof Pick; + const companionDefinition = (this + .constructor as any).getCompanionDefinition() as EntityCompanionDefinition< + TFields, + TID, + TViewerContext, + this, + EntityPrivacyPolicy, + TSelectedFields + >; + const idField = companionDefinition.entityConfiguration.idField as keyof Pick< + TFields, + TSelectedFields + >; const id = databaseFields[idField]; invariant(id, 'must provide ID to create an entity'); this.id = id as any; - const entitySelectedFields = (this.constructor as any).getCompanionDefinition() - .entitySelectedFields as (keyof TFields)[]; + const entitySelectedFields = companionDefinition.entitySelectedFields as (keyof TFields)[]; this.rawFields = pick(databaseFields, entitySelectedFields); } diff --git a/packages/entity/src/__tests__/EntityEdges-test.ts b/packages/entity/src/__tests__/EntityEdges-test.ts new file mode 100644 index 00000000..29562cb1 --- /dev/null +++ b/packages/entity/src/__tests__/EntityEdges-test.ts @@ -0,0 +1,347 @@ +import Entity from '../Entity'; +import { + DatabaseAdapterFlavor, + CacheAdapterFlavor, + EntityCompanionDefinition, +} from '../EntityCompanionProvider'; +import EntityConfiguration from '../EntityConfiguration'; +import { UUIDField, EntityEdgeDeletionBehavior } from '../EntityFields'; +import EntityPrivacyPolicy from '../EntityPrivacyPolicy'; +import { CacheStatus } from '../internal/ReadThroughEntityCache'; +import AlwaysAllowPrivacyPolicyRule from '../rules/AlwaysAllowPrivacyPolicyRule'; +import TestViewerContext from '../testfixtures/TestViewerContext'; +import { InMemoryFullCacheStubCacheAdapter } from '../utils/testing/StubCacheAdapter'; +import { createUnitTestEntityCompanionProvider } from '../utils/testing/createUnitTestEntityCompanionProvider'; + +interface ParentFields { + id: string; +} + +interface ChildFields { + id: string; + parent_id: string; +} + +class TestEntityPrivacyPolicy extends EntityPrivacyPolicy< + any, + string, + TestViewerContext, + any, + any +> { + protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()]; + protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()]; +} + +describe('EntityMutator.processEntityDeletionForAllEntitiesReferencingEntity', () => { + describe('OnDeleteBehavior.DELETE', () => { + class ParentEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + string, + TestViewerContext, + ParentEntity, + TestEntityPrivacyPolicy + > { + return parentEntityCompanion; + } + } + + class ChildEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ChildFields, + string, + TestViewerContext, + ChildEntity, + TestEntityPrivacyPolicy + > { + return childEntityCompanion; + } + } + + const parentEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'parents', + inboundEdges: [ChildEntity], + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const childEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'children', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + parent_id: new UUIDField({ + columnName: 'parent_id', + association: { + associatedEntityClass: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.CASCADE_DELETE, + }, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const parentEntityCompanion = new EntityCompanionDefinition({ + entityClass: ParentEntity, + entityConfiguration: parentEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + const childEntityCompanion = new EntityCompanionDefinition({ + entityClass: ChildEntity, + entityConfiguration: childEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + it('deletes', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync(); + const child = await ChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .enforceCreateAsync(); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.not.toBeNull(); + await expect( + ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()) + ).resolves.not.toBeNull(); + + await ParentEntity.enforceDeleteAsync(parent); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.toBeNull(); + await expect( + ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()) + ).resolves.toBeNull(); + }); + }); + + describe('OnDeleteBehavior.SET_NULL', () => { + class ParentEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + string, + TestViewerContext, + ParentEntity, + TestEntityPrivacyPolicy + > { + return parentEntityCompanion; + } + } + + class ChildEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ChildFields, + string, + TestViewerContext, + ChildEntity, + TestEntityPrivacyPolicy + > { + return childEntityCompanion; + } + } + + const parentEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'parents', + inboundEdges: [ChildEntity], + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const childEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'children', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + parent_id: new UUIDField({ + columnName: 'parent_id', + association: { + associatedEntityClass: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.SET_NULL, + }, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const parentEntityCompanion = new EntityCompanionDefinition({ + entityClass: ParentEntity, + entityConfiguration: parentEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + const childEntityCompanion = new EntityCompanionDefinition({ + entityClass: ChildEntity, + entityConfiguration: childEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + it('sets null', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync(); + const child = await ChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .enforceCreateAsync(); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.not.toBeNull(); + await expect( + ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()) + ).resolves.not.toBeNull(); + + await ParentEntity.enforceDeleteAsync(parent); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.toBeNull(); + + const loadedChild = await ChildEntity.loader(viewerContext) + .enforcing() + .loadByIDAsync(child.getID()); + expect(loadedChild.getField('parent_id')).toBeNull(); + }); + }); + + describe('OnDeleteBehavior.INVALIDATE_CACHE', () => { + class ParentEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ParentFields, + string, + TestViewerContext, + ParentEntity, + TestEntityPrivacyPolicy + > { + return parentEntityCompanion; + } + } + + class ChildEntity extends Entity { + static getCompanionDefinition(): EntityCompanionDefinition< + ChildFields, + string, + TestViewerContext, + ChildEntity, + TestEntityPrivacyPolicy + > { + return childEntityCompanion; + } + } + + const parentEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'parents', + inboundEdges: [ChildEntity], + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const childEntityConfiguration = new EntityConfiguration({ + idField: 'id', + tableName: 'children', + schema: { + id: new UUIDField({ + columnName: 'id', + cache: true, + }), + parent_id: new UUIDField({ + columnName: 'parent_id', + cache: true, + association: { + associatedEntityClass: ParentEntity, + edgeDeletionBehavior: EntityEdgeDeletionBehavior.INVALIDATE_CACHE, + }, + }), + }, + databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES, + cacheAdapterFlavor: CacheAdapterFlavor.REDIS, + }); + + const parentEntityCompanion = new EntityCompanionDefinition({ + entityClass: ParentEntity, + entityConfiguration: parentEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + const childEntityCompanion = new EntityCompanionDefinition({ + entityClass: ChildEntity, + entityConfiguration: childEntityConfiguration, + privacyPolicyClass: TestEntityPrivacyPolicy, + }); + + it('invalidates the cache', async () => { + const companionProvider = createUnitTestEntityCompanionProvider(); + const viewerContext = new TestViewerContext(companionProvider); + + const parent = await ParentEntity.creator(viewerContext).enforceCreateAsync(); + const child = await ChildEntity.creator(viewerContext) + .setField('parent_id', parent.getID()) + .enforceCreateAsync(); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.not.toBeNull(); + await expect( + ChildEntity.loader(viewerContext) + .enforcing() + .loadByFieldEqualingAsync('parent_id', parent.getID()) + ).resolves.not.toBeNull(); + + const cacheAdapter = viewerContext.getViewerScopedEntityCompanionForClass(ChildEntity)[ + 'entityCompanion' + ]['tableDataCoordinator']['cacheAdapter'] as InMemoryFullCacheStubCacheAdapter; + const cachedBefore = await cacheAdapter.loadManyAsync('parent_id', [parent.getID()]); + expect(cachedBefore.get(parent.getID())?.status).toEqual(CacheStatus.HIT); + + await ParentEntity.enforceDeleteAsync(parent); + + const cachedAfter = await cacheAdapter.loadManyAsync('parent_id', [parent.getID()]); + expect(cachedAfter.get(parent.getID())?.status).toEqual(CacheStatus.MISS); + + await expect( + ParentEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(parent.getID()) + ).resolves.toBeNull(); + + await expect( + ChildEntity.loader(viewerContext).enforcing().loadByIDNullableAsync(child.getID()) + ).resolves.not.toBeNull(); + }); + }); +}); diff --git a/packages/entity/src/__tests__/EntityMutator-test.ts b/packages/entity/src/__tests__/EntityMutator-test.ts index 4213d11a..81f2db91 100644 --- a/packages/entity/src/__tests__/EntityMutator-test.ts +++ b/packages/entity/src/__tests__/EntityMutator-test.ts @@ -80,7 +80,7 @@ const createEntityMutatorFactory = ( dataManager ); const entityMutatorFactory = new EntityMutatorFactory( - testEntityConfiguration.idField, + testEntityConfiguration, TestEntity, privacyPolicy, entityLoaderFactory, @@ -291,7 +291,7 @@ describe(EntityMutatorFactory, () => { ).thenReject(rejectionError); const entityMutatorFactory = new EntityMutatorFactory( - simpleTestEntityConfiguration.idField, + simpleTestEntityConfiguration, SimpleTestEntity, instance(privacyPolicyMock), entityLoaderFactory, @@ -355,7 +355,7 @@ describe(EntityMutatorFactory, () => { ); const entityMutatorFactory = new EntityMutatorFactory( - simpleTestEntityConfiguration.idField, + simpleTestEntityConfiguration, SimpleTestEntity, privacyPolicy, entityLoaderFactory, diff --git a/packages/entity/src/utils/testing/StubCacheAdapter.ts b/packages/entity/src/utils/testing/StubCacheAdapter.ts index 21f5ab85..e367ef7f 100644 --- a/packages/entity/src/utils/testing/StubCacheAdapter.ts +++ b/packages/entity/src/utils/testing/StubCacheAdapter.ts @@ -57,7 +57,7 @@ export class InMemoryFullCacheStubCacheAdapterProvider implements IEntityCacheAd } } -class InMemoryFullCacheStubCacheAdapter extends EntityCacheAdapter { +export class InMemoryFullCacheStubCacheAdapter extends EntityCacheAdapter { constructor( entityConfiguration: EntityConfiguration, readonly cache: Map> diff --git a/resources/jest-integration.config.js b/resources/jest-integration.config.js index 3dd7fcbd..4165e238 100644 --- a/resources/jest-integration.config.js +++ b/resources/jest-integration.config.js @@ -3,4 +3,11 @@ module.exports = { testMatch: ['**/__integration-tests__/**/*-test.ts'], coveragePathIgnorePatterns: ['testfixtures'], coverageDirectory: 'coverage-integration', + globals: { + 'ts-jest': { + diagnostics: { + warnOnly: true, + }, + }, + }, }; diff --git a/resources/jest.config.js b/resources/jest.config.js index c42a1408..d630b37b 100644 --- a/resources/jest.config.js +++ b/resources/jest.config.js @@ -2,4 +2,11 @@ module.exports = { preset: 'ts-jest', testMatch: ['**/__tests__/**/*-test.ts'], coveragePathIgnorePatterns: ['testfixtures'], + globals: { + 'ts-jest': { + diagnostics: { + warnOnly: true, + }, + }, + }, };