import * as jsonpatch from 'fast-json-patch'; import { generate as shortid } from 'shortid'; import { AsyncParallelHook } from 'tapable'; import { isObject } from 'util'; import { ConfigSettingsBuilder } from '../builder'; import { isNonArrayObject, merge } from '../lib'; import { ConfigSettingsArgs, MergeOptions } from '../types'; import { areConfigNodePropertyPathsEqual, configNodePropertyPathToArray } from './lib'; import { ConfigNodeArgs, ConfigNodeHooks, ConfigNodeInheritedNodeOptionsArg, ConfigNodeInheritedNodesArg, ConfigNodeOptions, ConfigNodePropertyFactory, ConfigNodePropertyPath, ConfigNodeValueArg, ConfigPropertyNodeFactoryArgs, } from './types'; const defaultOptions: ConfigNodeOptions = { shouldInheritParent: false, }; /** * Abstract Base class defining a Config Node. All ConfigNodes ultimately inherit from this. */ export abstract class ConfigNodeBase< TValue = {}, TOptions extends ConfigNodeOptions = ConfigNodeOptions, TArgs extends ConfigNodeArgs = ConfigNodeArgs > { /** * Root Node from which this Node descends ('this' if this is node is the root). Root Node has not parent. */ public get rootNode(): ConfigNodeBase { // tslint:disable-next-line: no-this-assignment let root: ConfigNodeBase = this; while (root.parentNode) { root = root.parentNode; } return root; } public get propertyPath(): string[] { let path = []; if (this.parentNode) { // tslint:disable-next-line: no-this-assignment let lastParent: ConfigNodeBase = this; do { path.unshift(lastParent.propertyName); lastParent = lastParent.parentNode; } while (lastParent.parentNode); path = path.filter(Boolean); } return path; } /** * Resolved value of this Node. */ public get value(): TValue { return this.getValue(); } /** * Hooks to Node notifications implemented as AsyncParallelHook */ public get hooks(): ConfigNodeHooks { if (!this._hooks) { this._hooks = { propertyAdded: new AsyncParallelHook(['ConfigNode']), valueChanged: new AsyncParallelHook(['patch', 'ConfigNode']), }; } return this._hooks; } /** * Direct value of this Node without inherited values. */ public get nodeValue(): TValue { return this.getValue(true); } /** * Map of Nodes representing properties of this Node, keyed on Property name. */ public get propertyNodes(): { [name: string]: ConfigNodeBase } { if (!this._propertyNodes) { this._propertyNodes = {}; } return this._propertyNodes; } /** * Inherit parent node's value for this node */ public get shouldInheritParent(): boolean { return this._shouldInheritParent; } /** * Inherit parent node's value for this node */ public set shouldInheritParent(inherit: boolean) { this._shouldInheritParent = inherit; } /** * Array of Inherited nodes providing base values from which this Node inherits. */ protected get inheritedNodes(): ConfigNodeBase[] { return this._getInheritedNodes(); } /** * Indicates whether this Node has a staic value (e.g. number, string, bool) * and not an object with property nodes */ protected get hasStaticValue(): boolean { return this._staticValue !== undefined && this._staticValue !== null; } /** * Getter for Option data used to configure Node handling */ protected get options(): TOptions { return this._options; } /** * Setter for Option data used to configure Node handling */ protected set options(options: TOptions) { this._options = options; } public get propertyName(): string { return this._propertyName; } public get parentNode(): ConfigNodeBase { return this._parentNode; } protected abstract get defaultPropertyNodeFactory(): ConfigNodePropertyFactory; public static merge = (settings: any[], options?: MergeOptions) => { return merge(settings, options); }; /** * Unique Id for this Node */ public readonly id = shortid(); protected _initialValue: ConfigNodeValueArg; private _propertyNodes: { [name: string]: ConfigNodeBase }; private _staticValue: any; private _inheritedNodes: ConfigNodeBase[]; private _options: TOptions; private _hooks: ConfigNodeHooks; private _applyingValue: boolean = false; private _initializing: boolean = false; private _shouldInheritParent: boolean = false; private _propertyName: string; private _parentNode: ConfigNodeBase; protected constructor(protected readonly args: TArgs) { const { propertyName, parentNode } = (args as unknown) as ConfigPropertyNodeFactoryArgs; this._options = this.merge([defaultOptions, args.options]); this._initialValue = args.initialValue; this._propertyName = propertyName; this._parentNode = parentNode; this._shouldInheritParent = this._options.shouldInheritParent || false; this.addInheritedNode(args.inheritedNodes); } /** * Adds a Node from which this Node will inherit values from. * @param node Node from which this will inherit. */ public addInheritedNode = (node: ConfigNodeInheritedNodesArg) => { // const newNodes = node ? (Array.isArray(node) ? node : [node]) : []; const newNodes = this.inheritedNodeArgToNodes(node); newNodes.unshift(...(this._inheritedNodes || [])); // de-dupe on id this._inheritedNodes = newNodes.filter( (newNode, index, self) => index === self.findIndex(n => n.id === newNode.id) ); // TODO: Find Circular Inheritance // const x = this._inheritedNodes.some(n => { // const xx = n.inheritedNodes.some(nn => nn.id === this.id); // return n.inheritedNodes.some(nn => nn.id === this.id); // }) }; /** * Returns Node at a property path * @param path path to property, delimited by '.', '/', or as an array * @param includeInherited if true, return matching inherited node if path doesn't exist directly on this node */ public getPropertyNode( path: ConfigNodePropertyPath, includeInherited = true ): TNodeValue | undefined { let propertyNode: TNodeValue | undefined; // loop over property nodes if (Object.keys(this.propertyNodes).length > 0) { // normalize path to array of strings const pathAry = configNodePropertyPathToArray(path); // make sure there's a path and that the first element matches a top-level property node if (pathAry.length > 0 && this.propertyNodes[pathAry[0]]) { propertyNode = (pathAry.length === 1 ? // single-ele path matches top-level node this.propertyNodes[pathAry[0]] : // pass path after first ele into the top-level node to let it search it's inherited nodes this.propertyNodes[pathAry[0]].getPropertyNode(pathAry.slice(1))) as TNodeValue; } } // if path not found on property nodes, try to find path in an inherited node if (!propertyNode && includeInherited) { // get all inherited nodes at supplied path const inheritedNodes = this.resolveInheritedNodes(path); // if inherited node found.. if (inheritedNodes.length > 0) { // return the last (highest priority) matching inherited node propertyNode = inheritedNodes[inheritedNodes.length - 1] as TNodeValue; } } return propertyNode; } /** * Applies passed value to Node. * @param value Value to apply to Node in form of ConfigNodeValueArg built with ConfigSettingsBuilder. */ public async applyValue(value: ConfigNodeValueArg) { await this._applyValue(value, true); } /** * Returns array of all inherited nodes, optionally nodes for a given path * @param propertyPath property path */ public resolveInheritedNodes(propertyPath?: ConfigNodePropertyPath): ConfigNodeBase[] { // loop over inherited nodes const nodes = this.inheritedNodes.reduce((acc, curr) => { if (propertyPath) { // get the property node from inherited node based on passed arg const node = curr.getPropertyNode(propertyPath, false); if (node) { acc.push(node); } } else { // no property path, add entire inhertied node acc.push(curr); } // get this node's inherited nodes and insert in front of returned array as lower priority acc.unshift(...curr.resolveInheritedNodes(propertyPath)); return acc; }, []); // if parentNode exists, search up to get inherited nodes if (this.parentNode) { // normalize path to array const propPathAry = configNodePropertyPathToArray(propertyPath); // if this node is a property, insert as first part of path to call into parent if (this.propertyName) { propPathAry.unshift(this.propertyName); } // get parent's inherited nodes at adjusted path const inheritedParentNodes = this.parentNode.resolveInheritedNodes(propPathAry); // insert parent inherited nodes in front of already gathered nodes nodes.unshift(...inheritedParentNodes); } return nodes; } /** * Build value to be returned by value property. Subclasses can override with specialized logic to build value. * @param excludeInherited do not include inherited values * @param omitProperties omit properties from value (primarily needed to avoid cycles in certain use cases) */ public getValue(excludeInherited = false, omitProperties: string[] = []): TValue { if (this.hasStaticValue) { return this._staticValue; } else { let value = excludeInherited ? {} : this.getInheritedValue(); const propertyNames = Object.keys(this.propertyNodes).filter(p => omitProperties.indexOf(p) < 0); if (propertyNames.length > 0) { // if inherited value is a static value, change it to empty object as this node has properties value = isObject(value) ? value : {}; for (const propName of propertyNames) { const nodeValue = this.propertyNodes[propName].value; value[propName] = nodeValue !== undefined ? nodeValue : value[propName]; } } // delete omitted properties that may have been from inherited nodes if (value) { for (const op of omitProperties) { delete value[op]; } } return value; } } /** * Convert ConfigNodeInheritedNodeOptionsArg for inherited node to a Node */ protected inheritedNodeOptionsArgToNode = (optionsArg: ConfigNodeInheritedNodeOptionsArg): ConfigNodeBase => { // const { includeRootProperty = false, node, propertyPath } = optionsArg; // return optionsArg.node; // TODO: Handle specifying options for inherited nodes to allow more flexibility // in what/how node is inherited from another node. Specifically, we want to support // inheriting from a property path, and optionally include the root property. // This will (likely) require creating container nodes, and the whole chain here // will need to be async, and curently inherited nodes are processed in constructor. // also need to to acount for inheriting nodes from a different part of a tree, // where custom property node factories may be defined for target tree but not where // it's inherited from. // also, the outline of the approach here resolves to inherited nodes on creation, // and may not properly handle changes to the inherited nodes after they are processed in here // most accurate approach may be to process inherited nodes when final value resolved, but // that probably adds quite a bit of complexity. // Gets messy. My head hurts. throw new Error('ConfigNodeInheritedNodeOptionsArg Not Iplemented'); }; /** * Convert inherited node argument to array of nodes */ protected inheritedNodeArgToNodes = (arg: ConfigNodeInheritedNodesArg): ConfigNodeBase[] => { const aarg = arg ? (Array.isArray(arg) ? arg : [arg]) : []; const nodes = aarg.map(a => { // if it's an actual node, use it as is if (a instanceof ConfigNodeBase) { return a; // if arg contains options specifying inherited node, convert it to a node } else { return this.inheritedNodeOptionsArgToNode(a); } }); return nodes; }; /** * Getter hook to return defined inherited nodes. Allows subclasses to defined inherited * nodes dynamically. Returns nodes in instance value by default. */ protected _getInheritedNodes(): ConfigNodeBase[] { return this._inheritedNodes; } /** * Get array of all property nodes for given path, including direct node (if exists) and inherited nodes for property path * @param path property path */ protected getPropertyNodes(path: ConfigNodePropertyPath): ConfigNodeBase[] { const propNode = this.getPropertyNode(path); const all = this.resolveInheritedNodes(path); if (propNode) { all.push(propNode); } return all; } /** * Return default value for this node. Can be used by subclasses to provide defaults. */ protected async getDefaultValue(): Promise> { return undefined; } /** * Build value from inherited nodes. */ protected getInheritedValue() { // get inherited nodes const nodes = this.resolveInheritedNodes(); let value; if (nodes.length > 0) { // if last node is has a static value (i.e. not an object), return that value if (nodes[nodes.length - 1].hasStaticValue) { value = nodes[nodes.length - 1].value; } else { // return merged object values from nodes value = nodes.reduce((acc, curr) => { if (!curr.hasStaticValue) { acc = this.merge([acc, curr.value]); } return acc; }, {}); } } if (this.shouldInheritParent && this.parentNode) { const parentValue = this.parentNode.getValue(false, [this.propertyName]); value = this.merge([value, parentValue]); } return value; } /** * Return raw initial value. By default returns value supplied to constructor. */ protected async getInitialValue() { return this._initialValue; } /** * Called prior to init function (for subclass hook) */ // tslint:disable-next-line: no-empty protected async beforeInit() {} /** * Called after init function (for subclass hook) */ // tslint:disable-next-line: no-empty protected async afterInit() {} /** * Async method to initialize Node. Must be called asyncronously, after Node constructed */ protected async init() { this._initializing = true; await this.beforeInit(); const defaultValue = await this.getDefaultValue(); await this._applyValue(defaultValue, false); const initialValue = await this.getInitialValue(); await this._applyValue(initialValue); await this.afterInit(); await this.notifyValueChanged(this.getJsonPatchDiff({}, this.value)); this._initializing = false; } /** * Searches the node hierarchy to find a configured property node factory based on a provided path. Returns * undefinded if a factory is not defined for the specific property path. * @param propertyPath property path for which to find configured node factory * @param includeInherited whether or not to search inherited nodes for factory */ protected findPropertyNodeFactory( propertyPath: ConfigNodePropertyPath, includeInherited = false ): ConfigNodePropertyFactory | undefined { let factory: ConfigNodePropertyFactory; // convert path to array of segments const aPath = configNodePropertyPathToArray(propertyPath); if (aPath.length > 0) { // interate over propertyNodeFactories defined in this Node's arg for (const factoryOption of this.args.propertyNodeFactories || []) { // Check if defined factory path matches passed in propertyPath arg if (areConfigNodePropertyPathsEqual(propertyPath, factoryOption.propertyPath)) { // use option's factory as return value factory = factoryOption.factory; } } // if factory wasn't found on this node, search for path on the parent (which will walk to root) if (!factory && this.parentNode) { factory = this.parentNode.findPropertyNodeFactory(propertyPath); } // if factory wasn't found on this node or root, search inherited nodes if flag specified if (!factory && includeInherited) { // TODO: Not sure if this would work, as assumes all inherited nodes are consistent paths to root for (const inheritedNode of this.resolveInheritedNodes()) { factory = inheritedNode.findPropertyNodeFactory(propertyPath, true); if (factory) { break; } } } } return factory; } /** * Creates a property node, tryes to find a defined factory to create the node based on the path * @param args property node args */ protected async createPropertyNode(args: TArgs & ConfigPropertyNodeFactoryArgs): Promise { const propertyPath = this.propertyPath.concat([args.propertyName]); let factory = this.findPropertyNodeFactory(propertyPath); if (!factory) { // try to find again, this time including inherited nodes factory = this.findPropertyNodeFactory(propertyPath, true); } // use default factory if a specific one not found factory = factory || this.defaultPropertyNodeFactory; const node = await factory(args); return node; } /** * Returns argument object to pass to Property Node Factory. Allows specialized sub-classes to include additional arguments. * @param baseArgs base args supplied by ConfigNodeBase */ protected getPropertyNodeFactoryArgs( baseArgs: ConfigPropertyNodeFactoryArgs ): TArgs & ConfigPropertyNodeFactoryArgs { return baseArgs as TArgs & ConfigPropertyNodeFactoryArgs; } /** * Returns json patch for previous and current values. Note, this only makes sense for object values. We can't * create a json patch for non-object static values, so only completely accurate at the parent object level * @param previousValue previous value to compare * @param currentValue current value to compare */ protected getJsonPatchDiff(previousValue: TValue | {}, currentValue: TValue | {}): jsonpatch.Operation[] { return jsonpatch.compare( isObject(previousValue) ? previousValue : {}, isObject(currentValue) ? currentValue : {} ); } /** * Helper using ConfigSettingsBuilder to build setting supplied by argument to applySettings * @param settings ConfigSettingsArgs value to be passed to ConfigSettingsBuilder */ protected async buildSettingsFromArgs(settings: ConfigSettingsArgs) { try { // normalize settings arg to Array const args = Array.isArray(settings) ? settings : [settings]; const mergeOptions: MergeOptions = {}; let value = {}; // loop through args array and resolve each to settings for (const arg of args) { const resolvedArgSettings = await ConfigSettingsBuilder.build(arg, { mergeOptions, }); value = this.merge([value, resolvedArgSettings], mergeOptions); } return value; } catch (e) { throw new Error(`buildSettingsFromArgs failed: ${e.message}`); } } /** * Internal method to apply value to node * @param value Value to apply * @param notify Indicates whether valueChanged hook is called */ protected async _applyValue(value: ConfigNodeValueArg, notify = false) { this._applyingValue = true; let previousValue: TValue | {}; if (notify) { previousValue = isObject(this.value) ? this.value : {}; } const builtValue = isNonArrayObject(value) ? await this.buildSettingsFromArgs(value) : value; const propertyNames = isNonArrayObject(builtValue) ? Object.keys(builtValue) : []; if (propertyNames.length > 0) { for (const propertyName of propertyNames) { let propertyNode = this.propertyNodes[propertyName]; const propertySettings = builtValue[propertyName]; if (!propertyNode) { const propertyNodeFactoryArgs = this.getPropertyNodeFactoryArgs({ initialValue: propertySettings, options: this.options, parentNode: this, propertyName, }); propertyNode = await this.createPropertyNode(propertyNodeFactoryArgs); // hook changes to property node (to notify when prop node changed directly) propertyNode.hooks.valueChanged.tapPromise( 'ConfigNodeBaseProperty', this.handlePropertyValueChanged ); this._propertyNodes[propertyName] = propertyNode; await this.notifyPropertyAdded(propertyNode); } else { await propertyNode._applyValue(propertySettings); } } this._staticValue = undefined; } else { // convert empty object to undefined this._staticValue = isNonArrayObject(builtValue) ? undefined : builtValue; } if (notify) { const currValue = isObject(this.value) ? this.value : {}; await this.notifyValueChanged(this.getJsonPatchDiff(previousValue, currValue)); } this._applyingValue = false; } /** * Handles valueChanged notifications from property nodes and calls notify hook with properties * mapped to this node's property paths. */ protected handlePropertyValueChanged = async (patch: jsonpatch.Operation[], propertyNode: ConfigNodeBase) => { if (!(this._applyingValue || this._initializing)) { // pre-pend property name to path of each operation in patch const propertyPatch = patch.map(o => { return { ...o, path: `/${propertyNode.propertyName}/` + o.path .split('/') .filter(Boolean) .join('/'), }; }); await this.notifyValueChanged(propertyPatch); } }; /** * Fires valueChanged hook with changes to node value * @param patch json patch with changes to node value */ protected async notifyValueChanged(patch: jsonpatch.Operation[]) { await this.hooks.valueChanged.promise(patch, this); } /** * Fires propertyAdded hook when new property is added to this node * @param propertyNode added property node */ protected async notifyPropertyAdded(propertyNode: ConfigNodeBase) { await this.hooks.propertyAdded.promise(propertyNode); } protected merge = (settings: any[], options?: MergeOptions) => { return ConfigNodeBase.merge(settings, options); }; }