UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

508 lines (457 loc) 17.7 kB
'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, };