UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

509 lines (441 loc) 16.6 kB
'use strict'; const { setProp } = require('../base/model'); const { applyTransformations, isDollarSelfOrProjectionOperand, implicitAs } = require('../model/csnUtils'); const { isBuiltinType } = require('../base/builtins'); const { condAsTree } = require('../model/xprAsTree'); const { pathToMessageString } = require('../base/messages'); const { pathId } = require('../model/csnRefs'); const { cloneCsnNonDict } = require('../model/cloneCsn'); /** * All relational operators supported by tuple expansion. * Also includes `is not`, which is actually two tokens. * * @type {string[]} */ const RelationalOperators = [ '=', '<>', '==', '!=', 'is', 'is not' ]; /** * Operators that to be used to combine expanded expressions by keeping logical relations. */ const OperatorCombinator = { __proto__: null, '=': 'and', '==': 'and', '<>': 'or', '!=': 'or', is: 'and', 'is not': 'or', }; /** * Get transformation functions for "tuple expansion", i.e. for expanding * structure references in expressions. * * @param {CSN.Model} csn * @param {object} csnUtils * @param {object} msgFunctions * * @returns {object} Object containing functions `expandStructsInExpression` and `flattenPath` */ function tupleExpansion(csn, csnUtils, msgFunctions) { const { message, error, info } = msgFunctions; const { inspectRef, effectiveType, resolvePath } = csnUtils; return { expandStructsInExpression, flattenPath, }; /** * Expand structured expression arguments to flat reference paths. * Structured elements are real sub element lists and managed associations. * All unmanaged association definitions are rewritten if applicable (elements/mixins). * Also, HAVING and WHERE clauses are rewritten. * We also check for infix filters and `.xpr` in columns. * * @param {object} [traversalOptions={}] "skipArtifact": (artifact, name) => Boolean to skip certain artifacts */ function expandStructsInExpression( traversalOptions = {} ) { applyTransformations(csn, { on: expandExpr, having: expandExpr, where: expandExpr, xpr: expandExpr, list: (parent, name, args, path) => { // Don't iterate `group by (foo, bar)` if (path.at(-2) !== 'groupBy' && path.at(-2) !== 'orderBy') expandExpr(parent, name, args, path); }, args: (parent, name, args, path) => { if (!parent.id && !parent.func) return; // ensure we're not in JOIN if (Array.isArray(parent.args)) { expandExpr(parent, name, args, path); return; } for (const argName in parent.args) // named arguments rejectAnyDirectStructureReference([ parent.args[argName] ], path.concat(name, argName)); }, }, [], traversalOptions); } function expandExpr(parent, name, _xpr, path) { // We need to structurize the _model_, as error locations may otherwise point to non-existent paths. // Note: structurizer does not go into nested `xpr`. parent[name] = condAsTree(parent[name]); parent[name] = expandStructurizedExpr(parent[name], path.concat(name)); parent[name] = parent[name].flat(Infinity); // tokenize again } function expandStructurizedExpr(expr, location) { if (!Array.isArray(expr)) return expr; // don't traverse strings, etc. expr = expr.map((e, i) => expandStructurizedExpr(e, location.concat(i))); if (expr.length === 3 && typeof expr[1] === 'string') // also includes `<lhs> is null` return expandBinaryOp(expr[0], [ expr[1] ], expr[2], location) ?? expr; if (expr.length === 4 && expr[1] === 'is' && expr[2] === 'not' && expr[3] === 'null') return expandBinaryOp(expr[0], [ expr[1], expr[2] ], expr[3], location) ?? expr; // expr.length is either <3 or >=4, in which case we reject all structure references // in the array, but still traverse sub-arrays (the expression is structurized, after all). rejectAnyDirectStructureReference(expr, location); return expr; } /** * Expands the binary operation `lhs <op> rhs`, where `op` may be one or more tokens, * e.g. `is not` for `is not null`. * * @param {object} lhs * @param {string[]} operators * @param {object} rhs * @param location * @returns {object[]|null} In case of errors, returns `null`. */ function expandBinaryOp(lhs, operators, rhs, location) { const lhsArt = lhs._art || lhs.ref && enrichRef(lhs, location.concat(0)); const rhsArt = rhs._art || rhs.ref && enrichRef(rhs, location.concat(2)); if (!lhsArt && !rhsArt || !isExpandable(lhsArt) && !isExpandable(rhsArt)) return null; // no structure to expand if (isDollarSelfOrProjectionOperand(lhs) || isDollarSelfOrProjectionOperand(rhs)) { // if either side is bare `$self`, the compiler has handled it already return null; } // At least one side is expandable. That means, if we can't expand a structural reference // starting here, we _must_ emit an error. const opStr = operators.join(' '); if (!RelationalOperators.includes(opStr)) { message('expr-unexpected-operator', location, { op: opStr, elemref: (lhsArt && lhs) || rhs }, 'Unexpected operator $(OP) in structural comparison with $(ELEMREF)'); return null; } const lhsIsVal = isVal(lhs); const rhsIsVal = isVal(rhs); const lhsPaths = lhsIsVal ? [] : flattenPath(lhs, false, true ); const rhsPaths = rhsIsVal ? [] : flattenPath(rhs, false, true ); const msgCode = () => `${ xprForMessage( lhs ) } ${ opStr } ${ xprForMessage( rhs ) }`; if (xprOperationRejected()) return null; if (scalarOperationRejected()) return null; if (lhsPaths.length === 0 && rhsPaths.length === 0) { error('expr-invalid-expansion-empty', location, { code: msgCode(), }, 'Neither side of the expression $(CODE) expands to anything'); return null; } const xref = createCrossReferences(lhsPaths, rhsPaths, lhs, rhs); for (const xn in xref) { const x = xref[xn]; // Each entry of the left side must have a matching entry on the right. if (!x.lhs || !x.rhs) { error('expr-invalid-expansion', location, { '#': 'path-mismatch', value: msgCode(), name: xn, alias: pathToMessageString(x.rhs ? lhs : rhs), } ); return null; } if (rejectNonScalarRef(lhs, x.lhs, location) || rejectNonScalarRef(rhs, x.rhs, location)) { // one side was expanded to a non-scalar reference return null; } // info about type incompatibility if no other errors occurred if (!lhsIsVal && !rhsIsVal && x.lhs && x.rhs) { if (getType(x.lhs._art) !== getType(x.rhs._art)) { info('expr-ignoring-type-mismatch', location, { code: msgCode(), name: xn, }, 'Types of sub path $(NAME) differ when expanding structure in $(CODE)'); } } } // We're not adding another `xpr`, because `op` binds stronger than the combinators // in all cases we care about. const xpr = Object.values(xref).map((x) => { delete x.lhs.comparisonRef; delete x.rhs.comparisonRef; return [ x.lhs, ...operators, x.rhs ]; }); // insert the combinator between each value for (let i = 1; i < xpr.length; i += 2) xpr.splice(i, 0, OperatorCombinator[opStr]); return xpr.length > 1 ? { xpr } : xpr; function rejectNonScalarRef(e, x, location) { if ( !x || isVal(e) || isScalarOrNoType(x)) return false; // error: one side was expanded to a non-scalar, e.g. unmanaged association error('expr-invalid-expansion', location, { '#': 'non-scalar', value: msgCode(), name: pathToMessageString( e ), }); return true; } /** * Check for and reject invalid expressions with complex operands. * * @returns {boolean} If true, expression is invalid. */ function xprOperationRejected() { if (Array.isArray(lhs) || Array.isArray(rhs)) { error('ref-unexpected-structured', location, { '#': 'complexExpr', elemref: lhs.ref ? lhs : rhs, value: msgCode() }); return true; } return false; } /** * Check for and reject invalid expressions with scalar values/references. * * @returns {boolean} If true, expression is invalid. */ function scalarOperationRejected() { if ((opStr === 'is' || opStr === 'is not') && (rhs === 'null' || lhs === 'null')) return false; // `is [not] null` works even with multiple elements if ((lhsIsVal || isScalarOrNoType(lhs)) && rhsPaths.length !== 1) { const variant = rhs._art?.target && 'assoc-expr' || 'struct-expr'; error('ref-unexpected-structured', location, { '#': variant, elemref: rhs }); return true; } if ((rhsIsVal || isScalarOrNoType(rhs)) && lhsPaths.length !== 1) { const variant = lhs._art?.target && 'assoc-expr' || 'struct-expr'; error('ref-unexpected-structured', location, { '#': variant, elemref: lhs }); return true; } if (lhs.ref && isScalarOrNoType(lhs)) { error('expr-unsupported-expansion', location, { '#': 'scalarRef', elemref: lhs, value: msgCode() }); return true; } if (rhs.ref && isScalarOrNoType(rhs)) { error('expr-unsupported-expansion', location, { '#': 'scalarRef', elemref: rhs, value: msgCode() }); return true; } return false; } } /** * @param expr * @param {CSN.Path} location */ function rejectAnyDirectStructureReference(expr, location) { if (expr[0] === 'exists') { // we ignore WHERE EXISTS clauses; they are not relevant for OData, // and in SQL it was handled before return; } for (const x of expr) { const art = x?._art || x?.ref && !x.$scope && inspectRef(location.concat(0)).art; if (art && isExpandable(art)) { const variant = art.target && 'assoc' || 'std'; error('ref-unexpected-structured', location, { '#': variant, elemref: x }); } } } /** * Pairs left and right sub-paths into a common structure. * * @param {Array} lhsPaths * @param {Array} rhsPaths * @param {object} lhs * @param {object} rhs * @returns {Record<string, object>} */ function createCrossReferences(lhsPaths, rhsPaths, lhs, rhs) { const xRef = Object.create(null); const rhsIsVal = isVal(rhs); for (const p of lhsPaths) { const name = p.comparisonRef.slice(lhs.ref.length).map(pathId).join('.'); xRef[name] ??= { name }; xRef[name].lhs = p; if (rhsIsVal) xRef[name].rhs = rhs; } const lhsIsVal = isVal(lhs); for (const p of rhsPaths) { const name = p.comparisonRef.slice(rhs.ref.length).map(pathId).join('.'); xRef[name] ??= { name }; xRef[name].rhs = p; if (lhsIsVal) xRef[name].lhs = lhs; } return xRef; } /** * Flatten structured leaf types and return an array of paths. * * Argument 'path' must be an object of the form * `{ _art: <leaf_artifact>, ref: [...] }` * with `_art` identifying `ref[ref.length-1]` * * A produced path has the form `{ _art: <ref>, ref: [ <id> (, <id>)* ], comparisonRef: [ <id> (, <id>)* ] }` * * Flattening stops on all non-structured elements, if followMgdAssoc=false. * * If fullRef is true, a path step is produced as `{ id: <id>, _art: <link> }`. * * The returned paths will have a property 'comparisonRef', that may differ from 'ref' * for managed associations (as it uses the foreign key name). * The caller may need to delete that property. */ function flattenPath(path, fullRef = false, followMgdAssoc = false) { let art = path._art; if (!art) return [ path ]; if (!art.elements) { if (followMgdAssoc && art.target && art.keys) { const rc = []; for (const k of art.keys) { const nps = { ref: k.ref.map(p => (fullRef ? { id: p } : p) ), comparisonRef: [ k.as || implicitAs(k.ref) ], }; setProp(nps, '_art', k._art); const paths = flattenPath( nps, fullRef, followMgdAssoc ); // prepend prefix path paths.forEach((p) => { p.comparisonRef.unshift(...clonePath(path.comparisonRef || path.ref)); p.ref.unshift(...clonePath(path.ref)); if (path.$scope !== undefined) setProp(p, '$scope', path.$scope); }); rc.push(...paths); } return rc; } if (art.type?.ref) art = resolvePath(art.type); else if (art.type && !isBuiltinType(art.type)) art = csn.definitions[art.type]; } const { elements } = art; if (!elements) { setProp(path, '_art', art); return [ path ]; } const rc = []; Object.entries(elements).forEach(([ en, elt ]) => { const step = (fullRef ? { id: en, _art: elt } : en ); const nps = { ref: [ step ], comparisonRef: [ step ], }; setProp(nps, '_art', elt); const paths = flattenPath( nps, fullRef, followMgdAssoc ); // prepend prefix path paths.forEach((p) => { p.comparisonRef.unshift(...clonePath(path.comparisonRef || path.ref)); p.ref.splice(0, 0, ...clonePath(path.ref)); if (path.$scope !== undefined) setProp(p, '$scope', path.$scope); }); rc.push(...paths); }); return rc; } function getType(art) { const effArt = art && effectiveType(art); return Object.keys(effArt).length ? effArt : art.type; } function isExpandable(art) { art = art && effectiveType(art); if (!art) return false; // items in ON conditions are illegal but this should be checked elsewhere const elements = art.elements || art.items?.elements; return !!(elements || art.target && art.keys); } /** * Returns true if the given artifact or artifact reference has a scalar type * or no type at all, e.g. for references to elements of type `cds.String`. * * @param art * @returns {boolean} */ function isScalarOrNoType(art) { art = art?._art ?? art; if (isTypeScalarBuiltin(art.type)) return true; art = art && effectiveType(art); // builtins are resolved to `{}` if (!art) return false; const type = art.type || art.items?.type; const elements = art.elements || art.items?.elements; const target = art.target || art.items?.target; if (!elements && !type && !target) return true; return isTypeScalarBuiltin(type); } function isTypeScalarBuiltin(type) { // "cds.Map" can't be used for tuple expansion, even though it is a structured type. return (type && isBuiltinType(type) && type !== 'cds.Association' && type !== 'cds.Composition' && type !== 'cds.Map'); } /** * Enrich a reference node with properties that are also set by our enricher. * There are paths (generated by "where-exists") that are missing these properties, hence * we add them here. * Since the result of tuple expansion is not exposed in any CSN, we don't need to care about * cleanup afterwards. * * TODO: Can we move this to the enricher? * * @param node Object with `ref` * @param {CSN.Path} loc Path to the node. * @returns {*} */ function enrichRef(node, loc) { if (!node.ref) return node; const inspected = inspectRef(loc); if (inspected.links) setProp(node, '_links', inspected.links); if (inspected.art) setProp(node, '_art', inspected.art ); if (inspected.$env) setProp(node, '$env', inspected.$env ); setProp(node, '$scope', inspected.scope); setProp(node, '$path', [ ...loc ]); return node._art; } } function isVal(valOrRef) { return (valOrRef === 'null' || valOrRef.val !== undefined); } function xprForMessage(xpr) { if (isVal(xpr)) return xpr.val ?? xpr; if (xpr.ref) return pathToMessageString( xpr ); return '…'; } /** * Clone a ref. Otherwise, it may happen that two paths with filters are transformed twice * during flattening, due to object equality. * * @param {any[]} ref * @returns {any[]} */ function clonePath(ref) { return ref.map((step) => { if (typeof step === 'string') return step; return cloneCsnNonDict(step); }); } module.exports = { tupleExpansion, RelationalOperators, };