UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

782 lines (707 loc) 28.8 kB
'use strict'; const { applyTransformations, setDependencies, walkCsnPath, getUtils, } = require('../../model/csnUtils'); const { implicitAs, columnAlias, pathId } = require('../../model/csnRefs'); const { setProp } = require('../../base/model'); const { forEach } = require('../../utils/objectUtils'); const { killNonrequiredAnno } = require('./killAnnotations'); const { featureFlags } = require('../featureFlags'); /** * For keys, columns, groupBy and orderBy, expand structured things. * Replace them with their flattened leaves, keeping the overall order intact. * * @param {CSN.Model} csn * @param {CSN.Options} options * @param {string} pathDelimiter * @param {object} messageFunctions * @param {object} csnUtils * @param {object} [iterateOptions] */ function expandStructureReferences( csn, options, pathDelimiter, messageFunctions, csnUtils, iterateOptions = {} ) { const { error, info, throwWithAnyError } = messageFunctions; if (options.transformation === 'odata' || csn.meta?.[featureFlags]?.$expandInline) rewriteExpandInline(); throwWithAnyError(); const transformers = { keys: (parent, name, keys, path) => { parent.keys = expand(keys, path.concat('keys'), true); }, columns: (parent, name, columns, path) => { const artifact = csn.definitions[path[1]]; csnUtils.initDefinition(artifact); // potentially not initialized, yet if (!artifact['@cds.persistence.table']) { const root = csnUtils.get$combined({ SELECT: parent }); // TODO: replace with the correct options.transformation? // Do not expand the * in OData for a moment, not to introduce changes // while the OData CSN is still official const isComplexQuery = parent.from.join !== undefined; if (!options.toOdata) parent.columns = replaceStar(root, columns, parent.excluding, isComplexQuery); // FIXME(v6): Remove argument "isComplexOrNestedQuery"; we use path.length > 4 to check // if we're inside the outermost "columns". If so, always prepend a table alias. See #11662 parent.columns = expand(parent.columns, path.concat('columns'), true, isComplexQuery || path.length > 4); } }, groupBy: (parent, name, groupBy, path) => { parent.groupBy = expand(groupBy, path.concat('groupBy')); }, orderBy: (parent, name, orderBy, path) => { parent.orderBy = expand(orderBy, path.concat('orderBy')); }, list: (parent, name, list, path) => { parent.list = expand(list, path.concat('list')); }, }; // To not have a whole model loop for such a "small" thing, we kill all non-sql-backend relevant annotations here if (options.transformation === 'sql' || options.transformation === 'hdbcds') transformers['@'] = killNonrequiredAnno; applyTransformations(csn, transformers, [], iterateOptions); /** * Turn .expand/.inline into normal refs. @cds.persistence.skip .expand with to-many (and all transitive views). * For such skipped things, error for usage of assoc pointing to them and ignore publishing of assoc pointing to them. */ function rewriteExpandInline() { let cleanup = []; let _dependents; const entity = findAnEntity(); const toDummify = []; applyTransformations(csn, { columns: (parent, name, columns, path) => { const artifact = csn.definitions[path[1]]; // get$combined expects a SET/SELECT - so we wrap the parent // (which is the thing inside SET/SELECT) // We can directly use SELECT here, as only projections and SELECT can have .columns const root = csnUtils.get$combined({ SELECT: parent }); if (!artifact['@cds.persistence.table']) { // Make root look like normal .elements - we never cared about conflict afaik anyway Object.keys(root).forEach((key) => { root[key] = root[key][0].element; }); const rewritten = rewrite(root, parent.columns, parent.excluding); /* * Do not remove unexpandable many columns in OData */ if (rewritten.toMany.length > 0 && !options.toOdata) { markAsToDummify(artifact, path[1]); rewritten.toMany.forEach(({ art }) => { error( null, art.$path || [ 'definitions', path[1] ], { name: `${ art.$env || path[1] }:${ art.ref.map(r => r.id || r) }` }, 'Unexpected .expand with to-many association $(NAME)'); }); } else { parent.columns = rewritten.columns; } } }, }); // OData must keep @cds.persistence.skip definitions // to present them in the API (and CSN) if (!options.toOdata) dummyfy(); cleanup.forEach(fn => fn()); csnUtils = getUtils(csn); const publishing = []; // OData must allow navigations to @cds.persistence.skip targets // as valid navigations in the API if (options.transformation !== 'odata') { applyTransformations(csn, { target: (parent, name, target, path) => { if (toDummify.indexOf(target) !== -1) { publishing.push({ parent, name, target, path: [ ...path ], }); } }, from: check, columns: check, where: check, groupBy: check, orderBy: check, having: check, limit: check, }); } /** * Check for usage of associations to skipped. * While we're at it, kill publishing of such assocs in columns. * * @param {object} parent * @param {string} name * @param {Array} parts * @param {CSN.Path} path */ function check( parent, name, parts, path ) { const inColumns = name === 'columns'; const kill = []; for (let i = 0; i < parts.length; i++) { const obj = parts[i]; if (!(obj && obj.ref) || obj.$scope === 'alias') continue; const links = obj._links || csnUtils.inspectRef(path.concat([ name, i ])).links; if (!links) continue; // Don't check the last element - to allow association publishing in columns for (let j = 0; j < (inColumns ? links.length - 1 : links.length); j++) { const link = links[j]; if (!link) continue; const { art } = link; if (!art) continue; const pathStep = obj.ref[j].id ? obj.ref[j].id : obj.ref[j]; const target = art.target ? art.target : pathStep; if (toDummify.indexOf(target) !== -1) { error( null, obj.$path, { id: pathStep, elemref: obj, name, anno: '@cds.persistence.skip', }, 'Unexpected $(ANNO) annotation on Association target $(NAME) of $(ID) in path $(ELEMREF) was skipped because of .expand in conjunction with to-many'); } } if (inColumns) { const { art } = links[links.length - 1]; if (art) { const pathStep = obj.ref[obj.ref.length - 1].id ? obj.ref[obj.ref.length - 1].id : obj.ref[obj.ref.length - 1]; const target = art.target ? art.target : pathStep; if (toDummify.indexOf(target) !== -1) kill.push(i); } } } for (let i = kill.length - 1; i >= 0; i--) parent[name].splice(kill[i]); } for (const { parent, target, path, } of publishing) { const last = parent.$path[parent.$path.length - 1]; const grandparent = walkCsnPath(csn, parent.$path.slice(0, -1)); if (typeof last === 'number') grandparent.splice(last); else delete grandparent[last]; info(null, path, { name: last, target }, 'Ignoring association $(NAME) with target $(TARGET), because it was skipped because of .expand in conjunction with to-many'); } /** * Mark the given artifact and all (transitively) dependent artifacts as `toDummify`. * This means that they will be replaced with simple dummy views in @dummify * * @param {CSN.Artifact} artifact * @param {string} name */ function markAsToDummify( artifact, name ) { if (!_dependents && cleanup.length === 0) ({ cleanup, _dependents } = setDependencies(csn, csnUtils)); const stack = [ [ artifact, name ] ]; while (stack.length > 0) { const [ a, n ] = stack.pop(); if (a[_dependents]) { forEach(a[_dependents], (dependentName, dependent) => { stack.push([ dependent, dependentName ]); }); } toDummify.push(n); } } /** * Replace the artifacts in `toDummify` with simple dummy views as produced by createDummyView. */ function dummyfy() { for (const artifactName of [ ...new Set(toDummify) ]) csn.definitions[artifactName] = createDummyView(entity); } /** * Get the next base for resolving a *. * Keep the current base unless we are now navigating into a structure or association. * * @param {CSN.Column} parent * @param {CSN.Artifact} base The current base * @returns {CSN.Artifact} */ function nextBase( parent, base ) { if (parent.ref) { const finalBaseType = csnUtils.getFinalTypeInfo(parent._art.type); const art = parent._art; if (finalBaseType && (finalBaseType.type === 'cds.Association' || finalBaseType.type === 'cds.Composition')) return csn.definitions[art.target].elements; return art.elements || finalBaseType?.elements; } return base; } /** * Rewrite expand and inline to "normal" refs * * @param {CSN.Artifact} root All elements visible from the query source ($combined) * @param {CSN.Column[]} columns * @param {string[]} excluding * @returns {{columns: Array, toMany: Array}} Object with rewritten columns (.expand/.inline) and with any .expand + to-many */ function rewrite( root, columns, excluding ) { const allToMany = []; const newThing = []; const containsExpandInline = columns.some(col => col.expand || col.inline); if (containsExpandInline) // Replace stars - needs to happen before resolving .expand/.inline since the .expand/.inline first path step affects the root * columns = replaceStar(root, columns, excluding); else return { columns, toMany: [] }; for (const col of columns) { if (col.expand || col.inline) { const { expanded, toManys } = expandInline(root, col, col.ref || [], col.expand ? [ dbName(col) ] : []); allToMany.push(...toManys); newThing.push(...expanded); } else { newThing.push(col); } } return { columns: newThing, toMany: allToMany }; } /** * Check whether the given object is a to-many association * * @param {CSN.Element} obj * @returns {boolean} */ function isToMany( obj ) { if (!obj._art) return false; const eType = csnUtils.effectiveType(obj._art); return (eType.type === 'cds.Association' || eType.type === 'cds.Composition') && eType.cardinality && eType.cardinality.max !== 1; } /** * Rewrite the expand/inline. For expand, keep along the alias - for inline, only leaf-alias has effect. * Expand * into the corresponding leaves - correctly handling .excluding and shadowing. * * Iterative, to not run into stack overflow. * * @param {CSN.Artifact} root All elements visible from the query source ($combined) * @param {CSN.Column} col Column to expand * @param {Array} ref Ref so far * @param {Array} alias Any start-alias * @returns {{expanded: Array, toManys: Array}} Object with expanded .expand/.inline and with any .expand + to-many */ function expandInline( root, col, ref, alias ) { const toManys = []; const expanded = []; const stack = [ [ root, col, ref, alias ] ]; while (stack.length > 0) { const [ base, current, currentRef, currentAlias ] = stack.pop(); if (isToMany(current) && current.expand) { expanded.push({ expand: current.expand, ref: currentRef, as: currentAlias.join(pathDelimiter), }); toManys.push({ art: current, ref: currentRef, as: currentAlias.join(pathDelimiter) }); } else if (current.expand || current.inline) { const withoutStar = replaceStar(nextBase(current, base), current.expand || current.inline, current.excluding); current[current.expand ? 'expand' : 'inline'] = withoutStar; for (let i = withoutStar.length - 1; i >= 0; i--) { const sub = withoutStar[i]; let subRef; if (sub.ref) { // Each expand/inline can introduce another layer of $self/$projection. Since $self is // a path-breakout, we can simply use the ref without outer expand/inline-references. subRef = (sub.$scope === '$self') ? sub.ref : currentRef.concat(sub.ref); } else { subRef = currentRef; } stack.push([ nextBase(current, base), sub, subRef, !sub.inline ? currentAlias.concat(dbName(sub)) : currentAlias ]); } } else if (current.xpr || current.args) { // We need to re-write refs in the .xpr/.args so they stay resolvable - we need to prepend the currentRef rewriteExpressionArrays(current, currentRef); expanded.push(Object.assign({}, current, { as: currentAlias.join(pathDelimiter) } )); } else if (current.on || current.cast?.on) { rewriteOn(current, [ currentAlias.slice(0, -1).join(pathDelimiter) ]); expanded.push(Object.assign({}, current, { ref: currentRef, as: currentAlias.join(pathDelimiter) } )); } else if (current.val !== undefined || current.func !== undefined) { expanded.push(Object.assign(current, { as: currentAlias.join(pathDelimiter) })); } else if (current.$scope === '$magic' || current.$scope === '$self') { expanded.push(Object.assign({}, current, { as: currentAlias.join(pathDelimiter) } )); } else { // preserve stuff like .cast for redirection const thing = base[currentAlias[currentAlias.length - 1]]; const value = current?._art?.value || thing?.value; if (value && !value.stored) error('query-unsupported-calc', current.$path || col.$path, { '#': 'inside' }); expanded.push(Object.assign({}, current, { ref: currentRef, as: currentAlias.join(pathDelimiter) } )); } } return { expanded, toManys }; } /** * Rewrite refs in the .xpr/.args to stay resolvable * * @param {object} parent Thing that has an .xpr/.args * @param {string[]} ref Ref so far */ function rewriteExpressionArrays( parent, ref ) { const stack = [ [ parent, ref ] ]; while (stack.length > 0) { const [ current, currentRef ] = stack.pop(); // '*' can't be used in function inside expand/inline, as it's not rewritten. if (current.func && current.args?.[0] === '*') error('query-unsupported-asterisk', current.$path, { code: `${ current.func }(*)` }); if (current.xpr) rewriteSingleExpressionArray(current.xpr, currentRef, stack); if (current.args) rewriteSingleExpressionArray(current.args, currentRef, stack); } } /** * With a .cast.on or .on in a .expand/.inline, we need to change the references, * since we change the overall scope of things (by "heaving" them up into "normal refs"). * * So anything that does not have a $self/$projection infront gets the so-far-traveled alias, * since after the transformation it will basically be in "top-level". * * @param {object} parent * @param {Array} ref The so-far effective name (basically the will-be alias), as an array to treat like a ref */ function rewriteOn( parent, ref ) { const stack = [ [ parent, ref ] ]; while (stack.length > 0) { const [ current, currentRef ] = stack.pop(); if (current.on) rewriteOnCondition(current.on, currentRef, stack); if (current.cast?.on) rewriteOnCondition(current.cast.on, currentRef, stack); } } /** * Actually rewrite the given oncondition. Once we find something to rewrite, * we preprend the currentRef. * * All stuff is pushed to the stack. * * @param {Array} on * @param {Array} currentRef * @param {Array} stack */ function rewriteOnCondition( on, currentRef, stack ) { for (let i = 0; i < on.length; i++) { const part = on[i]; if (part.ref && part.ref[0] !== '$self' && part.ref[0] !== '$projection') { part.ref = currentRef[0] ? [ currentRef[0], ...part.ref ] : part.ref; on[i] = part; stack.push([ part, part.ref ]); } else { stack.push([ part, currentRef ]); } } } /** * Rewrite the given expressionArray, prefixing currentRef to all refs * * @param {Array} expressionArray * @param {Array} currentRef * @param {Array} stack */ function rewriteSingleExpressionArray( expressionArray, currentRef, stack ) { for (let i = 0; i < expressionArray.length; i++) { const part = expressionArray[i]; if (part.ref) { part.ref = currentRef.concat(part.ref); expressionArray[i] = part; stack.push([ part, part.ref ]); } else { stack.push([ part, currentRef ]); } } } /** * Find any entity from the model so we can use it as the query source for our dummies. * * @returns {string|null} Name of any entity */ function findAnEntity() { for (const name in csn.definitions) { if (Object.prototype.hasOwnProperty.call(csn.definitions, name) && csn.definitions[name].kind === 'entity' && !csn.definitions[name].query) return name; } return null; } /** * Create a simple dummy view marked with @cds.persistence.skip * * @param {string} source * @returns {CSN.Artifact} */ function createDummyView( source ) { const elements = Object.create(null); elements.one = { '@Core.Computed': true, type: 'cds.Integer', }; const artifact = { '@cds.persistence.skip': true, kind: 'entity', query: { SELECT: { from: { ref: [ source, ], }, columns: [ { val: 1, as: 'one', cast: { type: 'cds.Integer', }, }, ], }, }, elements, }; setProp(artifact, '$wasToMany', true); return artifact; } } /** * Process thing and expand all structured refs inside * * @param {Array} thing * @param {CSN.Path} path * @param {boolean} [withAlias=false] Whether to "expand" the (implicit) alias as well. * @param {boolean} [isComplexOrNestedQuery] * @returns {Array} New array - with all structured things expanded */ function expand( thing, path, withAlias = false, isComplexOrNestedQuery = false ) { const newThing = []; for (let i = 0; i < thing.length; i++) { const col = thing[i]; if (col.ref && col.$scope !== '$magic') { const _art = col._art || csnUtils.inspectRef(path.concat(i)).art; if (_art && csnUtils.isStructured(_art)) newThing.push(...expandRef(_art, col, withAlias, isComplexOrNestedQuery)); else newThing.push(col); } else if (col.ref && col.$scope === '$magic' && ( col.ref[0] === '$user' || col.ref[0] === '$tenant' || col.ref[0] === '$session' ) && !col.as) { col.as = implicitAs(col.ref); newThing.push(col); } else if (col.cast?.type) { const _art = col.cast._type || csnUtils.inspectRef(path.concat(i, 'cast', 'type')).art; if (_art && csnUtils.isStructured(_art)) { // special case for `null as name : Struct` if (col.val === null) { newThing.push(...expandValAsStructure(_art, col, withAlias)); } else { error('type-invalid-cast', path.concat(i, 'cast', 'type'), { '#': col.val !== undefined ? 'val-to-structure' : 'expr-to-structure', value: col.val, }); } } else { newThing.push(col); } } else { newThing.push(col); } } return newThing; } /** * Expands a column, and calls leafCallback() when a leaf node is reached. * * @param {CSN.Element} art * Structured Artifact which is used for expansion (and names, etc.). For a ref, it's the * underlying type or a cast-type, for a value, it's always the cast-type. * @param {string} colName * Name of the column, that is used as the first name segment, e.g. a column `a` may end up in * leafs `a_b` and `a_c`, if `art` has elements `b` and `c`. * @param {string[]} colTypeRef * Expanded type for the column. Basically the path to the to-be-expanded `art`. * @param {(currentRef: any[], currentAlias: string[]) => object} leafCallback * Callback when leaf nodes are reached. currentRef is the type reference for the expanded * column. currentAlias is the columns calculated alias. * @returns {object[]} */ function _expandStructCol( art, colName, colTypeRef, leafCallback ) { const expanded = []; /** @type {Array<[CSN.Element, any[], string[]]>} */ const stack = [ [ art, colTypeRef, [ colName ] ] ]; while (stack.length > 0) { const [ current, currentRef, currentAlias ] = stack.pop(); if (csnUtils.isStructured(current)) { // `cds.Map` may also be used const elements = Object.entries(current.elements || csnUtils.effectiveType(current).elements || {}).reverse(); for (const [ name, elem ] of elements) stack.push([ elem, currentRef.concat(name), currentAlias.concat(name) ]); } else { const newCol = leafCallback(currentRef, currentAlias); expanded.push(newCol); } } return expanded; } /** * Expand the ref and - if requested - expand/set the alias with it. * * @param {CSN.Element} art * @param {object} root Column, ref in order by, etc. * @param {boolean} withAlias Whether to add an explicit flattened alias to the expanded columns/references. * @param {boolean} [isComplexOrNestedQuery] * @returns {Array} */ function expandRef( art, root, withAlias, isComplexOrNestedQuery ) { return _expandStructCol(art, columnAlias(root), root.ref, ( currentRef, currentAlias) => { const obj = { ...root, ref: currentRef }; if (withAlias) { obj.as = currentAlias.join(pathDelimiter); // alias was implicit - to later distinguish expanded s -> s.a from explicitly written s.a if (root.as === undefined) setProp(obj, '$implicitAlias', true); } // If our column/thing was cast to a structured type, we need to keep the "cast" insync with the // flattened out leaf elements that we turn the ref into if (obj.cast?.type) { const addedRef = currentRef.slice(root.ref.length); if (addedRef.length > 0) { // Decouple from other leafs obj.cast = { ...obj.cast }; if (!obj.cast.type.ref) obj.cast.type = { ref: [ obj.cast.type ] }; obj.cast.type.ref = [ ...obj.cast.type.ref, ...addedRef ]; } } // The Java runtime, as of 2023-09-13, assumes that for _simple projections_, all references // are relative to the query source. To avoid breaking that assumption unless necessary, // we only add the table alias if: // - it is a complex query with possibly multiple available table aliases, or // - the transformation is not for OData (which is used by Java), or // - the first path step has the same name as the table alias (only one, as otherwise the query would be complex) if (typeof root.$env === 'string' && (isComplexOrNestedQuery || options.transformation !== 'odata' || root.$env === pathId(obj.ref[0]))) obj.ref = [ root.$env, ...obj.ref ]; if (iterateOptions.keepKeysOrigin) { setProp(obj, '$originalKeyRef', { ref: root.ref, as: root.as }); setProp(obj, '$path', root.$path); } return obj; }); } /** * Expand `null` columns which were cast to a structure, that is: `null as name : Struct`. * Requires that `col` has an alias. * * @param {CSN.Element} art * @param {object} col * @param {boolean} withAlias Whether to add an explicit flattened alias to the expanded columns/references. * @returns {Array} */ function expandValAsStructure( art, col, withAlias ) { const colName = col.as || ''; // Expression-columns may have an internal name such as `$_column_N`. If the name is internal, // we should not publish names based upon the internal name. const isInternal = !col.as || !Object.prototype.propertyIsEnumerable.call(col, 'as'); return _expandStructCol(art, colName, col.cast.type?.ref || [ col.cast.type ], ( currentRef, currentAlias) => { const newCol = { ...col, val: col.val, cast: { type: { ref: currentRef } }, }; if (withAlias) { if (!isInternal) newCol.as = currentAlias.join(pathDelimiter); else setProp(newCol, 'as', currentAlias.join(pathDelimiter)); } return newCol; }); } /** * Get the effective name produced by the object * * @param {object} part A thing with a ref/as/func * @returns {string} */ function dbName( part ) { if (part.as) return part.as; else if (part.ref) return implicitAs(part.ref); else if (part.func) return part.func; return null; } /** * Replace the star and correctly put shadowed things in the right place. * * @param {object} base The raw set of things a * can expand to * @param {Array} subs Things - the .expand/.inline or .columns * @param {string[]} [excluding=[]] * @param {boolean} [isComplexQuery=false] Wether the query is a single source select or something more complex * @returns {Array} If there was a star, expand it and handle shadowing/excluding, else just return subs */ function replaceStar( base, subs, excluding = [], isComplexQuery = false ) { const stars = []; const names = Object.create(null); for (let i = 0; i < subs.length; i++) { const sub = subs[i]; if (sub !== '*') { const name = dbName(sub); names[name] = i; } else { // There should only be one * - but be prepared for more than one stars.push(i); } } // We have stars - replace/expand them if (stars.length > 0) { const replaced = Object.create(null); const final = []; const star = []; // Build the result of a * - for later use for (const part of Object.keys(base)) { if (excluding.indexOf(part) === -1) { // The thing is shadowed - ignore names present because of .inline, as those "disappear" if (names[part] !== undefined && !subs[names[part]].inline) { // Only works for a single * - but a second is forbidden anyway if (names[part] > stars[0]) { // explicit definitions BEFORE the star should stay "infront" of the star replaced[part] = true; star.push(subs[names[part]]); } } else { // the thing is not shadowed - use the name from the base const col = part.startsWith('$') ? { ref: [ base[part][0].parent, part ] } : { ref: [ part ] }; if (isComplexQuery) // $env: tableAlias setProp(col, '$env', base[part][0].parent); star.push(col); } } } // Finally: Replace the stars and leave out the shadowed things for (const sub of subs) { if (sub !== '*' && !replaced[dbName(sub)]) final.push(sub); else if (sub === '*') final.push(...star); } return final; } return subs; } } module.exports = { expandStructureReferences, };