From 04b4a052ad514d1d02f3dbcae090653841f8653c Mon Sep 17 00:00:00 2001 From: Vic van Cooten Date: Thu, 30 Sep 2021 17:16:03 +0200 Subject: [PATCH] Register formula dependencies and listen for chnge --- src/appbox-formulas/Functions/and.ts | 30 ++++++ src/appbox-formulas/Functions/index.ts | 7 ++ src/appbox-formulas/Types.ts | 93 +++++++++++++++++++ src/appbox-formulas/index.ts | 121 ++++++++++++++++++++++++- src/engine.ts | 39 +++++++- 5 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/appbox-formulas/Functions/and.ts create mode 100644 src/appbox-formulas/Functions/index.ts create mode 100644 src/appbox-formulas/Types.ts diff --git a/src/appbox-formulas/Functions/and.ts b/src/appbox-formulas/Functions/and.ts new file mode 100644 index 0000000..9deddcd --- /dev/null +++ b/src/appbox-formulas/Functions/and.ts @@ -0,0 +1,30 @@ +import Formula from "../index"; + +/* + * AND(...arg1, etc) + * returns true if ALL children are true + */ + +export default { + execute: (fArguments, data, formula: Formula) => + new Promise(async (resolve, reject) => { + let isTrue = true; + for (let x = 0; x < fArguments.length; x++) { + if (fArguments[x] !== true) isTrue = false; + } + + resolve(isTrue); + }), + onCompile: (fArguments) => { + const deps = []; + + // For each argument, check if their type is string (so not a "string", but a string). If so, it refers to a field and is a dependency. + for (let x = 0; x < fArguments.length; x++) { + if (typeof fArguments[x] === "string") deps.push(fArguments[x]); + } + + return deps; + }, + // Give a sample result so it's parent functions know what we will return on execute and can perform the right precompiling. + returnPreview: true, +}; diff --git a/src/appbox-formulas/Functions/index.ts b/src/appbox-formulas/Functions/index.ts new file mode 100644 index 0000000..a1d8566 --- /dev/null +++ b/src/appbox-formulas/Functions/index.ts @@ -0,0 +1,7 @@ +import and from "./and"; + +const functions = { + and, +}; + +export default functions; diff --git a/src/appbox-formulas/Types.ts b/src/appbox-formulas/Types.ts new file mode 100644 index 0000000..1767d80 --- /dev/null +++ b/src/appbox-formulas/Types.ts @@ -0,0 +1,93 @@ +import { Collection } from "mongodb"; + +/* Server technical types */ +export interface DBCollectionsType { + models: Collection; + objects: Collection; + usersettings: Collection; +} + +/* Model */ +export interface ModelType { + _id: string; + key: string; + key_plural: string; + label: string; + label_plural: string; + app: string; + primary: string; + icon: string; + locked?: boolean; + fields: { [key: string]: ModelFieldType }; + layouts: { [key: string]: ModelLayoutType }; + lists: { [key: string]: ModelListType }; + permissions: { + create: string[]; + read: string[]; + read_own: string[]; + update: string[]; + update_own: string[]; + delete: string[]; + delete_own: string[]; + }; +} + +// Field +export interface ModelFieldType { + label: string; + type?: "text" | "number" | "relationship" | "formula" | "options"; + required?: boolean; + unique?: boolean; + // Options + selectMultiple?: boolean; + optionsDisplayAs?: "dropdown" | "list" | string; + options?: { label: string; value: string }[]; + // Relationship + relationshipTo?: string; + // Formula + formula?: string; +} + +// Layout +export interface ModelLayoutType { + label: string; + layout: LayoutItemType[]; +} +export interface LayoutItemType { + key?: string; + label: string; + type: string; + items?: LayoutItemType[]; + args?: { [key: string]: any }; +} + +// List +export interface ModelListType { + label?: string; + filter?: {}; + fields?: string[]; +} + +/* Object types */ +export interface ObjectType { + _id: string; + meta: { + model: string; + createdOn: Date; + lastModifiedOn: Date; + createdBy: string; + lastModifiedBy: string; + owner: string; + team?: string; + }; + [key: string]: any; +} + +export interface UserObjectType extends ObjectType { + username: string; + first_name: string; + last_name: string; + email: string; + password: string; + roles: string[]; +} diff --git a/src/appbox-formulas/index.ts b/src/appbox-formulas/index.ts index 6ecfaa5..1d0aa2b 100644 --- a/src/appbox-formulas/index.ts +++ b/src/appbox-formulas/index.ts @@ -1,17 +1,39 @@ +import { ModelType } from "./Types"; +import { find } from "lodash"; +import uniqid from "uniqid"; const uniqid = require("uniqid"); - +/* + * The Formula class + */ class Formula { + id = uniqid(); + // Label + label; // Original formula string formulaString; // Holds formula template (tags replaced by unique identifiers) formulaTemplate; // Array holding all tags tags: { tag: string; identifier: string }[] = []; + // Array holding all dependencies + dependencies: { field: string; model: string; localDependency?: true }[] = []; + // Hold all models + models: ModelType[]; + // Promise to check if constructor is done working asynchronously + onParsed: Promise; // Constructor - constructor(formula, mode: "{{" | "[[" = "{{") { + constructor( + formula, + startingModelKey: string, + label?: string, + mode: "{{" | "[[" = "{{", // This is for nested formulas, such as templates + models?: ModelType[] // Some data may be statically delivered in JSON format. Then we don't need this. If we have dynamic (field__r) data we need to query the database and parse the correct dependencies. + ) { this.formulaString = formula; this.formulaTemplate = formula; + this.models = models; + this.label = label; // Pre-parse tags const tagPattern = @@ -27,9 +49,100 @@ class Formula { ); }); - // Loop through all the tags - console.log(this.formulaString, this.formulaTemplate, this.tags); + // Parse dependencies + this.onParsed = new Promise((resolve, reject) => + this.parseDependencies(startingModelKey).then( + () => resolve(), + (reason) => + reject(`(${label}) couldn't process dependencies: ${reason}`) + ) + ); } + + // Parse dependencies for all tags (asynchronously used in ) + parseDependencies = (startModelKey: string) => + new Promise(async (resolve, reject) => { + //@ts-ignore + await this.tags.reduce(async (prevTag, tag) => { + await prevTag; + + const tagParts = tag.tag.split(/[-+*\/](?![^\(]*\))/gm); + //@ts-ignore + await tagParts.reduce(async (prevTagPart, tagPart) => { + // The regexp splits on -, but not within parenthesis + const part = tagPart.trim(); + + // Check the context of the tag part and perform the appropriate action + if (part.match(/\w*\(.+\)/)) { + // This part has a function call. We need to preprocess these functions to figure out what the dependencies are. + const func = new RegExp(/(?\w*)\((?.*)\)/gm).exec( + part + ); + console.log("Preprocessing", func.groups.fName, func.groups.fArgs); + } else if (part.match(/\./)) { + if (part.match("__r")) { + // This is an object based relationship. Resolve the dependencies + if (this.models) { + // We're going to split by . and resolve them all to set a dependency. + const tagParts = part.split("."); + let currentModelKey = startModelKey; + //@ts-ignore + await tagParts.reduce(async (prevPart, currPart) => { + await prevPart; + + if (currPart.match("__r")) { + const fieldName = currPart.replace("__r", ""); + + // This is a part of the relationship. It needs to be registered as dependency, in case it's value changes. + this.dependencies.push({ + model: currentModelKey, + field: fieldName, + ...(currentModelKey === startModelKey + ? { localDependency: true } + : {}), + }); + // It also needs to be parsed to figure out what model the next + const currentModel = find( + this.models, + (o) => o.key === currentModelKey + ); + const field = currentModel.fields[fieldName]; + currentModelKey = field.relationshipTo; + } else { + this.dependencies.push({ + model: currentModelKey, + field: currPart, + }); + } + + return currPart; + }, tagParts[0]); + resolve(); + } else { + reject("no-models-provided"); + } + } else { + // This is a regular dependency (a.b.c), so we can just add it as a field + this.dependencies.push({ + field: part, + model: startModelKey, + localDependency: true, + }); + } + } else { + this.dependencies.push({ + field: part, + model: startModelKey, + localDependency: true, + }); + } + }, tagParts[0]); + + return tag; + }, this.tags[0]); + + resolve(); + }); } export default Formula; diff --git a/src/engine.ts b/src/engine.ts index 01b6080..5d25634 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -14,6 +14,10 @@ class Engine { objects: null, usersettings: null, }; + // All models + models: ModelType[]; + // Update triggers + updateTriggers: { [key: string]: string } = {}; // Initialise constructor(db) { @@ -23,19 +27,46 @@ class Engine { objects: db.collection("objects"), usersettings: db.collection("usersettings"), }; + this.collections.models + .find({}) + .toArray() + .then((models: ModelType[]) => (this.models = models)); console.log("Engine booting up"); } // Parse formulas - parseFormulas() { + async parseFormulas() { console.log("Parsing formulas"); - this.collections.models.find({}).forEach((model: ModelType) => { + const models = await this.collections.models.find({}).toArray(); + await models.reduce(async (prevModel, model) => { + await prevModel; + map(model.fields, (field, key) => { if (field.type === "formula") { - console.log(`🧪 Parsing formula ${field.label}.`); - const formula = new Formula(field.formula); + console.log(`🧪 Parsing formula '${field.label}'.`); + const formula = new Formula( + field.formula, + model.key, + `🧪 ${field.label}`, + "{{", + this.models + ); + formula.onParsed.then(() => { + formula.dependencies.map((dep) => { + this.updateTriggers[`${dep.model}___${dep.field}`] = formula.id; + }); + }); } }); + return model; + }, models[0]); + console.log("🥼 Done parsing formulas"); + this.registerOnObjectChangeListeners(); + } + + registerOnObjectChangeListeners() { + this.collections.objects.watch().on("change", async (change) => { + console.log(change); }); } }