From 51999961c3bb53df38f8122c764e1cc854a8ef62 Mon Sep 17 00:00:00 2001 From: getlarge Date: Mon, 11 May 2020 16:09:14 +0200 Subject: [PATCH] Add Subscription class and use it in Entity in engine --- src/engine/entity/Entity.ts | 73 +++- src/engine/subscription/Subscription.spec.ts | 351 ++++++++++++++++++ src/engine/subscription/Subscription.ts | 343 +++++++++++++++++ .../__snapshots__/Subscription.spec.ts.snap | 25 ++ 4 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 src/engine/subscription/Subscription.spec.ts create mode 100644 src/engine/subscription/Subscription.ts create mode 100644 src/engine/subscription/__snapshots__/Subscription.spec.ts.snap diff --git a/src/engine/entity/Entity.ts b/src/engine/entity/Entity.ts index 6b3bef49..7a757341 100644 --- a/src/engine/entity/Entity.ts +++ b/src/engine/entity/Entity.ts @@ -21,6 +21,12 @@ import { processEntityPermissions, } from '../permission/Permission'; +import { + Subscription, + defaultEntitySubscription, + processEntitySubscriptions, +} from '../subscription/Subscription'; + import { DataType, isDataType, DataTypeFunction } from '../datatype/DataType'; import { isStorageType } from '../storage/StorageType'; import { StorageTypeNull } from '../storage/StorageTypeNull'; @@ -66,6 +72,7 @@ export type EntitySetup = { // improve typings ? mutations?: any; permissions?: any; + subscriptions?: any; states?: any; preProcessor?: Function; postProcessor?: Function; @@ -84,6 +91,7 @@ export class Entity { indexes?: any; mutations?: any; permissions?: any; + subscriptions?: any; states?: any; preProcessor?: Function; postProcessor?: Function; @@ -95,6 +103,7 @@ export class Entity { private referencedByEntities: any; private _indexes: any; private _mutations: any; + private _subscriptions: any; private _states: any; private _permissions: any; private _defaultPermissions: any; @@ -122,6 +131,7 @@ export class Entity { indexes, mutations, permissions, + subscriptions, states, preProcessor, postProcessor, @@ -171,6 +181,7 @@ export class Entity { this.referencedByEntities = []; this._indexes = indexes; this._mutations = mutations; + this._subscriptions = subscriptions; this._states = states; this._permissions = permissions; this._preFilters = preFilters; @@ -285,7 +296,7 @@ export class Entity { return this.mutations; } - getMutationByName(name) { + getMutationByName(name: string) { const mutations = this.getMutations(); return mutations @@ -335,6 +346,63 @@ export class Entity { return null; } + _getDefaultSubscriptions() { + const nonSystemAttributeNames = []; + + mapOverProperties(this.getAttributes(), (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + nonSystemAttributeNames.push(attributeName); + } + }); + + const subscriptions = {}; + + defaultEntitySubscription.map(defaultSubscription => { + const key = `${defaultSubscription.name}Subscription`; + + subscriptions[key] = new Subscription({ + name: defaultSubscription.name, + type: defaultSubscription.type, + description: defaultSubscription.description(this.name), + attributes: nonSystemAttributeNames, + }); + }); + + return subscriptions; + } + + _processSubscriptions() { + let subscriptions; + + if (!this._subscriptions) { + subscriptions = Object.values(this._getDefaultSubscriptions()); + } else { + subscriptions = isFunction(this._subscriptions) + ? this._subscriptions(this._getDefaultSubscriptions()) + : this._subscriptions; + } + + return processEntitySubscriptions(this, subscriptions); + } + + getSubscriptions() { + if (this.subscriptions) { + return this.subscriptions; + } + + // this.getStates(); + this.subscriptions = this._processSubscriptions(); + return this.subscriptions; + } + + getSubscriptionByName(name: string) { + const subscriptions = this.getSubscriptions(); + + return subscriptions + ? subscriptions.find(subscription => String(subscription) === name) + : null; + } + getStates() { if (!this._states || this.states) { return this.states; @@ -730,6 +798,8 @@ export class Entity { } }); } + + // todo subscription } } @@ -758,6 +828,7 @@ export class Entity { } this.getMutations(); + // this.getSubscriptions(); this.permissions = this._processPermissions(); this._generatePermissionDescriptions(); return this.permissions; diff --git a/src/engine/subscription/Subscription.spec.ts b/src/engine/subscription/Subscription.spec.ts new file mode 100644 index 00000000..2edd3d48 --- /dev/null +++ b/src/engine/subscription/Subscription.spec.ts @@ -0,0 +1,351 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { + Subscription, + isSubscription, + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, + processEntitySubscriptions, +} from './Subscription'; +import { Entity } from '../entity/Entity'; +import { DataTypeString } from '../datatype/dataTypes'; +import { passOrThrow } from '../util'; + +describe('Mutation', () => { + const entity = new Entity({ + name: 'SomeEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + anotherAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + }, + }); + + it('should have a name', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription(); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a type', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a valid type', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: 12346, + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a description', () => { + function fn() { + // eslint-disable-next-line no-new + new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + }); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should have a list of default attributes', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'subscribe the world', + }); + + processEntitySubscriptions(entity, [subscription]); + const defaultAttributes = subscription.attributes; + + const expectedAttributes = ['someAttribute', 'anotherAttribute']; + + expect(defaultAttributes).toEqual(expectedAttributes); + }); + + it('should have a list of valid attribute names', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'mutate the world', + attributes: ['anything', { foo: 'bar' }], + }); + + function fn() { + processEntitySubscriptions(entity, [subscription]); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should allow an empty attributes list for UPDATE type subscriptions', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'mutate the world', + attributes: [], + }); + + processEntitySubscriptions(entity, [subscription]); + expect(subscription.attributes).toEqual([]); + }); + + it('should allow an empty attributes list for DELETE type subscriptions', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_DELETE, + description: 'mutate the world', + attributes: [], + }); + + processEntitySubscriptions(entity, [subscription]); + expect(subscription.attributes).not.toBeDefined(); + }); + + it('should have a list of unique attribute names', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_CREATE, + description: 'subscribe the world', + attributes: ['anything', 'anything'], + }); + + function fn() { + processEntitySubscriptions(entity, [subscription]); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it("should return it's name", () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'mutate the world', + attributes: ['anything'], + }); + + expect(subscription.name).toBe('example'); + expect(String(subscription)).toBe('example'); + }); + + // it('should have a valid preProcessor function', () => { + // function fn() { + // // eslint-disable-next-line no-new + // new Subscription({ + // name: 'example', + // type: SUBSCRIPTION_TYPE_CREATE, + // description: 'mutate the world', + // attributes: ['anything'], + // preProcessor: 'not-a-function', + // }); + // } + + // expect(fn).toThrowErrorMatchingSnapshot(); + // }); + + // it('should have a valid postProcessor function', () => { + // function fn() { + // // eslint-disable-next-line no-new + // new Subscription({ + // name: 'example', + // type: SUBSCRIPTION_TYPE_CREATE, + // description: 'mutate the world', + // attributes: ['anything'], + // postProcessor: 'not-a-function', + // }); + // } + + // expect(fn).toThrowErrorMatchingSnapshot(); + // }); + + describe('isSubscription', () => { + const subscription = new Subscription({ + name: 'example', + type: SUBSCRIPTION_TYPE_UPDATE, + description: 'mutate the world', + attributes: ['anything'], + // preProcessor() {}, + // postProcessor() {}, + }); + + it('should recognize objects of type Subscription', () => { + function fn() { + passOrThrow( + isSubscription(subscription), + () => 'This error will never happen', + ); + } + + expect(fn).not.toThrow(); + }); + + it('should recognize non-Subscription objects', () => { + function fn() { + passOrThrow( + isSubscription({}) || + isSubscription(function test() {}) || + isSubscription(Error), + () => 'Not a Subscription object', + ); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + }); + + describe('processEntitySubscriptions', () => { + // const subscriptionTypeCreateDefinition = { + // type: SUBSCRIPTION_TYPE_CREATE, + // name: 'build', + // description: 'on built item', + // attributes: ['someAttribute'], + // }; + + // const subscriptionTypeUpdateDefinition = { + // type: SUBSCRIPTION_TYPE_UPDATE, + // name: 'change', + // description: 'on changed item', + // attributes: ['id', 'someAttribute'], + // }; + + // const subscriptionTypeDeleteDefinition = { + // type: SUBSCRIPTION_TYPE_DELETE, + // name: 'drop', + // description: 'on dropped item', + // attributes: ['id'], + // }; + + it('should throw if provided with an invalid list of subscriptions', () => { + const subscriptions = { + foo: [{}], + }; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if provided with an invalid subscription', () => { + const subscriptions = [{ foo: 'bar' }]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if required attribute (without defaultValue) is missing in CREATE type subscriptions', () => { + function fn() { + const otherEntity = new Entity({ + name: 'SomeEntityName', + description: 'Just some description', + attributes: { + someAttribute: { + type: DataTypeString, + description: 'Just some description', + }, + neededAttribute: { + type: DataTypeString, + description: 'This is important', + required: true, + }, + }, + subscriptions: [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'build', + description: 'build item', + attributes: ['someAttribute'], + }), + ], + }); + + otherEntity.getMutationByName('build'); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw on duplicate subscription names', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'build', + description: 'build item', + attributes: ['someAttribute'], + }), + new Subscription({ + type: SUBSCRIPTION_TYPE_DELETE, + name: 'build', + description: 'build item', + attributes: ['someAttribute'], + }), + ]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should throw if unknown attributes are used', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_CREATE, + name: 'build', + description: 'build item', + attributes: ['doesNotExist'], + }), + ]; + + function fn() { + processEntitySubscriptions(entity, subscriptions); + } + + expect(fn).toThrowErrorMatchingSnapshot(); + }); + + it('should allow for empty attribute lists on DELETE type subscriptions', () => { + const subscriptions = [ + new Subscription({ + type: SUBSCRIPTION_TYPE_DELETE, + name: 'drop', + description: 'drop item', + attributes: [], + }), + ]; + + processEntitySubscriptions(entity, subscriptions); + }); + }); +}); diff --git a/src/engine/subscription/Subscription.ts b/src/engine/subscription/Subscription.ts new file mode 100644 index 00000000..1271cd7b --- /dev/null +++ b/src/engine/subscription/Subscription.ts @@ -0,0 +1,343 @@ +import { uniq } from 'lodash'; +import { + passOrThrow, + isArray, + // isFunction, + mapOverProperties, +} from '../util'; + +import { Entity } from '../entity/Entity'; + +export const SUBSCRIPTION_TYPE_CREATE = 'onCreate'; +export const SUBSCRIPTION_TYPE_UPDATE = 'onUpdate'; +export const SUBSCRIPTION_TYPE_DELETE = 'onDelete'; + +export const subscriptionTypes = [ + SUBSCRIPTION_TYPE_CREATE, + SUBSCRIPTION_TYPE_UPDATE, + SUBSCRIPTION_TYPE_DELETE, +]; + +export const defaultEntitySubscription = [ + { + name: 'onCreate', + type: SUBSCRIPTION_TYPE_CREATE, + description: (typeName: string) => + `Watch a new **\`${typeName}\`** creation`, + hasAttributes: true, + }, + { + name: 'onUpdate', + type: SUBSCRIPTION_TYPE_UPDATE, + description: (typeName: string) => + `Watch a single **\`${typeName}\`** update using its node ID and a data patch`, + hasAttributes: true, + }, + { + name: 'onDelete', + description: (typeName: string) => + `Watch a single **\`${typeName}\`** deletion using its node ID`, + type: SUBSCRIPTION_TYPE_DELETE, + }, +]; + +export type SubscriptionSetup = { + name?: string; + type?: string; + description?: string; + attributes?: string[]; + // preProcessor?: Function; + // postProcessor?: Function; + // fromState?: string | string[]; + // toState?: string | string[]; +}; + +export class Subscription { + name: string; + type: string; + description: string; + attributes: string[]; + // fromState: string | string[]; + // toState: string | string[]; + + // preProcessor: Function; + // postProcessor: Function; + + isTypeCreate?: boolean; + isTypeDelete?: boolean; + needsInstance?: boolean; + ignoreRequired?: boolean; + isTypeUpdate?: boolean; + + constructor(setup: SubscriptionSetup = {} as SubscriptionSetup) { + const { + name, + type, + description, + attributes, + // preProcessor, + // postProcessor, + // fromState, + // toState, + } = setup; + + passOrThrow(name, () => 'Missing subscription name'); + passOrThrow(type, () => `Missing type for subscription '${name}'`); + passOrThrow( + subscriptionTypes.indexOf(type) >= 0, + () => + `Unknown subscription type '${type}' used, try one of these: '${subscriptionTypes.join( + ', ', + )}'`, + ); + + passOrThrow( + description, + () => `Missing description for subscription '${name}'`, + ); + + this.name = name; + this.type = type; + this.description = description; + + if ( + this.type === SUBSCRIPTION_TYPE_CREATE || + this.type === SUBSCRIPTION_TYPE_UPDATE + ) { + this.attributes = attributes; + } + + if (this.type === SUBSCRIPTION_TYPE_CREATE) { + this.isTypeCreate = true; + } + + if (this.type === SUBSCRIPTION_TYPE_UPDATE) { + this.needsInstance = true; + this.ignoreRequired = true; + this.isTypeUpdate = true; + } + + if (this.type === SUBSCRIPTION_TYPE_DELETE) { + this.needsInstance = true; + this.isTypeDelete = true; + } + + // if (preProcessor) { + // passOrThrow( + // isFunction(preProcessor), + // () => `preProcessor of subscription '${name}' needs to be a valid function`, + // ); + + // this.preProcessor = preProcessor; + // } + + // if (postProcessor) { + // passOrThrow( + // isFunction(postProcessor), + // () => + // `postProcessor of subscription '${name}' needs to be a valid function`, + // ); + + // this.postProcessor = postProcessor; + // } + + // if (fromState) { + // passOrThrow( + // this.type !== SUBSCRIPTION_TYPE_CREATE, + // () => + // `Subscription '${this.name}' cannot define fromState as it is a 'onCreate' type subscription`, + // ); + + // passOrThrow( + // typeof fromState === 'string' || isArray(fromState), + // () => + // `fromState in subscription '${name}' needs to be the name of a state or a list of state names as a precondition to the subscription`, + // ); + + // if (this.type !== SUBSCRIPTION_TYPE_DELETE) { + // passOrThrow( + // toState, + // () => + // `Subscription '${this.name}' has a fromState defined but misses a toState definition`, + // ); + // } + + // this.fromState = fromState; + // } + + // if (toState) { + // passOrThrow( + // this.type !== SUBSCRIPTION_TYPE_DELETE, + // () => + // `Subscription '${this.name}' cannot define toState as it is a 'onDelete' type subscription`, + // ); + + // passOrThrow( + // typeof toState === 'string' || isArray(toState), + // () => + // `toState in subscription '${this.name}' needs to be the name of a state or a list of state names the subscription can transition to`, + // ); + + // if (this.type !== SUBSCRIPTION_TYPE_CREATE) { + // passOrThrow( + // fromState, + // () => + // `Subscription '${this.name}' has a toState defined but misses a fromState definition`, + // ); + // } + + // this.toState = toState; + // } + } + + toString() { + return this.name; + } +} + +export const isSubscription = (obj: any) => { + return obj instanceof Subscription; +}; + +export const processEntitySubscriptions = ( + entity: Entity, + subscriptions: Subscription[], +) => { + passOrThrow( + isArray(subscriptions), + () => + `Entity '${entity.name}' subscriptions definition needs to be an array of subscriptions`, + ); + + subscriptions.map((subscription, idx) => { + passOrThrow( + isSubscription(subscription), + () => + `Invalid subscription definition for entity '${entity.name}' at position '${idx}'`, + ); + }); + + const entityAttributes = entity.getAttributes(); + // const entityStates = entity.getStates(); + + const requiredAttributeNames = []; + + mapOverProperties(entityAttributes, (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + if (attribute.required && !attribute.defaultValue) { + requiredAttributeNames.push(attributeName); + } + } + }); + + const subscriptionNames = []; + + subscriptions.map(subscription => { + passOrThrow( + !subscriptionNames.includes(subscription.name), + () => + `Duplicate subscription name '${subscription.name}' found in '${entity.name}'`, + ); + + subscriptionNames.push(subscription.name); + + if (subscription.attributes) { + passOrThrow( + (isArray(subscription.attributes, true) && + subscription.type === SUBSCRIPTION_TYPE_CREATE) || + isArray(subscription.attributes, false), + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of attributes`, + ); + + subscription.attributes.map(attribute => { + passOrThrow( + typeof attribute === 'string', + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of attribute names`, + ); + }); + + passOrThrow( + subscription.attributes.length === uniq(subscription.attributes).length, + () => + `Subscription '${entity.name}.${subscription.name}' needs to have a list of unique attribute names`, + ); + + subscription.attributes.map(attributeName => { + passOrThrow( + entityAttributes[attributeName], + () => + `Cannot use attribute '${entity.name}.${attributeName}' in subscription '${entity.name}.${subscription.name}' as it does not exist`, + ); + }); + + if (subscription.type === SUBSCRIPTION_TYPE_CREATE) { + const missingAttributeNames = requiredAttributeNames.filter( + requiredAttributeName => { + return !subscription.attributes.includes(requiredAttributeName); + }, + ); + + passOrThrow( + missingAttributeNames.length === 0, + () => + `Missing required attributes in subscription '${entity.name}.${ + subscription.name + }' need to have a defaultValue() function: [ ${missingAttributeNames.join( + ', ', + )} ]`, + ); + } + } else if ( + subscription.type === SUBSCRIPTION_TYPE_CREATE || + subscription.type === SUBSCRIPTION_TYPE_UPDATE + ) { + const nonSystemAttributeNames = []; + + mapOverProperties(entityAttributes, (attribute, attributeName) => { + if (!attribute.isSystemAttribute) { + nonSystemAttributeNames.push(attributeName); + } + }); + + subscription.attributes = nonSystemAttributeNames; + } + + // const checkSubscriptionStates = stateStringOrArray => { + // const stateNames = isArray(stateStringOrArray) + // ? stateStringOrArray + // : [stateStringOrArray]; + + // stateNames.map(stateName => { + // passOrThrow( + // entityStates[stateName], + // () => + // `Unknown state '${stateName}' used in subscription '${entity.name}.${subscription.name}'`, + // ); + // }); + // }; + + // if (subscription.fromState) { + // passOrThrow( + // entity.hasStates(), + // () => + // `Mutation '${entity.name}.${subscription.name}' cannot define fromState as the entity is stateless`, + // ); + + // checkSubscriptionStates(subscription.fromState); + // } + + // if (subscription.toState) { + // passOrThrow( + // entity.hasStates(), + // () => + // `Subscription '${entity.name}.${subscription.name}' cannot define toState as the entity is stateless`, + // ); + + // checkSubscriptionStates(subscription.toState); + // } + }); + + return subscriptions; +}; diff --git a/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap b/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap new file mode 100644 index 00000000..7f297de3 --- /dev/null +++ b/src/engine/subscription/__snapshots__/Subscription.spec.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Mutation isSubscription should recognize non-Subscription objects 1`] = `"Not a Subscription object"`; + +exports[`Mutation processEntitySubscriptions should throw if provided with an invalid list of subscriptions 1`] = `"Entity 'SomeEntityName' subscriptions definition needs to be an array of subscriptions"`; + +exports[`Mutation processEntitySubscriptions should throw if provided with an invalid subscription 1`] = `"Invalid subscription definition for entity 'SomeEntityName' at position '0'"`; + +exports[`Mutation processEntitySubscriptions should throw if required attribute (without defaultValue) is missing in CREATE type subscriptions 1`] = `"Invalid setup property 'subscriptions' in entity 'SomeEntityName'"`; + +exports[`Mutation processEntitySubscriptions should throw if unknown attributes are used 1`] = `"Cannot use attribute 'SomeEntityName.doesNotExist' in subscription 'SomeEntityName.build' as it does not exist"`; + +exports[`Mutation processEntitySubscriptions should throw on duplicate subscription names 1`] = `"Duplicate subscription name 'build' found in 'SomeEntityName'"`; + +exports[`Mutation should have a description 1`] = `"Missing description for subscription 'example'"`; + +exports[`Mutation should have a list of unique attribute names 1`] = `"Subscription 'SomeEntityName.example' needs to have a list of unique attribute names"`; + +exports[`Mutation should have a list of valid attribute names 1`] = `"Subscription 'SomeEntityName.example' needs to have a list of attribute names"`; + +exports[`Mutation should have a name 1`] = `"Missing subscription name"`; + +exports[`Mutation should have a type 1`] = `"Missing type for subscription 'example'"`; + +exports[`Mutation should have a valid type 1`] = `"Unknown subscription type '12346' used, try one of these: 'onCreate, onUpdate, onDelete'"`;