diff --git a/.gitignore b/.gitignore index 3c3629e..2bc12db 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +junk diff --git a/bin/zap b/bin/zap index c21d369..e64909d 100755 --- a/bin/zap +++ b/bin/zap @@ -2,31 +2,73 @@ "use strict"; +const Promise = require("bluebird"); const yargs = require("yargs"); const matchValue = require("match-value"); +const chalk = require("chalk"); +const loadConfiguration = require("../src/load-configuration"); +const generateSchema = require("../src/schema-generator"); +const renderSchema = require("../src/render-schema"); +const processSchemaUpdate = require("../src/process-schema-update"); + +// FIXME: Update to take timestamps instead of revision numbers (and autocomplete them!) let argv = yargs - .command("change", "Create a new schema revision") + .command("change ", "Create a new schema revision", { + description: { describe: "A brief textual description of the purpose of the revision" } + }) .command("upgrade ", "Upgrade the database to a new schema revision", { revision: { describe: "The number of the revision to upgrade to (or 'latest')" } }) .command("undo", "Undo the most recent schema revision upgrade") - .command("show ", "Show the full database schema at a given revision") + .command("show ", "Show the full database schema at a given revision", { + revision: { describe: "The number of the revision at which the schema should be shown (or 'latest')" } + }) .argv; -console.log(argv); - -matchValue(argv._[0], { - change: () => { - console.log("change"); - }, - upgrade: () => { - console.log("upgrade"); - }, - undo: () => { - console.log("undo"); - }, - show: () => { - console.log("show"); - } +// FIXME: Error/help when no subcommand has been specified + +// console.log(argv); + +return Promise.try(() => { + return loadConfiguration(); +}).then(({ configuration, configurationPath }) => { + let state = { configurationPath, configuration }; + + // FIXME: Make configurable + const schemaProvider = require("../src/schema-providers/fs")(state); + + matchValue(argv._[0], { + change: () => { + return Promise.try(() => { + return schemaProvider.create({ description: argv.description }); + }).then(({ statusMessage }) => { + console.log(chalk.green(`✔ ${statusMessage}`)); + }); + }, + upgrade: () => { + return Promise.try(() => { + return schemaProvider.getAll(); + }).then((schemaUpdates) => { + let processed = schemaUpdates.map((update) => { + return processSchemaUpdate(update); + }); + + // console.log(require("util").inspect(processed, { colors: true, depth: null })); + }); + + // TODO: Allow-gap option to permit filling in 'gapped migrations' (eg. after an earlier migration gets merged in via branch merge) + }, + undo: () => { + console.log("undo"); + }, + show: () => { + return Promise.try(() => { + return schemaProvider.getAll(); + }).then((schemaUpdates) => { + let schema = generateSchema(schemaUpdates); + console.log(renderSchema(schema)); + }); + } + }); }); diff --git a/experiments/any-all-via-define.js b/experiments/any-all-via-define.js new file mode 100644 index 0000000..6d3c0f9 --- /dev/null +++ b/experiments/any-all-via-define.js @@ -0,0 +1,49 @@ +// Testcase from ThePendulum + +// Building must contain any of the specified people +select("buildings", [ + define("tenants", has("tenants.building_id")), + where({ tenants: { name: anyOf([ "james", "luke", "stanley" ]) } }) +]) + + +// Building must contain *all* of the specified people, and optionally others +select("buildings", [ + define("tenants", has("tenants.building_id")), + where({ tenants: { name: allOf([ "james", "luke", "stanley" ]) } }) +]) + +// Additional testcase: Building must contain *only* the specified people + + +// Related code: + /* GraphQL/Postgraphile 'every' applies to the data, will only include scenes for which every assigned tag is selected, + instead of what we want; scenes with every selected tag, but possibly also some others */ + CREATE FUNCTION actors_scenes(actor actors, selected_tags text[], mode text DEFAULT 'all') RETURNS SETOF releases AS $$ + SELECT releases.* + FROM releases + LEFT JOIN + releases_actors ON releases_actors.release_id = releases.id + LEFT JOIN + releases_tags ON releases_tags.release_id = releases.id + LEFT JOIN + tags ON tags.id = releases_tags.tag_id + WHERE releases_actors.actor_id = actor.id + AND CASE + /* match at least one of the selected tags */ + WHEN mode = 'any' AND array_length(selected_tags, 1) > 0 + THEN tags.slug = ANY(selected_tags) + ELSE true + END + GROUP BY releases.id + HAVING CASE + /* match all of the selected tags */ + WHEN mode = 'all' AND array_length(selected_tags, 1) > 0 + THEN COUNT( + CASE WHEN tags.slug = ANY(selected_tags) + THEN true + END + ) = array_length(selected_tags, 1) + ELSE true + END; + $$ LANGUAGE SQL STABLE; diff --git a/experiments/raqb-concepts.js b/experiments/raqb-concepts.js index d25481d..d390189 100644 --- a/experiments/raqb-concepts.js +++ b/experiments/raqb-concepts.js @@ -43,19 +43,19 @@ try { */ - // let niceNumbers = anyOf([ 1, 2, 3 ]); - - // query = select("projects", [ - // onlyFields([ "foo" ]), - // where({ - // number_one: niceNumbers, - // number_two: niceNumbers - // }), - // where({ - // number_three: anyOf([ 42, field("number_one") ]), - // number_four: moreThan(1337) - // }) - // ]); + let niceNumbers = anyOf([ 1, 2, 3 ]); + + query = select("projects", [ + onlyFields([ "foo" ]), + where({ + number_one: niceNumbers, + number_two: niceNumbers + }), + where({ + number_three: anyOf([ 42, field("number_one") ]), + number_four: moreThan(1337) + }) + ]); /* Generation timings: @@ -101,36 +101,36 @@ try { // ]) // ]); - query = createTable("actors", { - fields: { - imdb_id: string(), - name: [ required(), string() ], - date_of_birth: date({ withTimezone: true }), - place_of_birth: string(), - imdb_metadata_scraped: json() - } - }); - - query = createTable("movies", { - fields: { - imdb_id: string(), - name: string(), - imdb_metadata_scraped: json() - } - }); - - query = createTable("appearances", { - fields: { - actor: belongsTo("actors"), - movie: belongsTo("movie") - } - }); - - updateTable("appearances", { - fields: { + // query = createTable("actors", { + // fields: { + // imdb_id: string(), + // name: [ required(), string() ], + // date_of_birth: date({ withTimezone: true }), + // place_of_birth: string(), + // imdb_metadata_scraped: json() + // } + // }); + + // query = createTable("movies", { + // fields: { + // imdb_id: string(), + // name: string(), + // imdb_metadata_scraped: json() + // } + // }); + + // query = createTable("appearances", { + // fields: { + // actor: belongsTo("actors"), + // movie: belongsTo("movie") + // } + // }); + + // updateTable("appearances", { + // fields: { - } - }) + // } + // }) // FIXME: For query generation, ensure we are generating correct queries for TRUE/FALSE/NULL/NOTNULL (in particular, moreThan/lessThan should not allow these!) // FIXME: Partial indexes diff --git a/index.js b/index.js index a5bce07..a6ec52a 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,3 @@ "use strict"; -module.exports = { - -}; +module.exports = require("./src/operations"); diff --git a/notes.txt b/notes.txt index 9bbe939..55d5b90 100644 --- a/notes.txt +++ b/notes.txt @@ -14,6 +14,8 @@ Todo: - Annotate placeholder/parameter nodes with the expected parameter type in their position, for validation of input parameters at query time - Rename table -> collection everywhere, also column -> field? - Update wrapError API +- Figure out a way to semantically deal with the SQL boolean behaviour of propagating NULL (eg. as described on https://www.postgresql.org/docs/8.1/functions-subquery.html for IN) +- Some sort of convertAs method for schema building, when changing the data type of a column - disallow a direct type override, and require it to be wrapped in a changeType method that also takes the conversion logic as an argument? Docs: - Emphasize that users should feel free to experiment; the library will tell them (safely) if they are trying to do something invalid - need to help the user overcome the "but what if I get it wrong" fear (also emphasize the flexibility of schema updates to help with this, "it can always be fixed later" or so) @@ -42,6 +44,10 @@ Terminology: - Operation: any kind of item in a query; eg. select(...), but also where(...) and collapseBy(...) -- need to emphasize that this does *not* imply immediate execution - Clause: any kind of operation that modifies the behaviour of its parent operation in some way; eg. a where(...) inside of a select(...) or inside of a `has(...)` - Predicate: condition, like moreThan(3) +- Meta-queries?: EXISTS, IN, etc. - queries that operate on other queries + +Tricky usecases: +- https://gist.github.com/samsch/83f91a66eaf96a70c909ae80d4adfe3e ---- @@ -63,6 +69,7 @@ Recommendations to make: Ideas: - Make placeholders typed so that we can warn the user when they try to pass in invalid things? - Safe client API that returns a disposer instead of a client upon client creation? +- For modular/extensible design, allow plugins to supply operations + optimizers, and expect it all to be compilable to the core operations set? Need to find a way to keep same-named operations in different plugins from clashing. Also need dependency-solving to make it possible to specify "my optimizers should run before those of $otherPlugin". ---- diff --git a/package.json b/package.json index 26a7ab4..f9efe9e 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "zapdb", "version": "0.1.0", "main": "index.js", + "bin": { + "zap": "bin/zap" + }, "repository": "git@git.cryto.net:joepie91/raqb.git", "author": "Sven Slootweg ", "license": "WTFPL OR CC0-1.0", @@ -12,6 +15,7 @@ "@validatem/core": "^0.3.3", "@validatem/default-to": "^0.1.0", "@validatem/either": "^0.1.3", + "@validatem/ensure-array": "^0.1.0", "@validatem/error": "^1.0.0", "@validatem/forbidden": "^0.1.0", "@validatem/has-shape": "^0.1.7", @@ -37,19 +41,26 @@ "browser-hrtime": "^1.1.6", "chalk": "^4.1.0", "classnames": "^2.2.6", + "date-fns": "^2.16.1", "debounce": "^1.2.0", "debug": "^4.1.1", "default-value": "^1.0.0", "estree-assign-parent": "^1.0.0", + "find-last": "^1.0.0", + "find-up": "^5.0.0", "flatten": "^1.0.3", + "is-plain-obj": "^3.0.0", "map-obj": "^4.1.0", "match-value": "^1.1.0", + "merge-by-template": "^0.1.3", "pg": "^8.3.3", "prismjs": "^1.20.0", "scope-analyzer": "^2.0.5", + "slug": "^3.3.4", "split-filter": "^1.1.3", "split-filter-n": "^1.1.2", "syncpipe": "^1.0.0", + "table": "^6.0.3", "yargs": "^15.4.1" }, "devDependencies": { diff --git a/src/ast-to-query.js b/src/ast-to-query.js index e165787..f899e3f 100644 --- a/src/ast-to-query.js +++ b/src/ast-to-query.js @@ -229,7 +229,7 @@ let process = { select: function ({ collection, clauses }) { let $collection = $handle(collection); - let expectedClauseTypes = [ "where", "addFields", "onlyFields" ]; + let expectedClauseTypes = [ "where", "addFields", "onlyFields", "collapseBy" ]; let clausesByType = splitFilterN(clauses, expectedClauseTypes, (clause) => clause.type); let onlyFields = clausesByType.onlyFields.map((node) => node.fields).flat(); @@ -393,6 +393,7 @@ function $handle(node) { if (processor != null) { return processor(node); } else { + // FIXME: unreachable throw new Error(`Unrecognized node type: ${node.type}`); } } diff --git a/src/ast/contains-remote-fields.js b/src/ast/contains-remote-fields.js new file mode 100644 index 0000000..f449eef --- /dev/null +++ b/src/ast/contains-remote-fields.js @@ -0,0 +1,17 @@ +"use strict"; + +module.exports = function containsRemoteFields(node) { + if (node == null) { + return false; + } else if (Array.isArray(node)) { + return node.some((item) => containsRemoteFields(item)); + } else if (node.__raqbASTNode === true) { + if (node.type === "remoteField") { + return true; + } else { + return Object.values(node).some((item) => containsRemoteFields(item)); + } + } else { + return false; + } +}; diff --git a/src/ast/optimize/index.js b/src/ast/optimize/index.js index 27600d7..c843544 100644 --- a/src/ast/optimize/index.js +++ b/src/ast/optimize/index.js @@ -2,15 +2,18 @@ "use strict"; // Design note: We return stateLogs instead of passing in an object of registered handlers to call, because a node can become obsolete in mid-processing, and in those cases all of its state sets should be ignored. By far the easiest way to implement this, is to just keep a stateLog in the node handling context (since that entire context gets thrown away when processing gets aborted due to a subtree change), and let the parent deal with actually applying any still-relevant setStates to the correct handler functions. -// FIXME: Figure out a way to track 'loss factor' per optimizer, ie. how many (partial or complete) node evaluations have been discarded due to the actions of that optimizer, including subtrees. This can give insight into which optimizers cause unreasonably much wasted work. +// TODO: Figure out a way to track 'loss factor' per optimizer, ie. how many (partial or complete) node evaluations have been discarded due to the actions of that optimizer, including subtrees. This can give insight into which optimizers cause unreasonably much wasted work. const util = require("util"); const splitFilter = require("split-filter"); const mapObj = require("map-obj"); const defaultValue = require("default-value"); +const isPlainObj = require("is-plain-obj"); +const findLast = require("find-last"); const NoChange = require("../../optimizers/util/no-change"); const RemoveNode = require("../../optimizers/util/remove-node"); +const ConsumeNode = require("../../optimizers/util/consume-node"); const typeOf = require("../../type-of"); const concat = require("../../concat"); const deriveNode = require("../../derive-node"); @@ -23,6 +26,8 @@ const combineOptimizers = require("./combine-optimizers"); const createDebuggers = require("./create-debuggers"); // FIXME: Implement a scope tracker of some sort, to decouple the code here a bit more +// TODO: Determine if we can improve performance by avoiding a lot of array allocations for the path tracking; by eg. nesting objects instead and unpacking it into an array on-demand +// FIXME: Verify that the various iterations=0 arguments are actually correct, and don't lose iteration count metadata let EVALUATION_LIMIT = 10; @@ -30,45 +35,75 @@ function defer(func) { return { __type: "defer", func: func }; } -function handleNodeChildren(node, handle) { +function handleNodeChildren(node, handleASTNode, path) { let changedProperties = {}; let stateLogs = []; - for (let [ property, value ] of Object.entries(node)) { - if (value == null) { - continue; - } else if (value.__raqbASTNode === true) { - let result = handle(value); + function tryTransformItem(node, path) { + if (node == null) { + return node; + } else if (node.__raqbASTNode === true) { + let result = handleASTNode(node, 0, path); if (result.stateLog.length > 0) { stateLogs.push(result.stateLog); } - if (result.node !== value) { - changedProperties[property] = result.node; - } - } else if (Array.isArray(value) && value.length > 0 && value[0].__raqbASTNode === true) { - // NOTE: We assume that if an array in an AST node property contains one AST node, *all* of its items are AST nodes. This should be ensured by the input wrapping in the operations API. - // eslint-disable-next-line no-loop-func - let results = value.map((item) => handle(item)); + return result.node; + } else if (Array.isArray(node)) { + let valuesHaveChanged = false; + + let transformedArray = node.map((value, i) => { + let pathSegment = { type: "$array", key: i }; + let transformedValue = tryTransformItem(value, path.concat([ pathSegment ])); - let newStateLogs = results - .filter((result) => result.stateLog.length > 0) - .map((result) => result.stateLog); + if (transformedValue !== value) { + valuesHaveChanged = true; + } + + return transformedValue; + }); - if (newStateLogs.length > 0) { - stateLogs.push(... newStateLogs); + if (valuesHaveChanged) { + return transformedArray; + } else { + return node; } + } else if (isPlainObj(node)) { + let newObject = {}; + let propertiesHaveChanged = false; + + for (let [ key, value ] of Object.entries(node)) { + let pathSegment = { type: "$object", key: key }; + let transformedValue = tryTransformItem(value, path.concat([ pathSegment ])); - let newNodes = results.map((result) => result.node); - let hasChangedItems = newNodes.some((newNode, i) => newNode !== value[i]); + if (transformedValue !== value) { + propertiesHaveChanged = true; + } + + newObject[key] = transformedValue; + } - if (hasChangedItems) { - changedProperties[property] = newNodes.filter((item) => item !== RemoveNode); + if (propertiesHaveChanged) { + return newObject; + } else { + return node; } } else { // Probably some kind of literal value; we don't touch these. - continue; + return node; + } + } + + // FIXME: Delete nulls? + + for (let [ property, value ] of Object.entries(node)) { + let childPath = path.concat([{ type: node.type, key: property }]); + + let transformedValue = tryTransformItem(value, childPath); + + if (transformedValue !== value) { + changedProperties[property] = transformedValue; } } @@ -93,7 +128,9 @@ module.exports = function optimizeTree(ast, optimizers) { ]; }); - function handleNode(node, iterations = 0) { + function handleASTNode(node, iterations = 0, path = [], initialStateLog) { + // console.log(path.map((item) => String(item.key)).join(" -> ")); + // The stateLog contains a record of every setState call that was made during the handling of this node and its children. We keep a log for this rather than calling handlers directly, because setState calls should always apply to *ancestors*, not to the current node. That is, if the current node does a setState for `foo`, and also has a handler registered for `foo`, then that handler should not be called, but the `foo` handler in the *parent* node should be. // FIXME: Scope stateLog entries by optimizer name? To avoid name clashes for otherwise similar functionality. Like when multiple optimizers track column names. let stateLog = []; @@ -101,15 +138,20 @@ module.exports = function optimizeTree(ast, optimizers) { let handlers = createHandlerTracker(); let nodeVisitors = visitorsByType[node.type]; - function handleResult({ debuggerName, result, permitDefer }) { + function handleResult({ debuggerName, result, permitDefer, initialStateLog }) { if (result === NoChange) { // no-op } else if (result == null) { - // FIXME: Improve this error so that it actually tells you in what visitor things broke - throw new Error(`A visitor is not allowed to return null or undefined; if you intended to leave the node untouched, return a NoChange marker instead`); + // FIXME: Figure out a better way to indicate the origin of such an issue, than the current error message format? + // FIXME: Include information on which node this failed for + throw new Error(`[${debuggerName}] A visitor is not allowed to return null or undefined; if you intended to leave the node untouched, return a NoChange marker instead`); } else if (result === RemoveNode) { debuggers[debuggerName](`Node of type '${typeOf(node)}' removed`); return { node: RemoveNode, stateLog: [] }; + } else if (result === ConsumeNode) { + debuggers[debuggerName](`Node of type '${typeOf(node)}' consumed, but its stateLog was left intact`); + stateLog.forEach((item) => { item.isFromConsumedNode = true; }); // NOTE: Mutates! + return { node: ConsumeNode, stateLog: stateLog }; } else if (result.__type === "defer") { if (permitDefer) { debuggers[debuggerName](`Defer was scheduled for node of type '${typeOf(node)}'`); @@ -127,7 +169,7 @@ module.exports = function optimizeTree(ast, optimizers) { if (iterations >= EVALUATION_LIMIT) { throw new Error(`Exceeded evaluation limit in optimizer ${debuggerName}; aborting optimization. If you are a user of raqb, please report this as a bug. If you are writing an optimizer, make sure that your optimizer eventually stabilizes on a terminal condition (ie. NoChange)!`); } else { - return handleNode(result, iterations + 1); + return handleASTNode(result, iterations + 1, path, initialStateLog); } } } else { @@ -135,6 +177,17 @@ module.exports = function optimizeTree(ast, optimizers) { } } + function handleStateLog(newStateLog) { + let [ relevantState, otherState ] = splitFilter(newStateLog, (entry) => handlers.has(entry.name)); + + stateLog = stateLog.concat(otherState); + + for (let item of relevantState) { + // FIXME: Log these, and which visitor they originate from + handlers.call(item.name, item.value); + } + } + function applyVisitorFunction({ visitorName, func, node, permitDefer }) { let { value: result, time } = measureTime(() => { return func(node, { @@ -145,7 +198,12 @@ module.exports = function optimizeTree(ast, optimizers) { stateLog.push({ name, value }); }, registerStateHandler: (name, func) => handlers.add(name, func), - defer: (permitDefer === true) ? defer : null + defer: (permitDefer === true) ? defer : null, + findNearestStep: function (type) { + return (type != null) + ? findLast(path, (item) => item.type === type) + : path[path.length - 1]; + } }); }); @@ -174,28 +232,32 @@ module.exports = function optimizeTree(ast, optimizers) { } } - let childResult = handleNodeChildren(node, handleNode); + let childResult = handleNodeChildren(node, handleASTNode, path); if (Object.keys(childResult.changedProperties).length > 0) { let newNode = deriveNode(node, childResult.changedProperties); // We already know that the new node is a different one, but let's just lead it through the same handleResult process, for consistency. Handling of the pre-child-changes node is aborted here, and we re-evaluate with the new node. - return handleResult({ + let reevaluatedResult = handleResult({ debuggerName: "(subtree change)", result: newNode, - permitDefer: false + permitDefer: false, + // NOTE: If we have any leftover state from nodes that were consumed upstream, we should make sure to include this in the reevaluation, even when the subtree was replaced! + initialStateLog: (childResult.stateLog.length > 0) + ? childResult.stateLog.filter((item) => item.isFromConsumedNode) + : undefined }); + + return reevaluatedResult; } - if (childResult.stateLog.length > 0) { - let [ relevantState, otherState ] = splitFilter(childResult.stateLog, (entry) => handlers.has(entry.name)); - - stateLog = stateLog.concat(otherState); + if (initialStateLog != null) { + // NOTE: We intentionally process the initialStateLog here and not earlier; that way it is consistent with how any retained stateLog entries *would* have executed on the node before it got replaced (ie. after evaluation of the children). Conceptually you can think of it as the initialStateLog being prefixed to the stateLog of the childResult. + handleStateLog(initialStateLog); + } - for (let item of relevantState) { - // FIXME: Log these, and which visitor they originate from - handlers.call(item.name, item.value); - } + if (childResult.stateLog.length > 0) { + handleStateLog(childResult.stateLog); } for (let defer of defers) { @@ -215,7 +277,7 @@ module.exports = function optimizeTree(ast, optimizers) { return handled; } } - + return { stateLog: stateLog, node: node @@ -223,12 +285,12 @@ module.exports = function optimizeTree(ast, optimizers) { } let { value: rootResult, time } = measureTime(() => { - return handleNode(ast); + return handleASTNode(ast); }); let timeSpentInOptimizers = Object.values(timings).reduce((sum, n) => sum + n, 0); - if (rootResult.node !== RemoveNode) { + if (rootResult.node !== RemoveNode && rootResult.node !== ConsumeNode) { return { ast: rootResult.node, timings: { diff --git a/src/compare-strings.js b/src/compare-strings.js new file mode 100644 index 0000000..0fe0f54 --- /dev/null +++ b/src/compare-strings.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = function compareStrings(a, b) { + let aUppercase = a.toUpperCase(); + let bUppercase = b.toUpperCase(); + + if (aUppercase < bUppercase) { + return -1; + } else if (aUppercase > bUppercase) { + return 1; + } else { + return 0; + } +}; diff --git a/src/load-configuration.js b/src/load-configuration.js new file mode 100644 index 0000000..bc52604 --- /dev/null +++ b/src/load-configuration.js @@ -0,0 +1,20 @@ +"use strict"; + +const Promise = require("bluebird"); +const findUp = require("find-up"); + +module.exports = function loadConfiguration(basePath) { + return Promise.try(() => { + return findUp("zapfile.js", { cwd: basePath }); + }).then((configurationPath) => { + if (configurationPath != null) { + return { + configurationPath: configurationPath, + configuration: require(configurationPath) + }; + } else { + // FIXME: Link to configuration documentation + throw new Error(`Unable to find a zapfile; make sure that you've created one in the root of your project`); + } + }); +}; diff --git a/src/merge-map.js b/src/merge-map.js new file mode 100644 index 0000000..7239edb --- /dev/null +++ b/src/merge-map.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = function createMergeMapper({ merge, map }) { + return function mergeMap(items) { + return merge(items.map(map)); + }; +}; diff --git a/src/operations/add-fields.js b/src/operations/add-fields.js index 687e743..f06ab2b 100644 --- a/src/operations/add-fields.js +++ b/src/operations/add-fields.js @@ -7,11 +7,11 @@ const arrayOf = require("@validatem/array-of"); const node = require("../ast-node"); module.exports = function (operations) { - const isSelectionField = require("../validators/operations/is-selection-field")(operations); // FIXME: This needs a more descriptive name. Selectable field? + const isSelectableField = require("../validators/operations/is-selectable-field")(operations); // FIXME: This needs a more descriptive name. Selectable field? return function addFields(_fields) { let [ fields ] = validateArguments(arguments, { - fields: [ required, arrayOf([ required, isSelectionField ]) ] + fields: [ required, arrayOf([ required, isSelectableField ]) ] }); return node({ type: "addFields", fields: fields }); diff --git a/src/operations/table.js b/src/operations/collection.js similarity index 91% rename from src/operations/table.js rename to src/operations/collection.js index da611fc..e02ca3d 100644 --- a/src/operations/table.js +++ b/src/operations/collection.js @@ -8,7 +8,7 @@ const isCollectionName = require("../validators/is-collection-name"); const node = require("../ast-node"); module.exports = function (_operations) { - return function (_name) { + return function collection(_name) { let [ name ] = validateArguments(arguments, { name: [ required, isCollectionName ] }); diff --git a/src/operations/expression.js b/src/operations/expression.js index 0135b39..5b431e5 100644 --- a/src/operations/expression.js +++ b/src/operations/expression.js @@ -7,11 +7,11 @@ const node = require("../ast-node"); module.exports = function (operations) { const isCondition = require("../validators/operations/is-condition")(operations); - const isPossibleRemoteField = require("../validators/operations/is-possibly-remote-field")(operations); + const isPossiblyRemoteField = require("../validators/operations/is-possibly-remote-field")(operations); return function expression(_options) { let { left, condition } = validateOptions(arguments, { - left: [ required, isPossibleRemoteField ], // FIXME: allow sqlExpression and such + left: [ required, isPossiblyRemoteField ], // FIXME: allow sqlExpression and such condition: [ required, isCondition ] }); diff --git a/src/operations/index.js b/src/operations/index.js index 9e1d01c..e7b98f4 100644 --- a/src/operations/index.js +++ b/src/operations/index.js @@ -35,6 +35,9 @@ let operations = { } }; +// FIXME: first() and first({ optional: true }) +// TODO: Boolean subquery operations (UNION, INTERSECT, EXCEPT) with {keepDuplicates} option: https://www.postgresql.org/docs/9.4/queries-union.html + let operationModules = { // Base operations select: require("./select"), @@ -81,7 +84,7 @@ let operationModules = { through: require("./relations/through"), withRelations: require("./relations/with-relations"), define: require("./relations/define"), - resultExists: require("./relations/result-exists"), // FIXME: Better name? + producesResult: require("./relations/produces-result"), linkTo: require("./relations/link-to"), // Misc. @@ -98,6 +101,7 @@ let operationModules = { indexes: require("./schema/indexes"), deleteField: require("./schema/delete-field"), restoreAs: require("./schema/restore-as"), + // FIXME: unsafeRestoreAsNull optional: require("./schema/optional"), defaultTo: require("./schema/default-to"), @@ -111,9 +115,7 @@ let operationModules = { // Index types primaryKey: require("./indexes/primary-key"), index: require("./indexes/index"), - indexWhere: require("./indexes/index-where"), unique: require("./indexes/unique"), - uniqueWhere: require("./indexes/unique-where"), }; Object.assign(module.exports, operations); diff --git a/src/operations/indexes/_make-index-object.js b/src/operations/indexes/_make-index-object.js new file mode 100644 index 0000000..5d5a0fe --- /dev/null +++ b/src/operations/indexes/_make-index-object.js @@ -0,0 +1,26 @@ +"use strict"; + +const matchValue = require("match-value"); + +const node = require("../../ast-node"); + +module.exports = function makeIndexObject(fieldsResult, properties) { + if (fieldsResult.type === "local") { + return node({ + type: "localIndex" + }); + } else { + let isComposite = matchValue(fieldsResult.type, { + single: false, + composite: true + }); + + return node({ + type: "index", + isComposite: isComposite, + field: (isComposite === false) ? fieldsResult.value : undefined, + fields: (isComposite === true) ? fieldsResult.value : undefined, + ... properties + }); + } +}; diff --git a/src/operations/indexes/index.js b/src/operations/indexes/index.js index f527068..d4899a6 100644 --- a/src/operations/indexes/index.js +++ b/src/operations/indexes/index.js @@ -1,11 +1,24 @@ "use strict"; -const node = require("../../ast-node"); +const { validateArguments } = require("@validatem/core"); +const defaultTo = require("@validatem/default-to"); +const arrayOf = require("@validatem/array-of"); -module.exports = function (_operations) { - return function index() { - return node({ - type: "index" +const makeIndexObject = require("./_make-index-object"); + +module.exports = function (operations) { + const isIndexFields = require("../../validators/operations/is-index-fields")(operations); + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function index(_fields, _clauses) { + let [ fields, clauses ] = validateArguments(arguments, { + fields: isIndexFields, + clauses: [ defaultTo([]), arrayOf(isObjectType("where")) ] + }); + + return makeIndexObject(fields, { + clauses: clauses, + indexType: "index" }); }; }; diff --git a/src/operations/indexes/primary-key.js b/src/operations/indexes/primary-key.js index ca1c661..4aa7b09 100644 --- a/src/operations/indexes/primary-key.js +++ b/src/operations/indexes/primary-key.js @@ -1,11 +1,19 @@ "use strict"; -const node = require("../../ast-node"); +const { validateArguments } = require("@validatem/core"); -module.exports = function (_operations) { - return function primaryKey() { - return node({ - type: "primaryKey" +const makeIndexObject = require("./_make-index-object"); + +module.exports = function (operations) { + const isIndexFields = require("../../validators/operations/is-index-fields")(operations); + + return function primaryKey(_fields) { + let [ fields ] = validateArguments(arguments, { + fields: isIndexFields + }); + + return makeIndexObject(fields, { + indexType: "primaryKey" }); }; }; diff --git a/src/operations/indexes/unique-where.js b/src/operations/indexes/unique-where.js deleted file mode 100644 index eeb996b..0000000 --- a/src/operations/indexes/unique-where.js +++ /dev/null @@ -1,21 +0,0 @@ -"use strict"; - -const { validateArguments } = require("@validatem/core"); -const required = require("@validatem/required"); - -const node = require("../../ast-node"); - -module.exports = function (operations) { - return function uniqueWhere(_expression) { - const isExpression = require("../../validators/operations/is-expression")(operations); - - let [ expression ] = validateArguments(arguments, { - expression: [ required, isExpression ] - }); - - return node({ - type: "uniqueIndex", - expression: expression - }); - }; -}; diff --git a/src/operations/indexes/unique.js b/src/operations/indexes/unique.js index 433cee3..407aabf 100644 --- a/src/operations/indexes/unique.js +++ b/src/operations/indexes/unique.js @@ -1,11 +1,24 @@ "use strict"; -const node = require("../../ast-node"); +const { validateArguments } = require("@validatem/core"); +const defaultTo = require("@validatem/default-to"); +const arrayOf = require("@validatem/array-of"); -module.exports = function (_operations) { - return function unique() { - return node({ - type: "uniqueIndex" +const makeIndexObject = require("./_make-index-object"); + +module.exports = function (operations) { + const isIndexFields = require("../../validators/operations/is-index-fields")(operations); + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function unique(_fields, _clauses) { + let [ fields, clauses ] = validateArguments(arguments, { + fields: isIndexFields, + clauses: [ defaultTo([]), arrayOf(isObjectType("where")) ] + }); + + return makeIndexObject(fields, { + clauses: clauses, + indexType: "unique" }); }; }; diff --git a/src/operations/relations/belongs-to.js b/src/operations/relations/belongs-to.js index f7633bc..b7f764e 100644 --- a/src/operations/relations/belongs-to.js +++ b/src/operations/relations/belongs-to.js @@ -6,7 +6,7 @@ const defaultTo = require("@validatem/default-to"); const nestedArrayOf = require("@validatem/nested-array-of"); const flatten = require("../../validators/flatten"); -const node = require("../ast-node"); +const node = require("../../ast-node"); module.exports = function (operations) { const isRelationClause = require("../../validators/operations/is-relation-clause")(operations); diff --git a/src/operations/relations/define.js b/src/operations/relations/define.js new file mode 100644 index 0000000..eac2955 --- /dev/null +++ b/src/operations/relations/define.js @@ -0,0 +1,25 @@ +"use strict"; + +const { validateArguments } = require("@validatem/core"); +const required = require("@validatem/required"); + +const node = require("../../ast-node"); +const isCollectionName = require("../../validators/is-collection-name"); + +module.exports = function (operations) { + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function define(_name, _value) { + // NOTE: `define` essentially creates a virtual collection, which means that the name should comply with the usual collection naming rules + let [ name, value ] = validateArguments(arguments, { + name: [ required, isCollectionName ], + value: [ required, isObjectType("relation") ] // FIXME: Support subqueries + }); + + return node({ + type: "define", + name: name, + value: value + }); + }; +}; diff --git a/src/operations/relations/has.js b/src/operations/relations/has.js index fee234d..991fadb 100644 --- a/src/operations/relations/has.js +++ b/src/operations/relations/has.js @@ -6,7 +6,7 @@ const defaultTo = require("@validatem/default-to"); const nestedArrayOf = require("@validatem/nested-array-of"); const flatten = require("../../validators/flatten"); -const node = require("../ast-node"); +const node = require("../../ast-node"); module.exports = function (operations) { const isRelationClause = require("../../validators/operations/is-relation-clause")(operations); diff --git a/src/operations/relations/link-to.js b/src/operations/relations/link-to.js new file mode 100644 index 0000000..98d8ba1 --- /dev/null +++ b/src/operations/relations/link-to.js @@ -0,0 +1,21 @@ +"use strict"; + +const { validateArguments } = require("@validatem/core"); +const required = require("@validatem/required"); + +const node = require("../../ast-node"); + +module.exports = function (operations) { + const isRemoteField = require("../../validators/operations/is-remote-field")(operations); + + return function linkTo(_field) { + let [ field ] = validateArguments(arguments, { + field: [ required, isRemoteField ] + }); + + return node({ + type: "linkTo", + field: field + }); + }; +}; diff --git a/src/operations/relations/produces-result.js b/src/operations/relations/produces-result.js new file mode 100644 index 0000000..93a6c30 --- /dev/null +++ b/src/operations/relations/produces-result.js @@ -0,0 +1,21 @@ +"use strict"; + +const { validateArguments } = require("@validatem/core"); +const required = require("@validatem/required"); + +const node = require("../../ast-node"); + +module.exports = function (operations) { + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function producesResult(_subquery) { + let [ subquery ] = validateArguments(arguments, { + subquery: [ required, isObjectType("relation") ] // FIXME: Support subqueries + }); + + return node({ + type: "producesResult", + subquery: subquery + }); + }; +}; diff --git a/src/operations/relations/through.js b/src/operations/relations/through.js index 4c1c713..b11f997 100644 --- a/src/operations/relations/through.js +++ b/src/operations/relations/through.js @@ -5,7 +5,7 @@ const required = require("@validatem/required"); const arrayOf = require("@validatem/array-of"); const either = require("@validatem/either"); -const node = require("../ast-node"); +const node = require("../../ast-node"); module.exports = function (operations) { const isObjectType = require("../../validators/operations/is-object-type")(operations); diff --git a/src/operations/relations/with-relations.js b/src/operations/relations/with-relations.js new file mode 100644 index 0000000..b1c49d1 --- /dev/null +++ b/src/operations/relations/with-relations.js @@ -0,0 +1,29 @@ +"use strict"; + +const { validateArguments } = require("@validatem/core"); +const required = require("@validatem/required"); +const anyProperty = require("@validatem/any-property"); + +const node = require("../../ast-node"); +const isLocalFieldName = require("../../validators/is-local-field-name"); + +// NOTE: `withRelations` structurally functions like a `compute`, except the computation value is a has/belongsTo relation specifier, which gets resolved at query time +// FIXME: Actually implement relation fetching logic. Start by generating relational queries after retrieving the initial data, eventually change this to pre-compute most of the relational queries and leave placeholders for table/column/etc. names. Implement the query generation as a stand-alone "generate relational query" function that takes in the current DB schema + relational specifier (+ clauses). + +module.exports = function (operations) { + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function withRelations(_items) { + let [ items ] = validateArguments(arguments, { + items: [ required, anyProperty({ + key: [ required, isLocalFieldName ], // FIXME: Support dot-path notation for nested relation specification? Or let this be handled by a relation's clauses? + value: [ required, isObjectType("relation") ] + })] + }); + + return node({ + type: "withRelations", + items: items + }); + }; +}; diff --git a/src/operations/schema/default-to.js b/src/operations/schema/default-to.js index b56928a..142f5a6 100644 --- a/src/operations/schema/default-to.js +++ b/src/operations/schema/default-to.js @@ -4,13 +4,14 @@ const node = require("../../ast-node"); const { validateArguments } = require("@validatem/core"); const required = require("@validatem/required"); +const forbidRemoteFields = require("../../validators/forbid-remote-fields"); module.exports = function (operations) { - const isLocalValueExpression = require("../../validators/operations/is-local-value-expression")(operations); + const isValueExpression = require("../../validators/operations/is-value-expression")(operations); return function defaultTo(_value) { let [ value ] = validateArguments(arguments, { - value: [ required, isLocalValueExpression ] + value: [ required, isValueExpression, forbidRemoteFields ] // FIXME: Forbid aggregrate functions? }); return node({ diff --git a/src/operations/schema/delete-field.js b/src/operations/schema/delete-field.js new file mode 100644 index 0000000..bd03965 --- /dev/null +++ b/src/operations/schema/delete-field.js @@ -0,0 +1,21 @@ +"use strict"; + +const { validateArguments } = require("@validatem/core"); +const required = require("@validatem/required"); + +const node = require("../../ast-node"); + +module.exports = function (operations) { + const isObjectType = require("../../validators/operations/is-object-type")(operations); + + return function deleteField(_restoreOperation) { + let [ restoreOperation ] = validateArguments(arguments, { + restoreOperation: [ required, isObjectType("restoreAs") ] + }); + + return node({ + type: "deleteField", + restoreOperation: restoreOperation + }); + }; +}; diff --git a/src/operations/schema/indexes.js b/src/operations/schema/indexes.js index d0d6670..441f283 100644 --- a/src/operations/schema/indexes.js +++ b/src/operations/schema/indexes.js @@ -7,11 +7,11 @@ const required = require("@validatem/required"); const arrayOf = require("@validatem/array-of"); module.exports = function (operations) { - const isCompositeIndexType = require("../../validators/operations/schema/is-composite-index-type")(operations); + const isNamedIndexType = require("../../validators/operations/schema/is-named-index-type")(operations); return function indexes(_indexes) { let [ indexes ] = validateArguments(arguments, { - indexes: [ required, arrayOf(isCompositeIndexType) ] + indexes: [ required, arrayOf(isNamedIndexType) ] }); return node({ diff --git a/src/operations/schema/optional.js b/src/operations/schema/optional.js new file mode 100644 index 0000000..20f66da --- /dev/null +++ b/src/operations/schema/optional.js @@ -0,0 +1,11 @@ +"use strict"; + +const node = require("../../ast-node"); + +module.exports = function (_operations) { + return function optional() { + return node({ + type: "optional" + }); + }; +}; diff --git a/src/operations/indexes/index-where.js b/src/operations/schema/restore-as.js similarity index 60% rename from src/operations/indexes/index-where.js rename to src/operations/schema/restore-as.js index 0602913..1f3d8f6 100644 --- a/src/operations/indexes/index-where.js +++ b/src/operations/schema/restore-as.js @@ -6,15 +6,15 @@ const required = require("@validatem/required"); const node = require("../../ast-node"); module.exports = function (operations) { - return function indexWhere(_expression) { - const isExpression = require("../../validators/operations/is-expression")(operations); + const isValueExpression = require("../../validators/operations/is-value-expression")(operations); + return function restoreAs(_expression) { let [ expression ] = validateArguments(arguments, { - expression: [ required, isExpression ] + expression: [ required, isValueExpression ] }); return node({ - type: "index", + type: "restoreAs", expression: expression }); }; diff --git a/src/operations/types/auto-id.js b/src/operations/types/auto-id.js index 2c5ff41..5c13466 100644 --- a/src/operations/types/auto-id.js +++ b/src/operations/types/auto-id.js @@ -6,7 +6,8 @@ module.exports = function (_operations) { // FIXME: Length options? return function autoID() { return node({ - type: "autoIDField" + type: "fieldType", + fieldType: "autoID" }); }; }; diff --git a/src/operations/types/boolean.js b/src/operations/types/boolean.js index 8555908..85793f9 100644 --- a/src/operations/types/boolean.js +++ b/src/operations/types/boolean.js @@ -5,7 +5,8 @@ const node = require("../../ast-node"); module.exports = function (_operations) { return function boolean() { return node({ - type: "booleanField" + type: "fieldType", + fieldType: "boolean" }); }; }; diff --git a/src/operations/types/string.js b/src/operations/types/string.js index 2bb3058..cb1758b 100644 --- a/src/operations/types/string.js +++ b/src/operations/types/string.js @@ -6,7 +6,8 @@ module.exports = function (_operations) { // FIXME: Length options? return function string() { return node({ - type: "stringField" + type: "fieldType", + fieldType: "string" }); }; }; diff --git a/src/operations/types/timestamp.js b/src/operations/types/timestamp.js index 2ebcb31..dd2541c 100644 --- a/src/operations/types/timestamp.js +++ b/src/operations/types/timestamp.js @@ -6,7 +6,8 @@ module.exports = function (_operations) { // FIXME: withTimezone option return function timestamp() { return node({ - type: "timestampField" + type: "fieldType", + fieldType: "timestamp" }); }; }; diff --git a/src/operations/types/uuid.js b/src/operations/types/uuid.js index 9e159bd..179aec6 100644 --- a/src/operations/types/uuid.js +++ b/src/operations/types/uuid.js @@ -5,7 +5,8 @@ const node = require("../../ast-node"); module.exports = function (_operations) { return function uuid() { return node({ - type: "uuidField" + type: "fieldType", + fieldType: "uuid" }); }; }; diff --git a/src/optimizers/schema/index.js b/src/optimizers/schema/index.js new file mode 100644 index 0000000..656104b --- /dev/null +++ b/src/optimizers/schema/index.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = [ + // The optimizers applied to a schema AST are all those for regular queries + the schema-specific ones + ... require("../"), + require("./move-out-indexes") +]; diff --git a/src/optimizers/schema/move-out-indexes.js b/src/optimizers/schema/move-out-indexes.js new file mode 100644 index 0000000..c605597 --- /dev/null +++ b/src/optimizers/schema/move-out-indexes.js @@ -0,0 +1,69 @@ +"use strict"; + +const splitFilter = require("split-filter"); +const matchValue = require("match-value"); + +const operations = require("../../operations"); +const NoChange = require("../util/no-change"); +const ConsumeNode = require("../util/consume-node"); + +/* +Translate index modifiers on a single field into a top-level (non-composite) index element +{ type: "index"|"removeIndex", indexType, isComposite: true|false, field|fields} +*/ + +function handleCollection(node, { registerStateHandler, defer }) { + let createNode = matchValue.literal(node.type, { + createCollectionCommand: operations.createCollection, + changeCollectionCommand: operations.changeCollection + }); + + let indexNodes = []; + + registerStateHandler("encounteredLocalIndex", (item) => { + // FIXME: Make named index + // FIXME: Default name generation for indexes + indexNodes.push(item); + }); + + return defer(() => { + if (indexNodes.length > 0) { + let indexesObject = operations.indexes(indexNodes.map((item) => { + console.log(item); // node, property + })); + + return NoChange; + return createNode(name, operations.concat([ indexesObject ])); + } else { + return NoChange; + } + }); +} + +/* +[ createCollection, operations ] +[ _array, 0 ] +[ schemaFields, fields ] +[ _object, last_activity ] +*/ + + +module.exports = { + name: "move-out-indexes", + category: [ "normalization" ], + visitors: { + localIndex: (node, { setState, findNearestStep }) => { + let { key } = findNearestStep("$object"); + setState("encounteredLocalIndex", { node, key }); + + return ConsumeNode; + }, + createCollectionCommand: handleCollection, + changeCollectionCommand: handleCollection + + + // MARKER: Move indexes from column definitions to table index definition, with auto-generated name; may need some way to select "only within node of type X" (where X = fields) + // IDEA: propertyPath and typePath arguments, for the visitor to determine whether it is appearing in the correct place (otherwise NoChange) + + } +}; diff --git a/src/optimizers/set-collapse-by-fields.js b/src/optimizers/set-collapse-by-fields.js index 6b74820..f0b94cb 100644 --- a/src/optimizers/set-collapse-by-fields.js +++ b/src/optimizers/set-collapse-by-fields.js @@ -175,6 +175,8 @@ module.exports = { ]) }); } + } else { + return NoChange; } }); }, diff --git a/src/optimizers/util/consume-node.js b/src/optimizers/util/consume-node.js new file mode 100644 index 0000000..e140553 --- /dev/null +++ b/src/optimizers/util/consume-node.js @@ -0,0 +1,6 @@ +"use strict"; + +// NOTE: This marker differs from RemoveNode in that it *doesn't* wipe out the state collected by the removed node; that is, it is assumed that the node is "consumed" and the stateLog is the result of that consumption. This is useful for various "meta-operations" which just serve to annotate some other operation with a modifier, and where the meta-operations themselves do not have any representation in the resulting query. In those cases, the meta-operation would be consumed and the parent node updated to reflect the modifier. +// FIXME: Check for existing places in optimizers where nodes are currently left lingering around, that should be consumed instead + +module.exports = Symbol("ConsumeNode"); diff --git a/src/process-schema-update/index.js b/src/process-schema-update/index.js new file mode 100644 index 0000000..eb65c92 --- /dev/null +++ b/src/process-schema-update/index.js @@ -0,0 +1,23 @@ +"use strict"; + +const syncpipe = require("syncpipe"); + +const astToQuery = require("../ast-to-query"); + +const optimizeAST = require("../ast/optimize"); +const optimizers = require("../optimizers/schema"); + +module.exports = function processSchemaUpdate(update) { + return { + ... update, + operations: update.operations.map((operation) => { + // FIXME: This is too simplified. A single operation may result in multiple queries. + return syncpipe(operation, [ + (_) => optimizeAST(_, optimizers), + // (_) => astToQuery(_.ast) + (_) => _.ast + ]); + }) + }; +}; + diff --git a/src/render-schema.js b/src/render-schema.js new file mode 100644 index 0000000..2353d59 --- /dev/null +++ b/src/render-schema.js @@ -0,0 +1,56 @@ +"use strict"; + +const syncpipe = require("syncpipe"); +const table = require("table").table; +const chalk = require("chalk"); + +const unreachable = require("./unreachable"); + +function renderTable(data) { + return table(data, { + border: { + topBody: "", + topJoin: "", + topLeft: "", + topRight: "", + bottomBody: "", + bottomJoin: "", + bottomLeft: "", + bottomRight: "", + bodyLeft: "", + bodyRight: "", + joinLeft: "", + joinRight: "" + } + }); +} + +module.exports = function renderSchema(dbSchema) { + return Object.entries(dbSchema) + .map(([ collectionName, schema ]) => { + let tableData = [ + [ chalk.bold("Column"), chalk.bold("Type"), " " ] + ].concat(syncpipe(schema.fields, [ + (_) => Object.entries(_), + (_) => _.filter(([ _columnName, definition ]) => definition != null), + (_) => _.map(([ fieldName, definition ]) => { + let link = definition.linkTo; + + if (link != null) { + if (link.type === "remoteField") { + let linkedField = dbSchema[link.collectionName].fields[link.fieldName]; + + return [ fieldName, chalk.gray(linkedField.type), `-> ${link.collectionName}.${link.fieldName}` ]; + } else { + unreachable("Non-remoteField link encountered"); + } + } else { + return [ fieldName, definition.type, " " ]; // FIXME: Linked type + } + }) + ])); + + return chalk.bold.green(collectionName) + "\n\n" + renderTable(tableData); + }) + .join("\n\n"); +}; diff --git a/src/schema-generator/index.js b/src/schema-generator/index.js new file mode 100644 index 0000000..de679cc --- /dev/null +++ b/src/schema-generator/index.js @@ -0,0 +1,105 @@ +/* eslint-disable no-loop-func */ +"use strict"; + +const matchValue = require("match-value"); +const mergeByTemplate = require("merge-by-template"); +const mapObj = require("map-obj"); + +const createMergeMapper = require("../merge-map"); + +// FIXME: Move all the console.logs out of here, return some sort of action log instead? For display by whatever is applying/displaying the schema. Or is this not necessary as we will be separately handling the mutations anyway? +// FIXME: Ensure that the actual migration handling code always looks at a generated schema *at the revision being processed* as a reference, not whatever the target revision is! Otherwise the wrong values may end up being combined. +// FIXME: Ensure that the schema has been optimized first! +// FIXME: Track schemaAfter for every individual schema update + keep a log of changes for each update, so that we have all the information needed to generate SQL queries + look up schema state in the at-that-revision schema where needed for schema operations that depend on previous state +// MARKER: Generate operations list + +function setOnce(a, b) { + if (a === undefined) { + return b; + } else { + throw new Error(`Value cannot be overridden`); + } +} + +let mergeFieldSchema = mergeByTemplate.createMerger({ + type: setOnce, // FIXME: Also disallow combination with linkTo +}); + +let mergeCollectionSchema = mergeByTemplate.createMerger({ + fields: mergeByTemplate.anyProperty(mergeFieldSchema) +}); + +let mergeSchema = mergeByTemplate.createMerger( + mergeByTemplate.anyProperty(mergeCollectionSchema) +); + +// FIXME: Figure out a way to do this with merge-by-template +let fieldDefaults = { + optional: false +}; + +let mapFieldOperations = createMergeMapper({ + merge: mergeFieldSchema, + map: (operation) => matchValue(operation.type, { + fieldType: () => ({ type: operation.fieldType }), + optional: () => ({ optional: true }), + required: () => ({ optional: false }), + defaultTo: () => ({ defaultTo: operation.value }), // FIXME: Check that this does not need any post-processing + linkTo: () => ({ linkTo: operation.field }), // FIXME: Actually extract the related information (eg. column type) from the schema afterwards, but *before* sanity checks + deleteField: () => mergeByTemplate.DeleteValue, + index: () => undefined, // FIXME: Move these out into `indexes` as an optimizer step + }) +}); + +let mapCollectionOperations = createMergeMapper({ + merge: mergeCollectionSchema, + map: (operation) => matchValue(operation.type, { + schemaFields: () => ({ + fields: mapObj(operation.fields, (key, value) => { + return [ key, mapFieldOperations(value) ]; + }) + }), + }) +}); + +// NOTE: We set consumeDeleteNodes to false in various places below; this is because we first convert each series of operations in a schema update to a cumulative update, and then merge that cumulative update to the final schema. This means that there are *two* merge operations, and by only allowing DeleteValue nodes to be consumed in the second merge operation, we avoid the situation where the first merge operation would simply return 'undefined' for something and this would be (wrongly) interpreted by the second merge operation to mean "no changes made". + +module.exports = function generateSchema(updates) { + let builtSchema = {}; + + for (let update of updates) { + for (let operation of update.operations) { + matchValue(operation.type, { + createCollectionCommand: () => { + if (builtSchema[operation.name] == null) { + let collectionOperations = mapCollectionOperations(operation.operations); + + builtSchema = mergeSchema([ builtSchema, { + [operation.name]: collectionOperations + }]); + } else { + throw new Error(`Cannot create collection '${operation.name}' because it already exists; maybe you meant to use changeCollection instead?`); + } + }, + changeCollectionCommand: () => { + if (builtSchema[operation.name] != null) { + let collectionOperations = mapCollectionOperations(operation.operations, builtSchema[operation.name]); + + builtSchema = mergeSchema([ builtSchema, { + [operation.name]: collectionOperations + }]); + } else { + throw new Error(`Cannot change collection '${operation.name}' because it does not exist; maybe you meant to use createCollection instead?`); + } + }, + deleteCollectionCommand: () => { + builtSchema = mergeSchema([ builtSchema, { + [operation.name]: undefined + }]); + }, + }); + } + } + + return builtSchema; +}; diff --git a/src/schema-providers/fs.js b/src/schema-providers/fs.js new file mode 100644 index 0000000..bebb649 --- /dev/null +++ b/src/schema-providers/fs.js @@ -0,0 +1,87 @@ +"use strict"; + +const Promise = require("bluebird"); +const fs = require("fs").promises; +const path = require("path"); +const dateFns = require("date-fns"); +const slug = require("slug"); + +const compareStrings = require("../compare-strings"); + +let suffixRegex = /^(.+)\.js$/; +let filenameRegex = /^(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})_(.+)$/; +let templateFolder = path.resolve(__dirname, "../templates"); + +module.exports = function ({ configurationPath, configuration }) { + let schemaUpdateFolder = path.resolve(path.dirname(configurationPath), configuration.schema.fs.root); + + return { + create: function ({ description }) { + return Promise.try(() => { + let timestamp = dateFns.format(new Date(), "yyyy-MM-dd_hh-mm-ss"); + let destinationFilename = `${timestamp}_${slug(description)}.js`; + + let sourceTemplate = path.join(templateFolder, "schema-update.js"); + let destinationPath = path.join(schemaUpdateFolder, destinationFilename); + + return Promise.try(() => { + return fs.copyFile(sourceTemplate, destinationPath); + }).then(() => { + return { + statusMessage: `New schema update created at ${destinationPath}` + }; + }); + }); + }, + getAll: function () { + return Promise.try(() => { + return fs.readdir(schemaUpdateFolder); + }).then((schemaFiles) => { + let seenDates = new Set(); + + return schemaFiles + .map((filename) => { + let match = suffixRegex.exec(filename); + + if (match != null) { + let basename = match[1]; + let parsed = filenameRegex.exec(basename); + + if (parsed != null) { + let [ _, date, description ] = parsed; + + if (!seenDates.has(date)) { + seenDates.add(date); + + return { + filename: filename, + timestamp: date, + description: description + }; + } else { + // FIXME: Link to docs explaining this + throw new Error(`Encountered timestamp prefix twice: ${date} -- this is not allowed, change one of the timestamps to indicate the desired order.`); + } + } else { + // FIXME: Link to docs explaining this + throw new Error(`Filename does not match the expected format: ${filename}`); + } + } else { + throw new Error(`The schema folder must only contain .js files; encountered ${filename}`); + } + }) + .sort((a, b) => { + return compareStrings(a.timestamp, b.timestamp); + }) + .map((item) => { + return { + timestamp: item.timestamp, // NOTE: This is a unique sortable ID, that is used to identify the schema update in the internal schema state + description: item.description, + operations: require(path.join(schemaUpdateFolder, item.filename)) + }; + }); + }); + // FIXME: Error case handling + } + }; +}; diff --git a/src/templates/schema-update.js b/src/templates/schema-update.js new file mode 100644 index 0000000..25dc4e1 --- /dev/null +++ b/src/templates/schema-update.js @@ -0,0 +1,7 @@ +"use strict"; + +const { createCollection, changeCollection, deleteCollection } = require("zapdb"); + +module.exports = [ + /* your schema update operations go here */ +]; diff --git a/src/validators/forbid-remote-fields.js b/src/validators/forbid-remote-fields.js new file mode 100644 index 0000000..7807a4a --- /dev/null +++ b/src/validators/forbid-remote-fields.js @@ -0,0 +1,10 @@ +"use strict"; + +const containsRemoteFields = require("../ast/contains-remote-fields"); +const ValidationError = require("@validatem/error"); + +module.exports = function forbidRemoteFields(value) { + if (containsRemoteFields(value)) { + throw new ValidationError(`Must not reference any remote fields`); + } +}; diff --git a/src/validators/operations/is-condition-value.js b/src/validators/operations/is-condition-value.js index 111dd08..7594c2e 100644 --- a/src/validators/operations/is-condition-value.js +++ b/src/validators/operations/is-condition-value.js @@ -1,9 +1,6 @@ "use strict"; const either = require("@validatem/either"); -const wrapError = require("@validatem/wrap-error"); - -const isLiteralValue = require("../is-literal-value"); // NOTE: This validator should typically come last in an `either`, since it will catch various types of inputs (sqlExpression, literal values, etc.) that might need to be interpreted differently in specific contexts. // FIXME: Rename valueExpression -> value? @@ -13,8 +10,8 @@ module.exports = function (operations) { const isObjectType = require("./is-object-type")(operations); return either([ - isValueExpression, [ isObjectType("anyOfValues") ], - [ isObjectType("allOfValues") ] + [ isObjectType("allOfValues") ], + isValueExpression ]); }; diff --git a/src/validators/operations/is-index-fields.js b/src/validators/operations/is-index-fields.js new file mode 100644 index 0000000..dcba987 --- /dev/null +++ b/src/validators/operations/is-index-fields.js @@ -0,0 +1,19 @@ +"use strict"; + +const defaultTo = require("@validatem/default-to"); +const either = require("@validatem/either"); +const arrayOf = require("@validatem/array-of"); + +const tagAsType = require("../../validators/tag-as-type"); + +module.exports = function (operations) { + const isField = require("./is-field")(operations); + + return [ + either([ + [ isField, tagAsType("single") ], + [ arrayOf(isField), tagAsType("composite") ], + ]), + defaultTo({ type: "local" }), + ]; +}; diff --git a/src/validators/operations/is-local-value-expression.js b/src/validators/operations/is-local-value-expression.js deleted file mode 100644 index 26822cb..0000000 --- a/src/validators/operations/is-local-value-expression.js +++ /dev/null @@ -1,19 +0,0 @@ -"use strict"; - -const either = require("@validatem/either"); - -const isLiteralValue = require("../is-literal-value"); - -// NOTE: This validator should typically come last in an `either`, since it will catch various types of inputs (sqlExpression, literal values, etc.) that might need to be interpreted differently in specific contexts. - -module.exports = function (operations) { - const isObjectType = require("./is-object-type")(operations); - const wrapWithOperation = require("./wrap-with-operation")(operations); - - return either([ - [ isObjectType("sqlExpression") ], - [ isObjectType("literalValue") ], - [ isObjectType("field") ], - [ isLiteralValue, wrapWithOperation("value") ] - ]); -}; diff --git a/src/validators/operations/is-value-expression.js b/src/validators/operations/is-value-expression.js index 9852839..5142274 100644 --- a/src/validators/operations/is-value-expression.js +++ b/src/validators/operations/is-value-expression.js @@ -12,14 +12,22 @@ module.exports = function (operations) { const isObjectType = require("./is-object-type")(operations); const isAnyFieldObject = require("./is-any-field-object")(operations); const isAggregrateFunction = require("./is-aggregrate-function")(operations); + const isValueFunction = require("./is-value-function")(operations); const wrapWithOperation = require("./wrap-with-operation")(operations); return wrapError("Must be a type of value", either([ + // Subqueries + [ isObjectType("producesResult") ], + // Raw SQL [ isObjectType("sqlExpression") ], - [ isObjectType("literalValue") ], - [ isObjectType("placeholder") ], // TODO: Verify that this also works for the `alias` method + // Functions + [ isValueFunction ], // FIXME: Is it possible for these to be invalid when collapsing, eg. when referencing non-collapsed columns? [ isAggregrateFunction ], // FIXME: Make sure to check that this is only permitted when collapsing + // Field references [ isAnyFieldObject ], + // Plain values + [ isObjectType("literalValue") ], + [ isObjectType("placeholder") ], // TODO: Verify that this also works for the `alias` method [ isLiteralValue, wrapWithOperation("value") ] ])); }; diff --git a/src/validators/operations/is-value-function.js b/src/validators/operations/is-value-function.js new file mode 100644 index 0000000..636577e --- /dev/null +++ b/src/validators/operations/is-value-function.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = function (operations) { + const isObjectType = require("./is-object-type")(operations); + + return isObjectType("sqlFunction"); +}; diff --git a/src/validators/operations/schema/is-composite-index-type.js b/src/validators/operations/schema/is-composite-index-type.js deleted file mode 100644 index 5d85aae..0000000 --- a/src/validators/operations/schema/is-composite-index-type.js +++ /dev/null @@ -1,17 +0,0 @@ -"use strict"; - -const wrapError = require("@validatem/wrap-error"); -const ValidationError = require("@validatem/error"); - -module.exports = function (operations) { - const isIndexType = require("./is-index-type")(operations); - - return wrapError("Must be a composite index type", [ - isIndexType, - (node) => { - if (node.composite !== true) { - return new ValidationError(`Must be a composite index`); - } - } - ]); -}; diff --git a/src/validators/operations/schema/is-field-type.js b/src/validators/operations/schema/is-field-type.js index 8bfbf7b..cb30b42 100644 --- a/src/validators/operations/schema/is-field-type.js +++ b/src/validators/operations/schema/is-field-type.js @@ -1,16 +1,9 @@ "use strict"; -const either = require("@validatem/either"); const wrapError = require("@validatem/wrap-error"); module.exports = function (operations) { const isObjectType = require("../is-object-type")(operations); - return wrapError("Must be a field type", either([ - isObjectType("autoIDField"), - isObjectType("stringField"), - isObjectType("timestampField"), - isObjectType("booleanField"), - isObjectType("uuidField"), - ])); + return wrapError("Must be a field type", isObjectType("fieldType")); }; diff --git a/src/validators/operations/schema/is-fields-object.js b/src/validators/operations/schema/is-fields-object.js index 93dca97..15c0b41 100644 --- a/src/validators/operations/schema/is-fields-object.js +++ b/src/validators/operations/schema/is-fields-object.js @@ -4,24 +4,27 @@ const either = require("@validatem/either"); const wrapError = require("@validatem/wrap-error"); const anyProperty = require("@validatem/any-property"); const required = require("@validatem/required"); +const ensureArray = require("@validatem/ensure-array"); +const arrayOf = require("@validatem/array-of"); const isLocalFieldName = require("../../is-local-field-name"); module.exports = function (operations) { const isObjectType = require("../is-object-type")(operations); const isFieldType = require("./is-field-type")(operations); - const isIndexType = require("./is-index-type")(operations); + const isLocalIndexType = require("./is-local-index-type")(operations); + let isItem = either([ + isFieldType, + isLocalIndexType, + isObjectType("linkTo"), + isObjectType("defaultTo"), + isObjectType("optional"), + // FIXME: Only allow deleteField/restoreAs for changeCollection, not createCollection + isObjectType("deleteField") + ]); + return wrapError("Must be an object of schema fields", anyProperty({ key: [ required, isLocalFieldName ], - value: [ required, either([ - isFieldType, - isIndexType, - isObjectType("belongsTo"), // FIXME: Correct type? Need to distinguish between reference and definition usage (local vs. remote field name) - isObjectType("defaultTo"), - isObjectType("optional"), - // FIXME: Only allow deleteField/restoreAs for changeCollection, not createCollection - isObjectType("deleteField"), - isObjectType("restoreAs"), - ])] + value: [ required, ensureArray, arrayOf(isItem) ] }), { preserveOriginalErrors: true }); }; diff --git a/src/validators/operations/schema/is-index-type.js b/src/validators/operations/schema/is-index-type.js deleted file mode 100644 index 921be58..0000000 --- a/src/validators/operations/schema/is-index-type.js +++ /dev/null @@ -1,15 +0,0 @@ -"use strict"; - -const either = require("@validatem/either"); -const wrapError = require("@validatem/wrap-error"); - -module.exports = function (operations) { - const isObjectType = require("../is-object-type")(operations); - - return wrapError("Must be an index type", either([ - isObjectType("index"), - isObjectType("indexWhere"), - isObjectType("unique"), - isObjectType("uniqueWhere"), - ])); -}; diff --git a/src/validators/operations/schema/is-local-index-type.js b/src/validators/operations/schema/is-local-index-type.js new file mode 100644 index 0000000..c763b7d --- /dev/null +++ b/src/validators/operations/schema/is-local-index-type.js @@ -0,0 +1,9 @@ +"use strict"; + +const wrapError = require("@validatem/wrap-error"); + +module.exports = function (operations) { + const isObjectType = require("../is-object-type")(operations); + + return wrapError("Must be an index type (without specifying a field name)", isObjectType("localIndex")); +}; diff --git a/src/validators/operations/schema/is-named-index-type.js b/src/validators/operations/schema/is-named-index-type.js new file mode 100644 index 0000000..885c643 --- /dev/null +++ b/src/validators/operations/schema/is-named-index-type.js @@ -0,0 +1,9 @@ +"use strict"; + +const wrapError = require("@validatem/wrap-error"); + +module.exports = function (operations) { + const isObjectType = require("../is-object-type")(operations); + + return wrapError("Must be an index type (specifying one or more field names)", isObjectType("index")); +}; diff --git a/yarn.lock b/yarn.lock index ace0595..8fdcb28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -934,7 +934,7 @@ "@validatem/is-array" "^0.1.0" "@validatem/validation-result" "^0.1.1" -"@validatem/combinator@^0.1.0", "@validatem/combinator@^0.1.1": +"@validatem/combinator@^0.1.0", "@validatem/combinator@^0.1.1", "@validatem/combinator@^0.1.2": version "0.1.2" resolved "https://registry.yarnpkg.com/@validatem/combinator/-/combinator-0.1.2.tgz#eab893d55f1643b9c6857eaf6ff7ed2a728e89ff" integrity sha512-vE8t1tNXknmN62FlN6LxQmA2c6TwVKZ+fl/Wit3H2unFdOhu7SZj2kRPGjAXdK/ARh/3svYfUBeD75pea0j1Sw== @@ -984,6 +984,11 @@ "@validatem/validation-result" "^0.1.2" flatten "^1.0.3" +"@validatem/ensure-array@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@validatem/ensure-array/-/ensure-array-0.1.0.tgz#4903bcc557964377ad6eb060f1fe030786b982ed" + integrity sha512-OPP8BDm2PhmMfxgozVd61W9J57Irksz0IxKWbS3wQVrk/J6MPRX6oxlcCNyFDovsFGmJlIoKgfVrhQuNSo/6nQ== + "@validatem/error@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@validatem/error/-/error-1.1.0.tgz#bef46e7066c39761b494ebe3eec2ecdc7348f4ed" @@ -1011,7 +1016,7 @@ default-value "^1.0.0" flatten "^1.0.3" -"@validatem/is-array@^0.1.0": +"@validatem/is-array@^0.1.0", "@validatem/is-array@^0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@validatem/is-array/-/is-array-0.1.1.tgz#fbe15ca8c97c30b622a5bbeb536d341e99cfc2c5" integrity sha512-XD3C+Nqfpnbb4oO//Ufodzvui7SsCIW/stxZ39dP/fyRsBHrdERinkFATH5HepegtDlWMQswm5m1XFRbQiP2oQ== @@ -1141,6 +1146,11 @@ dependencies: "@validatem/error" "^1.0.0" +"@validatem/remove-nullish-items@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@validatem/remove-nullish-items/-/remove-nullish-items-0.1.0.tgz#fe1a8b64d11276b506fae2bd2c41da4985a5b5ff" + integrity sha512-cs4YSF47TA/gHnV5muSUUqGi5PwybP5ztu5SYnPKxQVTyubvcbrFat51nOvJ2PmUasyrIccoYMmATiviXkTi6g== + "@validatem/require-either@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@validatem/require-either/-/require-either-0.1.0.tgz#250e35ab06f124ea90f3925d74b5f53a083923b0" @@ -1190,6 +1200,15 @@ default-value "^1.0.0" split-filter-n "^1.1.2" +"@validatem/wrap-path@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@validatem/wrap-path/-/wrap-path-0.1.0.tgz#777998b62d3e74f2b2897c992dae9b3675161c33" + integrity sha512-6hOqydnr4u8FA0iRv8fyXxsr64T99+w/XL/fixmsgN0uqulEIwGMxCre3y9YkFNcEtysyPHkQl0CrGPcASsZxw== + dependencies: + "@validatem/annotate-errors" "^0.1.2" + "@validatem/combinator" "^0.1.2" + "@validatem/validation-result" "^0.1.2" + JSONStream@^1.0.3: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -1255,6 +1274,16 @@ ajv@^6.10.0, ajv@^6.10.2: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.12.4: + version "6.12.5" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da" + integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ansi-align@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb" @@ -1440,6 +1469,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + astw@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/astw/-/astw-2.2.0.tgz#7bd41784d32493987aeb239b6b4e1c57a873b917" @@ -2270,6 +2304,11 @@ dash-ast@^1.0.0: resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37" integrity sha512-Vy4dx7gquTeMcQR/hDkYLGUnwVil6vk4FOOct+djUnHOUWt+zJPJAaRIXaAFkPXtJjvlY7o3rfRu0/3hpnwoUA== +date-fns@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.16.1.tgz#05775792c3f3331da812af253e1a935851d3834b" + integrity sha512-sAJVKx/FqrLYHAQeN7VpJrPhagZc9R4ImZIWYRFZaaohR3KzmuK88touwsSwSVT8Qcbd4zoDsnGfX4GFB4imyQ== + debounce@^1.0.0, debounce@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" @@ -2973,6 +3012,18 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" +find-last-index@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/find-last-index/-/find-last-index-1.0.0.tgz#df4d76de9704b3c944f77cb27575822d1e2205c2" + integrity sha512-aPYT8fnE/EegRBn+YBf08NFtjdwCJsL7FiYmy+G3VoN0R4BJ1Q4zAmb7eEbTuASgJmeXh6s8d5+Rlmd980+Q+g== + +find-last@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/find-last/-/find-last-1.0.0.tgz#75f7ede230aa19df956da021de3e799da6252207" + integrity sha512-xK6JsdhjNzFRwPiBEG4ObfWJdl8f8BF4Ve5HNmiX7PYGvadoqxONc0F7m9HSIE0pHEx9pDmdTY4CAFG1QyoPTQ== + dependencies: + find-last-index "^1.0.0" + find-up@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" @@ -2981,6 +3032,14 @@ find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + flat-cache@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" @@ -3037,6 +3096,11 @@ from2@^2.0.3: inherits "^2.0.1" readable-stream "^2.0.0" +fromentries@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.2.1.tgz#64c31665630479bc993cd800d53387920dc61b4d" + integrity sha512-Xu2Qh8yqYuDhQGOhD5iJGninErSfI9A3FrriD3tjUgV5VbJFeH8vfgZ9HnC6jWN80QDVNQK5vmxRAmEAp7Mevw== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -3655,6 +3719,11 @@ is-plain-obj@^2.0.0, is-plain-obj@^2.1.0: resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== +is-plain-obj@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" + integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== + is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -3886,6 +3955,13 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash.memoize@~3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" @@ -3896,6 +3972,11 @@ lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.4: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.20: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -3972,6 +4053,22 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= +merge-by-template@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/merge-by-template/-/merge-by-template-0.1.3.tgz#1df332b60a26bce29134ad6850edb78293ba9e26" + integrity sha512-C4/phAKvNs47OZnA/8WKLJg/G7ywllaRB9W2b6wKGCjFTMvuGO+63HalSB3MECe79W2uQ8waC0UNPPSZDZNGNg== + dependencies: + "@validatem/core" "^0.3.3" + "@validatem/default-to" "^0.1.0" + "@validatem/is-array" "^0.1.1" + "@validatem/is-plain-object" "^0.1.1" + "@validatem/remove-nullish-items" "^0.1.0" + "@validatem/virtual-property" "^0.1.0" + "@validatem/wrap-path" "^0.1.0" + default-value "^1.0.0" + fromentries "^1.2.0" + range "^0.0.3" + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -4364,6 +4461,13 @@ p-limit@^2.2.0: dependencies: p-try "^2.0.0" +p-limit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe" + integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg== + dependencies: + p-try "^2.0.0" + p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -4371,6 +4475,13 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -4774,6 +4885,11 @@ range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== +range@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/range/-/range-0.0.3.tgz#b5b8eb2463a516b624a563bd32b18fe89e70151b" + integrity sha1-tbjrJGOlFrYkpWO9MrGP6J5wFRs= + raw-body@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" @@ -5270,6 +5386,20 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +slug@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/slug/-/slug-3.3.4.tgz#bde453bee60587505f312c68738b8df21e5d388f" + integrity sha512-VpHbtRCEWmgaZsrZcTsVl/Dhw98lcrOYDO17DNmJCNpppI6s3qJvnNu2Q3D4L84/2bi6vkW40mjNQI9oGQsflg== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -5584,6 +5714,16 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +table@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/table/-/table-6.0.3.tgz#e5b8a834e37e27ad06de2e0fda42b55cfd8a0123" + integrity sha512-8321ZMcf1B9HvVX/btKv8mMZahCjn2aYrDlpqHaBFCfnox64edeH9kEid0vTLTRR8gWR2A20aDgeuTTea4sVtw== + dependencies: + ajv "^6.12.4" + lodash "^4.17.20" + slice-ansi "^4.0.0" + string-width "^4.2.0" + term-color@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/term-color/-/term-color-1.0.1.tgz#38e192553a473e35e41604ff5199846bf8117a3a"