@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
509 lines (441 loc) • 16.6 kB
JavaScript
'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,
};