@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
508 lines (457 loc) • 17.7 kB
JavaScript
'use strict';
const { setProp } = require('../../base/model');
const { CompilerAssertion } = require('../../base/error');
const {
forEachDefinition,
applyTransformationsOnNonDictionary,
applyTransformationsOnDictionary,
implicitAs,
} = require('../../model/csnUtils');
const { getBranches } = require('./flattening');
const { cloneCsnNonDict } = require('../../model/cloneCsn');
const cloneCsnOptions = { hiddenPropertiesToClone: [ '_art', '_links', '$env', '$scope' ] };
/**
* Rewrite usage of calculated Elements into the expression itself.
* Delete calculated elements in entities after processing so they don't materialize on the db.
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param {object} csnUtils
* @param {string} pathDelimiter
* @param {object} _messageFunctions
*/
function rewriteCalculatedElementsInViews( csn, options, csnUtils, pathDelimiter, _messageFunctions ) {
const { inspectRef, effectiveType } = csnUtils;
const views = [];
const entities = [];
// In this first pass, we rewrite all the .value things in tables into their most basic form
forEachDefinition(csn, (artifact, artifactName) => {
if (artifact.kind === 'entity') {
if (artifact.query || artifact.projection) {
views.push({ artifact, artifactName });
}
else if (artifact.elements) { // can happen with CSN input
rewriteInEntity(artifact);
entities.push({ artifact, artifactName });
}
}
});
// Replace calculated elements in filters, functions and other places (if the root-association element is in an entity).
// Depends on the first pass!
entities.forEach(({ artifactName }) => {
applyTransformationsOnNonDictionary(csn.definitions, artifactName, {
ref: (_parent, _prop, ref, _path, root, index) => {
if (_parent._art?.value && !_parent._art.value.stored) {
if (_parent._art.value.ref) {
// Ensure that we don't break any navigation by only replacing the real element at the end
const leafLength = getLeafLength(_parent._links);
root[index].ref = [ ...root[index].ref.slice(0, -1 * leafLength), ..._parent._art.value.ref ];
setProp(root[index], '_links', [ ...root[index]._links.slice(0, leafLength), ..._parent._art.value._links ]);
setProp(root[index], '_art', _parent._art);
}
else {
root[index] = _parent._art.value;
}
// Note: Depends on A2J rejecting deeply nested filters
applyTransformationsOnNonDictionary(root, index, {
ref: (__parent, _, _ref) => {
if (_ref[0] === '$self' || _ref[0] === '$projection')
__parent.ref = _ref.slice(-1);
},
});
}
},
}, {
drillRef: true,
// skip "type" to avoid going into type.ref
skipStandard: { type: 1 },
}, [ 'definitions' ]);
});
// In this third pass, we process our views, generate .columns if needed and replace usage
// of calculated elements with their respective `.value`.
// This depends on the first pass!
views.forEach(({ artifact, artifactName }) => {
applyTransformationsOnNonDictionary(csn.definitions, artifactName, {
SELECT: (parent, prop, SELECT, path) => {
rewriteInView(SELECT, SELECT.elements || artifact.elements, path);
},
projection: (parent, prop, projection, path) => {
parent.SELECT = projection; // Fake as SELECT so our path below will match in the applyTransformations...
rewriteInView(parent.SELECT, artifact.elements, path);
delete parent.SELECT;
},
}, {}, [ 'definitions' ]);
});
// Last pass, turn .value in tables into a simple 'val' so we don't need to rewrite/flatten properly - will kill them later
entities.forEach(({ artifact, artifactName }) => {
dummifyInEntity(artifact, [ 'definitions', artifactName ]);
});
/**
* Get the length of the effective leaf - since structures are not flat yet it might be more than 1.
* Walk from the back until we find the first association/composition.
*
* @param {object[]} links
* @returns {number}
*/
function getLeafLength( links ) {
for (let i = links.length - 1; i >= 0; i--) {
const { art } = links[i];
if (art.target)
return links.length - i - 1;
}
return links.length;
}
/**
* Rewrite calculated-elements-columns in views/projections and replace them
* with their "root"-expression.
*
* Then, we check the `art` of each ref for a `.value` and rewrite accordingly.
* We need to ensure that the scope of the rewritten expressions is still correct!
* An `id` in the `.value` needs to point to the entity containing the element,
* not to some random view element named `id`. See {@link absolutifyPaths} for
* details on that.
*
* @param {CSN.QuerySelect} SELECT
* @param {CSN.Elements} elements
* @param {CSN.Path} path
*/
function rewriteInView( SELECT, elements, path ) {
expandStructSelectItems(SELECT);
const name = SELECT.from.args
? undefined
: SELECT.from.as || (SELECT.from.ref && implicitAs(SELECT.from.ref));
applyTransformationsOnNonDictionary({ SELECT }, 'SELECT', {
ref: transformRef,
}, {}, path);
/**
* @param {object} parent
* @param {string} prop
* @param {CSN.Ref} ref
* @param {CSN.Path} p
* @param {CSN.Element} root
*/
function transformRef(parent, prop, ref, p, root) {
const {
art, env, links, scope,
} = getRefInfo(parent, p);
// calc element publishes association, treat as regular unmanaged association
const calcElementIsAssoc = art?.value && art.target;
if (!art?.value || art.value.stored || calcElementIsAssoc)
return;
if (scope === 'inline' || scope === 'expand') {
// Calculated elements in expand/inline are not supported, yet.
// Error is reported in expansion.js
return;
}
const alias = parent.as || implicitAs(parent.ref);
// TODO: What about other scopes? expand/inline may become relevant again if we allow calc elements in them.
const value = (scope !== 'ref-target')
? absolutifyPaths(env, art, ref, links, name).value
: keepAssocStepsInRef(ref, links, art).value;
// TODO: Is a shallow copy enough?
root[p.at(-1)] = art.value.cast ? { xpr: [ value ] } : { ...value };
if (p.at(-2) === 'columns' || p.at(-2) === 'expand')
root[p.at(-1)].as = alias;
else
delete root[p.at(-1)].as;
// If the calculated element has a type, use it. But only if the column did not have an explicit type.
// Note: We should not check `art.type`, because we only need the type for columns, not filters.
if (parent.cast)
root[p.at(-1)].cast = parent.cast;
else if (parent._element?.type)
root[p.at(-1)].cast = { type: parent._element.type };
// TODO: Copy annotations? May become relevant in the future
}
}
/**
* Replace all nested .value things (in .xpr, in .ref) with their most-direct thing:
* - A ref to a non-calculated element
* - A .val
* - An expression containing the above
*
* @param {CSN.Artifact} artifact The artifact currently being processed
*/
function rewriteInEntity( artifact ) {
let reorderElements = false;
applyTransformationsOnDictionary(artifact.elements, {
value: (parent, prop, value) => {
if (value.stored)
reorderElements = true;
replaceValuesWithBaseValue(parent);
},
});
// on-write must appear at the end of the elements. Order of the on-write between themselves
// should be as written.
if (reorderElements) {
const newElements = Object.create(null);
const onWrite = [];
for (const name in artifact.elements) {
const element = artifact.elements[name];
if (element.value?.stored)
onWrite.push(name);
else
newElements[name] = element;
}
// Add the on-write to the end
onWrite.forEach((name) => {
newElements[name] = artifact.elements[name];
});
artifact.elements = newElements;
}
}
/**
* Iteratively replace all .values with the most-basic form:
* - a .val thing
* - a .ref to a non-value thing
*
* @param {object} parent
*/
function replaceValuesWithBaseValue( parent ) {
if (parent.value.val !== undefined)
return; // literal; no need to traverse
const stack = [ { parent, value: parent.value } ];
while (stack.length > 0) {
const current = stack.pop();
if (current.value.xpr) {
applyTransformationsOnNonDictionary(current.value, 'xpr', {
ref: (p, prop, ref, path, root ) => {
stack.push({
parent: root,
value: p,
isInXpr: true,
refBase: current.refBase,
linksBase: current.linksBase,
});
},
}, { skipStandard: { where: true } });
}
else if (current.value.ref && current.value._art?.value && !current.value._art?.value.stored) {
const linksBase = current.value._links;
const refBase = current.value.ref;
const newValue = replaceInRef(current.value, current.value._art.value, current.isInXpr, refBase, linksBase);
const prop = Array.isArray(current.parent) ? current.parent.indexOf(current.value) : 'value';
if (prop === -1)
throw new CompilerAssertion('Calculated Elements: Value not in parent; should never happen!');
current.parent[prop] = newValue;
stack.push(Object.assign(current, { value: newValue, refBase, linksBase }));
}
else if (current.value.val) {
if (current.isInXpr) { // inside of expressions we directly need the val
current.parent.val = current.value.val;
delete current.parent.value; // TODO: current.parent could be an array!
}
else { // outside of expressions, i.e. as normal elements, we need it in a .value wrapper
current.parent.value = current.value;
}
}
}
}
/**
* A value referenced via a ref is replaced here
* - kill the ref
* - explicitly mention the value
*
* We either "trick" it into the correct place in an .xpr or we simply overwrite the existing .ref
*
* @param {object} oldValue
* @param {object} newValue
* @param {boolean} isInXpr
* @param {Array} refBase
* @param {Array} linksBase
* @returns {object|Array}
*/
function replaceInRef( oldValue, newValue, isInXpr, refBase, linksBase ) {
const clone = { value: cloneCsnNonDict(newValue, cloneCsnOptions) };
if (oldValue.stored)
clone.value.stored = oldValue.stored;
const refPrefix = refBase.slice(0, -1);
const linksPrefix = linksBase.slice(0, -1);
// We need to adapt the scope of all refs in the new .xpr, as it might have been at a different "root"
applyTransformationsOnNonDictionary(clone, 'value', {
ref: (p, prop, ref) => {
if (ref[0] !== '$self' && ref[0] !== '$projection') {
p.ref = [ ...refPrefix, ...ref ];
if (p._links)
p._links = [ ...linksPrefix, ...p._links ]; // TODO: Make non-enum, increment idx
}
},
}, {
// Do not rewrite refs inside an association-where; avoids endless loop
skipStandard: { where: true },
});
return clone.value;
}
/**
* Expands all references to structures to its separate leaf elements, adding columns if needed.
*
* @param {object} SELECT
*/
function expandStructSelectItems(SELECT) {
for (let i = 0; i < SELECT.columns?.length; i++) {
const column = SELECT.columns[i];
if (column.expand)
continue; // skip expand
if (column.ref && column._element) {
const columnName = column.as || implicitAs(column.ref);
const branches = getBranches(column._element, columnName, effectiveType, pathDelimiter);
const paths = Object.keys(branches);
if (paths.length > 1 || paths[0] !== columnName) {
SELECT.columns[i] = Object.entries(branches).map(([ name, branch ]) => {
const elem = branch.steps.at(-1);
// TODO: Table alias somehow?
const ref = [ ...column.ref, ...branch.ref.slice(1) ];
const newColumn = { ref, as: name };
// If the calculated element has a type, use it.
if (elem['@Core.Computed'] && elem.type) // very crude - we could walk the branches to see if we are dealing with a real calc element?
newColumn.cast = { type: elem.type };
return newColumn;
});
}
}
}
SELECT.columns = SELECT.columns.flat(Infinity);
}
/**
* We need to keep association steps in front of the paths - else they would lead into nothing
*
* @param {Array} artRef
* @param {Array} links
* @param {object} art
* @returns {object}
*/
function keepAssocStepsInRef( artRef, links, art ) {
let lastAssocIndex = -1;
for (let i = links.length - 1; i > -1; i--) {
if (links[i].art.target) {
lastAssocIndex = i;
break;
}
}
if (lastAssocIndex > -1) {
const clone = { value: cloneCsnNonDict(art.value, cloneCsnOptions) };
applyTransformationsOnNonDictionary(clone, 'value', {
ref: (parent, prop, ref) => {
parent.ref = [ ...artRef.slice(0, lastAssocIndex + 1), ...ref ];
if (parent._links)
parent._links = [ ...links.slice(0, lastAssocIndex + 1), ...parent._links ];
},
}, {
skipStandard: { where: true }, // Do not rewrite refs inside of an association-where
});
return clone;
}
return art;
}
/**
* In order to just replace them in views, our calculated elements need to reference absolute things, i.e. have a table alias in front!
*
* @param {string | object} env
* @param {object} art
* @param {Array} artRef
* @param {Array} artLinks
* @param {string|undefined} name
* @todo this is probably very wonky and will break with some view hierarchy stuff etc!
* @returns {object}
*/
function absolutifyPaths( env, art, artRef, artLinks, name ) {
const clone = { value: cloneCsnNonDict(art.value, cloneCsnOptions) };
applyTransformationsOnNonDictionary(clone, 'value', {
ref: (parent, prop, ref) => {
const artifactName = typeof env === 'string' ? env : name;
if (parent._links) {
if (parent._links[0].art.kind !== 'entity') {
if (artLinks[0].art.kind === 'entity' || artifactName === undefined) {
parent.ref = [ ...artRef.slice(0, -1), ...ref ];
setProp(parent, '_links', [ ...artLinks.slice(0, -1), ...parent._links ]); // TODO: increment idx
}
else {
parent.ref = [ artifactName, ...artRef.slice(0, -1), ...ref ];
setProp(parent, '_links', [ { idx: 0 }, ...artLinks.slice(0, -1), ...parent._links ]); // TODO: increment idx
}
}
else if (parent.$scope === '$self') {
if (artifactName !== undefined)
parent.ref[0] = artifactName;
else
parent.ref = parent.ref.slice(-1);
}
}
},
}, {
skipStandard: { where: true }, // Do not rewrite refs inside of an association-where
});
return clone;
}
/**
* Get the ref-info
* - either the cached _art etc.
* - or calculate using inspectRef
*
* @param {object} parent
* @param {CSN.Path} path
* @returns {object}
*/
function getRefInfo( parent, path ) {
if (parent._art) {
return {
art: parent._art,
env: parent.$env,
links: parent._links,
scope: parent.$scope,
};
}
return inspectRef(path);
}
}
/**
* @param {CSN.Model} csn
* @param {CSN.Options} options
*/
function processCalculatedElementsInEntities( csn, options ) {
forEachDefinition(csn, (artifact, definitionName) => {
if (artifact.kind === 'entity' && !(artifact.query || artifact.projection))
removeDummyValueInEntity(artifact, [ 'definitions', definitionName ], options);
});
}
/**
* In an entity, remove all instances of calculated elements.
*
* @param {CSN.Artifact} artifact
* @param {CSN.Path} path
* @param {CSN.Options} options
* @todo calculated elements that "live" on the database?
* @todo error when artifact is empty afterwards? Probably better as a CSN check!
*/
function removeDummyValueInEntity( artifact, path, options ) {
applyTransformationsOnDictionary(artifact.elements, {
value: (parent, prop, value, p, elements) => {
if (!value.stored) {
if (options.transformation === 'effective' && parent.on)
delete parent.value;
else
delete elements[p.at(-1)];
}
},
}, {}, path.concat( 'elements' ));
}
/**
* In an entity, turn all instances of calculated elements into an = 1. This way,
* we don't have to rewrite any scope there and can kill them after A2J, see {@link processCalculatedElementsInEntities}.
*
* @param {CSN.Artifact} artifact
* @param {CSN.Path} path
*/
function dummifyInEntity( artifact, path ) {
applyTransformationsOnDictionary(artifact.elements, {
value: (parent, _prop, value) => {
if (!value.stored) {
parent.value = { val: 'DUMMY' };
delete parent.localized;
}
},
}, {}, path);
}
module.exports = {
rewriteCalculatedElementsInViews,
processCalculatedElementsInEntities,
};