"use strict"; const syncpipe = require("syncpipe"); const splitFilter = require("split-filter"); const asExpression = require("as-expression"); const NoChange = require("./util/no-change"); const deriveNode = require("../derive-node"); const operations = require("../operations"); const typeOf = require("../type-of"); const unreachable = require("../unreachable"); const concat = require("../concat"); const merge = require("../merge"); const uniqueByPredicate = require("../unique-by-predicate"); // FIXME: Support for remote field names function uniqueFields(fields) { return uniqueByPredicate(fields, (field) => field.name); } /* valid fields when collapsing: - fields that appear in the collapseBy field list, within or without a hierarchical wrapper - any field that is wrapped in an aggregrate function of some sort */ module.exports = { name: "set-collapse-by-fields", category: [ "normalization" ], visitors: { collapseBy: (_node, { setState }) => { setState("isCollapsing", true); return NoChange; }, field: (node, { setState }) => { setState("fieldSeen", node); return NoChange; }, // FIXME: Think of a generic way to express "only match fields under this specific child property" (+ direct descendants also?) -- maybe (node, property, parentNode) signature? collapseByFields: (node, { registerStateHandler, setState, defer }) => { let fields = []; registerStateHandler("fieldSeen", (node) => { fields.push(node); }); return defer(() => { setState("setCollapsedFields", fields); return NoChange; }); }, addFields: (node, { setState }) => { setState("setAddFields", node.fields); return NoChange; }, onlyFields: (node, { setState }) => { setState("setOnlyFields", node.fields); return NoChange; }, aggregrateFunction: (node, { registerStateHandler }) => { // FIXME: Also report isCollapsing here, due to aggregrate function use, but make sure that the error describes this as the (possible) cause return NoChange; }, compute: (node, { setState }) => { setState("computeSeen", node); return NoChange; }, select: (node, { registerStateHandler, defer }) => { let isCollapsing; let onlyFields = []; let addFields = []; let computes = []; let collapsedFields; registerStateHandler("isCollapsing", (value) => { isCollapsing = isCollapsing || value; }); registerStateHandler("setCollapsedFields", (fields) => { if (collapsedFields == null) { collapsedFields = fields; } else { throw new Error(`You can currently only specify a single 'collapseBy' clause. Please file an issue if you have a reason to need more than one!`); } }); registerStateHandler("setOnlyFields", (fields) => { onlyFields = onlyFields.concat(fields); }); registerStateHandler("setAddFields", (fields) => { addFields = addFields.concat(fields); }); registerStateHandler("computeSeen", (node) => { computes.push(node); }); return defer(() => { if (isCollapsing) { if (addFields.length > 0) { let extraFieldNames = addFields.map((field) => field.name); throw new Error(`You tried to add extra fields (${extraFieldNames.join(", ")}) in your query, but this is not possible when using collapseBy. See [FIXME: link] for more information, and how to solve this.`); } else if (onlyFields.length > 0) { // NOTE: This can happen either because the user specified an onlyFields clause, *or* because a previous run of this optimizer did so! let uniqueSelectedFields = uniqueFields(onlyFields); let collapsedFieldNames = collapsedFields.map((field) => field.name); let invalidFieldSelection = uniqueSelectedFields.filter((node) => { let isAggregrateComputation = typeOf(node) === "alias" && typeOf(node.expression) === "aggregrateFunction"; let isCollapsedField = typeOf(node) === "field" && collapsedFieldNames.includes(node.name); let isValid = isAggregrateComputation || isCollapsedField; return !isValid; }); // FIXME: We can probably optimize this by marking the optimizer-created onlyFields as inherently-valid, via some sort of node metadata mechanism if (invalidFieldSelection.length > 0) { let invalidFieldNames = invalidFieldSelection.map((item) => { let fieldType = typeOf(item); if (fieldType === "field") { return item.name; } else if (fieldType === "alias") { // FIXME: Show alias target instead of field name here? return item.field.name; } else { return unreachable(`Encountered '${fieldType}' node in invalid fields`); } }); throw new Error(`You tried to include one or more field in your query (${invalidFieldNames.join(", ")}), that are not used in a collapseBy clause or aggregrate function. See [FIXME: link] for more information.`); } else { return NoChange; } } else { // TODO: Move compute -> alias/withResult conversion out into its own optimizer eventually, as it's a separate responsibility let [ withResultComputes, aliasComputes ] = splitFilter(computes, (node) => typeof node.expression === "function"); let computeAliases = aliasComputes.map((node) => { return operations.alias(node.field, node.expression); }); let withResultsOperations = asExpression(() => { if (withResultComputes.length > 0) { return operations.withResult((item) => { // FIXME: Remote fields support let computedFields = syncpipe(withResultComputes, [ (_) => _.map((compute) => { let fieldName = compute.field.name; return [ fieldName, compute.expression(item[fieldName]) ]; }), (_) => Object.fromEntries(_) ]); return merge(item, computedFields); }); } else { return []; } }); return deriveNode(node, { clauses: node.clauses.concat([ operations.onlyFields(concat([ collapsedFields, computeAliases ])), withResultsOperations ]) }); } } else { return NoChange; } }); }, } }; // FIXME: a ConsumeNode marker, like RemoveNode but it does not invalidate that node's state... may need to actually make it a reference, so that a parent node can decide whether to consume that node. Basically passing a "consume this node" function as a state value, that correctly internally triggers the optimizer infrastructure to change the tree as a result. // FIXME: Consume the compute nodes, and have an optimizer that removes empty computeMultiple nodes