Skip to content

Commit

Permalink
[www] Add support for reference deletion
Browse files Browse the repository at this point in the history
  • Loading branch information
wschurman committed Jul 11, 2020
1 parent 9c92d10 commit 7f8b0fb
Show file tree
Hide file tree
Showing 13 changed files with 767 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -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<any, string, ViewerContext, any, any> {
protected readonly readRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly createRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly updateRules = [new AlwaysAllowPrivacyPolicyRule()];
protected readonly deleteRules = [new AlwaysAllowPrivacyPolicyRule()];
}

class ParentEntity extends Entity<ParentFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ParentFields,
string,
ViewerContext,
ParentEntity,
TestEntityPrivacyPolicy
> {
return parentEntityCompanion;
}
}

class ChildEntity extends Entity<ChildFields, string, ViewerContext> {
static getCompanionDefinition(): EntityCompanionDefinition<
ChildFields,
string,
ViewerContext,
ChildEntity,
TestEntityPrivacyPolicy
> {
return childEntityCompanion;
}
}

const parentEntityConfiguration = new EntityConfiguration<ParentFields>({
idField: 'id',
tableName: 'parents',
inboundEdgeEntities: [ChildEntity],
schema: {
id: new UUIDField({
columnName: 'id',
cache: true,
}),
},
databaseAdapterFlavor: DatabaseAdapterFlavor.POSTGRES,
cacheAdapterFlavor: CacheAdapterFlavor.REDIS,
});

const childEntityConfiguration = new EntityConfiguration<ChildFields>({
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<void> {
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<void> {
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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
EntityCompanionProvider,
CacheAdapterFlavor,
DatabaseAdapterFlavor,
NoCacheStubCacheAdapterProvider,
InMemoryFullCacheStubCacheAdapterProvider,
} from '@expo/entity';
import Knex from 'knex';

Expand All @@ -25,7 +25,7 @@ export const createKnexIntegrationTestEntityCompanionProvider = (
},
{
[CacheAdapterFlavor.REDIS]: {
cacheAdapterProvider: new NoCacheStubCacheAdapterProvider(),
cacheAdapterProvider: new InMemoryFullCacheStubCacheAdapterProvider(),
},
}
);
Expand Down
2 changes: 1 addition & 1 deletion packages/entity/src/EntityCompanion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export default class EntityCompanion<
tableDataCoordinator.dataManager
);
this.entityMutatorFactory = new EntityMutatorFactory(
tableDataCoordinator.entityConfiguration.idField,
tableDataCoordinator.entityConfiguration,
entityClass,
privacyPolicy,
this.entityLoaderFactory,
Expand Down
6 changes: 6 additions & 0 deletions packages/entity/src/EntityConfiguration.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,6 +13,7 @@ export default class EntityConfiguration<TFields> {
readonly cacheableKeys: ReadonlySet<keyof TFields>;
readonly cacheKeyVersion: number;

readonly inboundEdges: IEntityClass<any, any, any, any, any, any>[];
readonly schema: ReadonlyMap<keyof TFields, EntityFieldDefinition>;
readonly entityToDBFieldsKeyMapping: ReadonlyMap<keyof TFields, string>;
readonly dbToEntityFieldsKeyMapping: ReadonlyMap<string, keyof TFields>;
Expand All @@ -23,13 +25,15 @@ export default class EntityConfiguration<TFields> {
idField,
tableName,
schema,
inboundEdges = [],
cacheKeyVersion = 0,
databaseAdapterFlavor,
cacheAdapterFlavor,
}: {
idField: keyof TFields;
tableName: string;
schema: Record<keyof TFields, EntityFieldDefinition>;
inboundEdges?: IEntityClass<any, any, any, any, any, any>[];
cacheKeyVersion?: number;
databaseAdapterFlavor: DatabaseAdapterFlavor;
cacheAdapterFlavor: CacheAdapterFlavor;
Expand All @@ -40,6 +44,8 @@ export default class EntityConfiguration<TFields> {
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
Expand Down
78 changes: 77 additions & 1 deletion packages/entity/src/EntityFields.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,92 @@
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<any, any, any, any, any, any>;
/**
*
* @param columnName - Column name in the database.
* @param cache - Whether or not to cache loaded instances of the entity by this field. The column name is
* 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<any, any, any, any, any, any>;
}) {
this.columnName = columnName;
this.cache = cache;
this.association = association;
}
}

Expand Down
Loading

0 comments on commit 7f8b0fb

Please sign in to comment.