UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

712 lines (654 loc) 25.2 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 { getColumnMap } = require('./views'); 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 { error } = messageFunctions; 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. * * As a first step, we ensure that all views/projections have a .columns (see {@link calculateColumns}) and that * all calculated elements are addressed explicitly and not via a * (see {@link makeAllCalculatedElementsExplicitColumns}). * * 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 ) { const containsExpandInline = hasExpandInline(SELECT); let cleanupCallbacks; if (!SELECT.columns) // needs to happen for all subqueries! cleanupCallbacks = calculateColumns(elements, SELECT); else cleanupCallbacks = makeAllCalculatedElementsExplicitColumns(elements, SELECT, containsExpandInline); const name = SELECT.from.args ? undefined : SELECT.from.as || (SELECT.from.ref && implicitAs(SELECT.from.ref)); if (!containsExpandInline) { applyTransformationsOnNonDictionary({ SELECT }, 'SELECT', { ref: (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; // TODO: Calculated elements on-write if (art?.value && !art.value.stored && !calcElementIsAssoc) { const alias = parent.as || implicitAs(parent.ref); // TODO: What about other scopes? expand/inline? const value = (scope !== 'ref-target') ? absolutifyPaths(env, art, ref, links, name).value : keepAssocStepsInRef(ref, links, art).value; // Is a shallow copy enough? if (art.value.cast) root[p[p.length - 1]] = { xpr: [ value ] }; else root[p[p.length - 1]] = { ...value }; if (p[p.length - 2] === 'columns') root[p[p.length - 1]].as = alias; else delete root[p[p.length - 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[p.length - 1]].cast = parent.cast; else if (parent._element?.type) root[p[p.length - 1]].cast = { type: parent._element.type }; // TODO: Copy annotations? May become relevant in the future } }, }, {}, path); } cleanupCallbacks.forEach(fn => fn()); } /** * * @param {CSN.QuerySelect} SELECT * @returns {boolean} */ function hasExpandInline( SELECT ) { if (!SELECT.columns) return false; for (const column of SELECT.columns) { if (column.expand || column.inline) return true; } return false; } /** * 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; } /** * For a `view V as select from E;` or a `entity P as projection on E;` calculate and * attach the .columns if they contain a calculated element so we can rewrite them in * the later steps. * * @param {CSN.Elements} elements Artifact elements * @param {object} carrier The thing that will "carry" the columns - .SELECT or .projection * @returns {Function[]} Cleanup callbacks that remove `_`-links. */ function calculateColumns( elements, carrier ) { carrier.columns = [ '*' ]; const cleanupCallbacks = makeAllCalculatedElementsExplicitColumns(elements, carrier, false); if (carrier.columns.length === 1 && carrier.columns[0] === '*') delete carrier.columns; return cleanupCallbacks; } /** * * @param {CSN.QuerySelect} SELECT * @returns {object} */ function getDirectlyAddressableElements( SELECT ) { const { from } = SELECT; if (from.ref) { return from._art.elements; } else if (from.SELECT) { return from.SELECT.elements; } else if (from.SET) { // args[0] could be SELECT or UNION return getDirectlyAddressableElements({ from: from.SET.args[0] }); } else if (from.args) { const mergedElements = Object.create(null); for (const arg of from.args) { if (arg.ref) { for (const elementName in arg._art.elements) mergedElements[elementName] = arg._art.elements[elementName]; } else if (arg.SET) { return getDirectlyAddressableElements({ from: arg.SET.args[0] }); } else if (arg.SELECT) { // TODO: UNION for (const elementName in arg.SELECT.elements) mergedElements[elementName] = arg.SELECT.elements[elementName]; } else if (arg.args) { // TODO: Is it safe to do recursion here? for (const subarg of arg.args) { const elements = getDirectlyAddressableElements({ from: subarg }); for (const elementName in elements) mergedElements[elementName] = elements[elementName]; } } else { throw new CompilerAssertion(`Unhandled arg type: ${ JSON.stringify(arg, null, 2) }`); } } return mergedElements; } throw new CompilerAssertion(`Unhandled query type: ${ JSON.stringify(SELECT, null, 2) }`); } /** * Ensure that all elements of the query that are calculated elements have an explicit column that we can rewrite. * If a field originally comes in via the *, then we need to add an explicit column for it. * * @param {CSN.Elements} elements * @param {CSN.QuerySelect} SELECT * @param {boolean} containsExpandInline * @returns {Function[]} Cleanup callbacks that remove `_`-links. */ function makeAllCalculatedElementsExplicitColumns( elements, SELECT, containsExpandInline ) { const cleanupCallbacks = []; const root = getDirectlyAddressableElements(SELECT); const columnMap = getColumnMap( { SELECT }, csnUtils ); const hasStar = SELECT.columns.includes('*'); const unfoldingMap = {}; let starContainsCalculated = false; let containsCalcOnRead = false; for (const name in elements) { const originalRef = columnMap[name] && columnMap[name].ref || [ name ]; if (columnMap[name] || hasStar) { let element; if (columnMap[name]?.expand || columnMap[name]?.inline) element = elements[name]; // only the direct thing in .elements has the .excluding respected properly! else element = columnMap[name]?._art || columnMap[name]?._element || root[name] || elements[name]; const branches = getBranches(element, name, effectiveType, pathDelimiter); // TODO: is our elements[name] really the root[name]? if (hasCalcOnReadLeaf(branches)) { containsCalcOnRead = true; const columns = []; for (const branchName in branches) { const branch = branches[branchName]; const leafElement = branch.steps[branch.steps.length - 1]; if (columnMap[branchName]) { // Existing column - don't overwrite, we need $env! columns.push(columnMap[branchName]); } else { // TODO: Hm, will we have a $env in the leaf of the thing then? const column = { ref: [ ...originalRef, ...branches[branchName].ref.slice(1) ], as: branchName }; setProp(column, '_element', leafElement); cleanupCallbacks.push(() => delete column._element); columns.push(column); } } if (columnMap[name]) { unfoldingMap[name] = [ false, [ ...columns ] ]; } else if (hasStar) { // Via * - just append starContainsCalculated = true; unfoldingMap[name] = [ true, [ ...columns ] ]; } } else { if (usesCalcOnRead(branches)) containsCalcOnRead = true; if (!columnMap[name] && hasStar) { // Via * - just append unfoldingMap[name] = [ true, [ { ref: [ name ] } ] ]; } else { // just a random column - keep unfoldingMap[name] = [ false, [ columnMap[name] ] ]; } } } } if (containsExpandInline && containsCalcOnRead) { error('query-unsupported-calc', SELECT.$path, { '#': 'std' }); } else if (containsCalcOnRead) { const newColumns = []; if (hasStar && !starContainsCalculated) newColumns.push('*'); for (const name in elements) { const [ isViaStar, columns ] = unfoldingMap[name]; if (isViaStar && starContainsCalculated || !isViaStar) newColumns.push(...columns); } SELECT.columns = newColumns; } return cleanupCallbacks; } /** * Returns true if any leaf node is a calculated element on-read. * On-write behaves like regular elements, hence they do not count here. * * @param {object} branches * @returns {boolean} */ function hasCalcOnReadLeaf( branches ) { for (const branchName in branches) { const branch = branches[branchName].steps; const leaf = branch[branch.length - 1]; if (hasOnReadValue(leaf)) return true; } return false; } /** * Returns true if the branch/column uses a calc-on-read, * for example in a filter. * * TODO: Enable calculated elements next to nested projections * * @param {object} branches * @returns {boolean} */ function usesCalcOnRead( branches ) { let returnValue = false; for (const branchName in branches) { const column = branches[branchName]?.steps[0]?._column; if (column) { applyTransformationsOnNonDictionary({ column }, 'column', { // eslint-disable-next-line no-loop-func ref: (parent) => { if (hasOnReadValue(parent)) returnValue = true; }, }, { drillRef: true, // skip subqueries and nested projections // calculated elements and nested projections // only conflict on same level skipStandard: [ 'SELECT', 'expand', 'inline' ], }); } } return returnValue; } /** * A leaf can reference a column which in turn references a real element - that might have a .value. * Find such cases. * * @param {object} baseLeaf Leaf to start at * @returns {boolean} */ function hasOnReadValue( baseLeaf ) { const visited = new WeakSet(); const stack = [ baseLeaf ]; while (stack.length > 0) { const leaf = stack.pop(); if (!visited.has(leaf)) { // Don't re-process things if (leaf.value && !leaf.value.stored) return true; else if (leaf._art) stack.push(leaf._art); else if (leaf['@Core.Computed'] && leaf._column && leaf._column !== baseLeaf) stack.push(leaf._column); } visited.add(leaf); } return false; } /** * 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, };