UNPKG

@sap/cds-compiler

Version:

CDS (Core Data Services) compiler and backends

1,174 lines (1,055 loc) 76.3 kB
'use strict'; const { setProp, forEachGeneric, forEachDefinition, isBetaEnabled, } = require('../base/model'); const { makeMessageFunction } = require('../base/messages'); const { recompileX } = require('../compiler/index'); const { linkToOrigin, pathName } = require('../compiler/utils'); const { compactModel, compactExpr } = require('../json/to-csn'); const { deduplicateMessages } = require('../base/messages'); const { timetrace } = require('../utils/timetrace'); const { CompilerAssertion } = require('../base/error'); // Paths that start with an artifact of protected kind are special // either ignore them in QAT building or in path rewriting const internalArtifactKinds = [ 'builtin', '$parameters', 'param' ]; function translateAssocsToJoinsCSN(csn, options) { timetrace.start('A2J: Recompiling model'); // Do not re-complain about localized const compileOptions = { ...options, $skipNameCheck: true }; delete compileOptions.csnFlavor; const model = recompileX(csn, compileOptions); timetrace.stop('A2J: Recompiling model'); timetrace.start('A2J: Translating associations to joins'); translateAssocsToJoins(model, options); timetrace.stop('A2J: Translating associations to joins'); // Use the effective elements list as columns forEachDefinition(model, (art) => { if (art.$queries) { for (const query of art.$queries) { query.columns = Object.values(query.elements); // TODO: Remove viaAll for (const elemName in query.elements) { const elem = query.elements[elemName]; if (elem.$inferred === '*') delete elem.$inferred; } } } }); if (options.messages) { // Make sure that we don't complain twice about the same things deduplicateMessages( options.messages ); } return compactModel(model, compileOptions); } function translateAssocsToJoins(model, inputOptions = {}) { const { error, warning, throwWithError } = makeMessageFunction(model, inputOptions, 'a2j'); const options = model.options || inputOptions; // create JOINs for foreign key paths const noJoinForFK = options.forHana ? !options.joinfk : true; // Note: This is called from the 'forHana' transformations, so it is controlled by its options const pathDelimiter = (options.forHana && options.sqlMapping === 'hdbcds') ? '.' : '_'; forEachDefinition(model, prepareAssociations); forEachDefinition(model, transformQueries); // If A2J reports error - end! Continuing with a broken model makes no sense throwWithError(); return model; function prepareAssociations(art) { if (art.kind === 'element' && art.target) { /* Create the prefix string up to the main artifact which is prepended to all source side paths of the resulting ON condition (cut off name.id from name.element) */ art.$elementPrefix = ''; for (let parent = art._parent; parent?.kind === 'element'; parent = parent._parent) art.$elementPrefix = parent.name.id + pathDelimiter + art.$elementPrefix; /* Create path prefix tree for Foreign Keys, required to substitute aliases in ON cond calculation, also very useful to detect fk overlaps. */ if (art.foreignKeys && !art.$fkPathPrefixTree) { art.$fkPathPrefixTree = { children: Object.create(null) }; forEachGeneric(art, 'foreignKeys', (fk) => { let ppt = art.$fkPathPrefixTree; fk.targetElement.path.forEach((ps) => { ppt.children[ps.id] ??= { children: Object.create(null) }; ppt = ppt.children[ps.id]; }); ppt._fk = fk; }); } } // drill into structures forEachGeneric(art, 'elements', prepareAssociations); } function transformQueries(art) { if (art.$queries === undefined) return; function forEachQuery(callback, env) { art.$queries.forEach((q, i) => { if (env !== undefined) env.queryIndex = i; callback(q, env); }); } const env = { aliasCount: 0, walkover: { from: true, onCondFrom: true, select: true, filter: true, }, }; /* Setup QAs for mixins Mark all mixin assoc definitions with a pseudo QA that points to the assoc target. This QA is required to detect mixin assoc usages to decide whether a minimum or full join needs to be done */ forEachQuery(createQAForMixinAssoc, env); /* Setup QATs and leaf QAs (@ query and subqueries in from clause) a) For all paths in a query create the path prefix trees aka QATs. Paths that start with a mixin assoc are Qat'ed into the mixin definition. If a mixin assoc is published, its leaf Qat receives the pseudo QA(view) from the rootQat, which is the mixin definition itself. See 1a) b) Create QAs for FROM clause subqueries, as they are not yet swept by the path walk */ env.callback = mergePathIntoQAT; forEachQuery(walkQuery, env); forEachQuery(createQAForFromClauseSubQuery, env); // 2) Walk over each from table path, transform it into a join tree env.walkover = { from: true, onCondFrom: false, select: false, filter: false, }; env.callback = createInnerJoins; forEachQuery(walkQuery, env); // 3) Transform all remaining join relevant paths into left outer joins and connect with // FROM block join tree. Instead of walking paths it is sufficient to process the $qat // of each $tableAlias. forEachQuery(createLeftOuterJoins, env); // 4) Rewrite ON condition paths that are part of the original FROM block // (same rewrite as (injected) assoc ON cond paths but with different table alias). // 5) Prepend table alias to all remaining paths env.walkover = { from: false, onCondFrom: true, select: true, filter: false, }; env.callback = substituteDollarSelf; forEachQuery(walkQuery, env); env.callback = rewriteGenericPaths; forEachQuery(walkQuery, env); // 6) Attach firstFilterConds to Where Condition. forEachQuery(attachFirstFilterConditions); } // Transform each FROM table path into a join tree and attach the tree to the path object function createInnerJoins(fromPathNode, env) { const fqat = env.lead.$tableAliases[fromPathNode.name.id].$fqat; const joinTree = createJoinTree(env, undefined, fqat, 'inner', '$fqat', undefined); replaceTableAliasInPlace( fromPathNode, joinTree); } // Translate all other join relevant query paths into left outer join tree and attach it to the lead query function createLeftOuterJoins(query, env) { if (query.op.val === 'SELECT') { env.lead = query; let joinTree = query.from; for (const tan in query.$tableAliases) { if (query.$tableAliases[tan].kind !== '$self') { // don't drive into $projection/$self tableAlias (yet) const ta = query.$tableAliases[tan]; joinTree = createJoinTree(env, joinTree, ta.$qat, 'left', '$qat', ta.$QA); } } query.from = joinTree; } } /* Each leaf node of a table path must end in either a direct or a target artifact. During mergePathIntoQat() this 'leaf' artifact is marked as a QA at the corresponding 'leaf' QAT and to the respective $tableAlias which is used to link paths to the correct table alias. Subqueries are not considered in the mergePathIntoQat(), so a subquery QA must be created and added separately to the lead query $tableAlias'es. Also, the name of the subquery (the alias) needs to be set to the final QA alias name. */ function createQAForFromClauseSubQuery(query, env) { for (const taName in query.$tableAliases) { if (query.$tableAliases[taName].kind !== '$self') { const ta = query.$tableAliases[taName]; if (!ta.$QA) { let alias = taName; if (ta.name.$inferred === '$internal') { // query has no explicit table alias, i.e. is internal: make it visible and remove `$` alias = ta.name.id.replace(/^[$]/, '_'); ta.$inferred = undefined; ta.name.$inferred = undefined; } ta.$QA = createQA(env, ta._origin, alias, undefined); incAliasCount(env, ta.$QA); if (ta.name && ta.name.id) ta.name.id = ta.$QA.name.id; } } } // Only subqueries of the FROM clause have a name (which is the alias) // TODO Discuss: a query does not have a name.id anymore // const queryAlias = query._parent; // parent could also be outer query, or main entity // if(query.op.val === 'SELECT' && query.name.id && queryAlias && queryAlias.kind === '$tableAlias') // { // query.name.id = queryAlias._parent.$tableAliases[query.name.id].$QA.name.id; // } } /* Add an artificial QA for each mixin definition. This QA completes the QAT data-structure that requires a QA at the rootQat before starting the join generation. This QA is marked as 'mixin' which indicates that the paths of the ON condition must not receive the usual source and target table alias (which is used for generic associations) but instead just use the rootQA of the individual ON condition paths. These paths are resolved against the FROM clause and must of course be connected to the respective table aliases. */ function createQAForMixinAssoc(query, env) { if (query.op.val === 'SELECT') { env.lead = query; // use view as QA origin forEachGeneric(query, 'mixin', (art) => { if (!art.$QA) { art.$QA = createQA(env, art.target._artifact, art.name.id ); art.$QA.mixin = true; } }); } } /* Substitute $self/$projection expression with its value */ function substituteDollarSelf(pathNode, env) { // do not substitute $self values for outer order by clauses if (env?.location === 'UnionOuterOrderBy') return; let pathValue = pathNode; let [ head, ...tail ] = pathValue.path; while (tail.length && head._navigation?.kind === '$self') { const self = head; [ head, ...tail ] = tail; if (head) { pathValue = self._navigation._origin.elements[head.id].value; // core compiler has already caught $self.<assoc>.<postfix> and // non-path $self expressions with postfix path if (pathValue.path) { if (tail.length) pathValue = constructPathNode([ ...pathValue.path, ...tail ], pathValue.alias, false); [ head, ...tail ] = pathValue.path; } } } if (head) replaceNodeContent(pathNode, pathValue); } /* Prefix all paths with table alias (or replace existing alias) Rewrite a given path of the native ON condition to TableAlias.ColumnName and substitute all eventually occurring foreign key path segments against the respective FK aliases. No flattening of structured leaf types necessary, this is done in renderer */ function rewriteGenericPaths(pathNode, env) { if (pathNode.$rewritten) return; if (env.location === 'onCondFrom') { if (checkPathDictionary(pathNode, env)) { const [ tableAlias, tail ] = constructTableAliasAndTailPath(pathNode.path); const pathStr = translateONCondPath(tail).map(ps => ps.id).join(pathDelimiter); replaceNodeContent(pathNode, constructPathNode([ tableAlias, { id: pathStr, _artifact: pathNode._artifact } ])); } } else { // Paths without _navigation in ORDER BY are select item aliases, they must // be rendered verbatim // eslint-disable-next-line prefer-const let [ head, ...tail ] = pathNode.path; if ((env.location === 'OrderBy' && !head._navigation) || env.location === 'UnionOuterOrderBy' && (!head._navigation || [ '$self', '$projection' ].includes(head.id))) return; // path outside ON cond: // spin the crystal ball to identify the correct table alias // pop ta ps if (head._navigation.kind !== '$tableAlias') tail = pathNode.path; const rootQA = head._navigation._parent.$QA || head._navigation.$QA; // if tail.length > 1, search bottom up for QA // default to rootQA, _parent.$QA has precedence const [ QA, ps ] = rightMostJoinRelevantQA(tail, rootQA); if (!QA) { error(null, pathNode.$location, { name: pathName(pathNode.path) }, 'Debug me: No QA found for generic path rewriting in $(NAME)'); return; } // if the found QA is the mixin QA and if the path length is one, // this indicates the publishing of a mixin assoc, don't rewrite the path if (QA.mixin && tail.length === 1) return; let pos = tail.indexOf(ps); // cut off ps if it's a join relevant association with postfix if (tail.length - (pos + 1) > 0 && ps._artifact.target && (rootQA.mixin || rootQA !== QA)) pos++; // QA + tail is the rewritten path tail = tail.slice(pos); // check from left to right (longest match) if a subsequent QAT is $njr // if so, substitute path with pregenerated foreign key, prepend by optional // (to be flattened) prefix for (let i = 0; i < tail.length - 1; i++) { if (tail[i]._navigation) { // the correct flattened foreign key must match the leaf artifact and access path prefix of this path const fk = findForeignKey(tail[i], tail[i + 1]); // if the assoc is not join relevant, we should have found the foreign key if (tail[i]._navigation.$njr && !fk) throw new CompilerAssertion('Debug me: No FK found for FK rewriting'); if (fk && fk.name.id !== tail[i + 1].id) tail[i + 1].id = fk.name.id; // fk renamed } } tail = [ { id: tail.map(p => p.id).join(pathDelimiter), _artifact: tail[tail.length - 1]._artifact, }, ]; replaceNodeContent(pathNode, constructPathNode([ constructTableAliasPathStep(QA), ...tail ])); } function findForeignKey(assoc, fk) { return Object.values(assoc._artifact.foreignKeys).find(k => k.targetElement._artifact === fk._artifact); } /** * Search right-to-left for first QA that matches either * - a $tableAlias * - or an association (skip this QA if postfix path is foreign key) * * If no QA found, return rootQA with first path step. */ function rightMostJoinRelevantQA(path, rootQA) { /* Search right to left to find first QA in QAT tree Start with n-1st path element (to not find QA for exposed nested association). If no QA could be found, return rootQA with first path step. */ let QA; let pl = path.length - 1; let ps = path[pl]; // return [null, ps] for pl==0 while (!QA && pl > 0) { const next = ps; ps = path[--pl]; if (ps._navigation) { const tailIsFk = !ps.where && !ps.args && ps._artifact.foreignKeys && findForeignKey(ps, next); if (tailIsFk) continue; QA = ps._navigation.$QA; } } return [ (QA || rootQA), ps ]; } } /* AND filter conditions of the first path steps of the FROM clause to the WHERE condition. If WHERE does not exist, create a new one. This step must be done after rewriteGenericPaths() as the filter expressions would be traversed twice. */ function attachFirstFilterConditions(query) { if (query.$startFilters) { if (query.where) { if (query.where.op.val === 'and') query.where.args.push(...query.$startFilters.map(parenthesise)); else query.where = { op: { val: 'and' }, args: [ parenthesise(query.where), ...query.$startFilters.map(parenthesise) ] }; } else { query.where = query.$startFilters.length > 1 ? { op: { val: 'and' }, args: query.$startFilters.map(parenthesise) } : parenthesise(query.$startFilters[0]); } } } /* Transform a QATree into a JOIN tree Starting from a root (parentQat) follow all QAT children and in case QAT.origin is an association, create a new JOIN node using the existing joinTree as LHS and the QAT.QA as RHS. */ function createJoinTree(env, joinTree, parentQat, joinType, qatAttribName, lastAssocQA) { for (const childQatId in parentQat) { const childQat = parentQat[childQatId]; // If this QAT is not join relevant, don't drill down any further but // continue with current parentQat if (!childQat.$njr) { let newAssocLHS = lastAssocQA; const art = childQat._origin; if (art.kind === 'entity') { if (!childQat.$QA) childQat.$QA = createQA(env, art, art.name.id.split('.').pop(), childQat._namedArgs); incAliasCount(env, childQat.$QA); newAssocLHS = childQat.$QA; if (joinTree === undefined) { // This is the first artifact in the JOIN tree joinTree = childQat.$QA; // Collect the toplevel filters and add them to the where condition if (childQat._filter) { // Filter conditions are unique for each JOIN, they don't need to be copied const filter = childQat._filter; rewritePathsInFilterExpression(filter, pathNode => [ /* tableAlias=> */ constructTableAliasPathStep(childQat.$QA), /* filterPath=> */ pathNode.path ], env); if (!env.lead.$startFilters) env.lead.$startFilters = []; env.lead.$startFilters.push( filter ); } } } else if (art.target) { // it's not an artifact, so it should be an assoc step if (joinTree === undefined) throw new CompilerAssertion('Can\'t follow Associations without starting Entity'); if (!childQat.$QA) childQat.$QA = createQA(env, art.target._artifact, art.name.id, childQat._namedArgs); incAliasCount(env, childQat.$QA); joinTree = createJoinQA(joinType, joinTree, childQat.$QA, childQat, lastAssocQA, env); newAssocLHS = childQat.$QA; } // Follow the children of this QAT to append more JOIN nodes joinTree = createJoinTree(env, joinTree, childQat[qatAttribName], joinType, qatAttribName, newAssocLHS); } } return joinTree; } function createJoinQA(joinType, lhs, rhs, assocQAT, assocSourceQA, env) { const node = { op: { val: 'join' }, join: { val: joinType }, args: [ lhs, rhs ] }; const assoc = assocQAT._origin; if (isBetaEnabled(options, 'mapAssocToJoinCardinality')) node.cardinality = mapAssocToJoinCardinality(assoc); // 'path steps' for the src/tgt table alias const srcTableAlias = constructTableAliasPathStep(assocSourceQA); const tgtTableAlias = constructTableAliasPathStep(assocQAT.$QA); node.on = createOnCondition(assoc, srcTableAlias, tgtTableAlias, options.tenantDiscriminator); if (assocQAT._filter) { // Filter conditions are unique for each JOIN, they don't need to be copied const filter = assocQAT._filter; rewritePathsInFilterExpression(filter, pathNode => [ tgtTableAlias, pathNode.path ], env); // If toplevel ON cond op is AND add filter condition to the args array, // create a new toplevel AND op otherwise const onCond = (Array.isArray(node.on) ? node.on[0] : node.on); if (onCond.op.val === 'and') onCond.args.push(parenthesise(filter)); else node.on = parenthesise({ op: { val: 'and' }, args: [ parenthesise(onCond), parenthesise(filter) ] }); } return node; /* Map assoc cardinality to allowed JOIN cardinality Allowed join cardinalities are: [ EXACT ] ONE | MANY TO [ EXACT ] ONE | MANY Source side EXACT ONE is not applicable with CSN due to missing sourceMin/Max Mapping: sourceMax != 1 > MANY, sourceMax = 1 > ONE targetMax != 1 > MANY, targetMax = 1 > ONE targetMin = 1 && targetMax = 1 > EXACT ONE Default is the CDS default for Association sourceMax = *, targetMax = 1 > MANY TO ONE Default is the CDS default for Composition sourceMin = 1, sourceMax = 1, targetMax = 1 > EXACT ONE TO ONE */ function mapAssocToJoinCardinality(assoc) { /** @type {object} */ const xsnCard = { targetMax: { literal: 'number', val: 1 }, }; if (assoc.type._artifact._effectiveType.name.id === 'cds.Composition') { xsnCard.sourceMin = { literal: 'number', val: 1 }; xsnCard.sourceMax = { literal: 'number', val: 1 }; } else { xsnCard.sourceMax = { literal: 'string', val: '*' }; } if (assoc.cardinality) { if (assoc.cardinality.sourceMax && assoc.cardinality.sourceMax.val === 1) { xsnCard.sourceMax.literal = 'number'; xsnCard.sourceMax.val = 1; } if (assoc.cardinality.targetMax && assoc.cardinality.targetMax.val !== 1) { xsnCard.targetMax.literal = 'string'; xsnCard.targetMax.val = '*'; } else if (assoc.cardinality.targetMin && assoc.cardinality.targetMin.val === 1) { xsnCard.targetMin = { literal: 'number', val: 1 }; } } return xsnCard; } // produce the ON condition for a given association function createOnCondition(assoc, srcAlias, tgtAlias, compareTenants) { const prefixes = [ assoc.name.id ]; /* This is no art and can be removed once ON cond for published and renamed backlink assocs are publicly available. Example: entity E { ...; toE: association to E; toEb: association to E on $self = toEb.toE; }; entity EP as projection on E { *, toEb as foo }; This requires ON cond rewritten to: $self = foo.toE but instead its still $self = toEb.toE, so prefix 'foo' won't match.... */ if (assoc._origin && !prefixes.includes(assoc._origin.name.id)) prefixes.push(assoc._origin.name.id); // produce the ON condition of the managed association if (assoc.foreignKeys) { /* Get both the source and the target column names for the EQ term. For the src side provide a path prefix for all paths that is the assocElement name itself preceded by the path up to the first lead artifact (usually the entity or view) (or in QAT speak: follow the parent QATs until a QA has been found). */ if (!assoc.$flatSrcFKs) setProp(assoc, '$flatSrcFKs', flattenElement(assoc, true, assoc.name.id, assoc.name.id)); if (!assoc.$flatTgtFKs) setProp(assoc, '$flatTgtFKs', flattenElement(assoc, false)); if (assoc.$flatSrcFKs.length !== assoc.$flatTgtFKs.length) throw new CompilerAssertion(`srcPaths length [${ assoc.$flatSrcFKs.length }] != tgtPaths length [${ assoc.$flatTgtFKs.length }]`); /* Put all src/tgt path siblings into the EQ term and create the proper path objects with the src/tgt table alias path steps in front. */ const args = compareTenants && addTenantComparison(assoc) || []; for (let i = 0; i < assoc.$flatSrcFKs.length; i++) { args.push({ op: { val: '=' }, args: [ constructPathNode( [ srcAlias, prefixFK(assoc.$elementPrefix, assoc.$flatSrcFKs[i]) ] ), constructPathNode( [ tgtAlias, assoc.$flatTgtFKs[i] ] ) ], }); } // TODO: why inner "parenthesise" - comparison in `and`? return parenthesise((args.length > 1 ? { op: { val: 'and' }, args: [ ...args.map(parenthesise) ] } : args[0] )); } else if (assoc.on) { if (env.assocStack === undefined) { env.assocStack = []; env.assocStack.head = function head() { return this[this.length - 1]; }; env.assocStack.id = function id() { return (this.head() && this.head().name.id); }; env.assocStack.element = function element() { return (this.head() && (this.head().name.element || this.head().name.id)); }; env.assocStack.stripAssocPrefix = function stripAssocPrefix(path) { return this.stripPrefix(path); }; // offset must be a negative value to indicate prefix length // offset=0 includes the element assoc id itself env.assocStack.stripPrefix = function stripPrefix(path, offset = 0) { const elt = this.element(); const id = this.id(); if (elt) { let found = true; const epath = [ elt ]; const epl = epath.length + offset; if (epl < path.length) { for (let i = 0; i < epl && found; i++) found = epath[i] === path[i].id; if (found) return path.slice(epl); } } if (id) { let found = true; const epath = [ id ]; const epl = epath.length + offset; if (epl < path.length) { for (let i = 0; i < epl && found; i++) found = epath[i] === path[i].id; if (found) return path.slice(epl); } } return path; }; } env.assocStack.push(assoc); const onCond = cloneOnCondition(assoc.on); env.assocStack.pop(); return compareTenants ? addTenantComparison(assoc, onCond) : onCond; } else if (!hasPersistenceSkipAnnotation(assoc._main)) { // TODO: exclude non-persisted entities from SQL generation; they may have // to-many associations without foreign keys nor ON-condition. throw new CompilerAssertion(`Association must have either ON-condition or foreign keys: ${ assoc.name.id } at ${ JSON.stringify(assoc.location) }`); } else { return null; } // Add tenant comparison function addTenantComparison(assoc, cond) { // It is enough to test whether the target is tenant-dependent. If it is, // the current query must also be (check in addTenantFields). If we allow // assocs from tenant-independent entities to tenant-dependent ones, we // also need to use the current query = `env.lead`. if (annotationVal(assoc.target._artifact['@cds.tenant.independent'])) return cond; const args = [ constructPathNode([ srcAlias ]), constructPathNode([ tgtAlias ]) ]; args[0].path.push({ id: 'tenant' }); // no need for _artifact args[1].path.push({ id: 'tenant' }); // no need for _artifact const comparison = { op: { val: '=' }, args }; if (!cond) // for managed assoc return [ comparison ]; return { op: { val: 'and' }, args: [ comparison, parenthesise(cond) ] }; } // make foreign key absolute to its main entity function prefixFK(prefix, fk) { return prefix ? { id: prefix + fk.id, _artifact: fk._artifact } : fk; } // clone ON condition with rewritten paths and substituted backlink conditions function cloneOnCondition(expr) { const op = expr.op?.val; if (op === 'xpr' || op === 'ixpr' || op === 'nary') return cloneOnCondExprStream(expr); return cloneOnCondExprTree(expr); } function cloneOnCondExprStream(expr) { const { args } = expr; const result = { op: { val: expr.op.val }, args: [ ] }; for (let i = 0; i < args.length; i++) { const op = args[i].op?.val; if (op === 'xpr' || op === 'ixpr' || op === 'nary') { result.args.push(cloneOnCondition(args[i])); } // If this is a backlink condition, produce the // ON cond of the forward assoc with swapped src/tgt aliases else if (i < args.length - 2 && args[i].path && args[i + 1]?.literal === 'token' && args[i + 1]?.val === '=' && args[i + 2].path) { const fwdAssoc = getForwardAssociation(args[i].path, args[i + 2].path); if (fwdAssoc) { // env.assocStack.includes(fwdAssoc) => recursion if (env.assocStack.length === 2) { error('type-invalid-self', [ env.assocStack[0].location, env.assocStack[0] ], { name: '$self' }); // don't check these paths again args[i].$check = false; args[i + 2].$check = false; } else { result.args.push(createOnCondition(fwdAssoc, ...swapTableAliasesForFwdAssoc(fwdAssoc, srcAlias, tgtAlias))); } i += 2; // skip next two tokens and continue with loop continue; } else { // it's ensured that it's a path result.args.push(rewritePathNode(args[i])); } } else { // could be `{op:…}`, clone generically result.args.push(cloneOnCondition(args[i])); } } return result; } function cloneOnCondExprTree(expr) { // TODO: This function is not covered by an tests, only cloneOnCondExprStream is. // keep parentheses intact if (Array.isArray(expr)) return expr.map(cloneOnCondition); // If this is a backlink condition, produce the // ON cond of the forward assoc with swapped src/tgt aliases const fwdAssoc = getForwardAssociationExpr(expr); if (fwdAssoc) { if (env.assocStack.length === 2) { // reuse (ugly) error message from forHana error(null, expr.location, { id: '$self' }, 'An association that uses $(ID) in its ON-condition can\'t be compared to $(ID)'); // don't check these paths again expr.args.forEach((x) => { x.$check = false; } ); return expr; } return createOnCondition(fwdAssoc, ...swapTableAliasesForFwdAssoc(fwdAssoc, srcAlias, tgtAlias)); } // If this is an ordinary expression, clone it and mangle its arguments // this will substitute multiple backlink conditions ($self = ... AND $self = ...AND ...) if (expr.op) { const x = clone(expr); if (expr.args) x.args = expr.args.map(cloneOnCondition); return x; } // If this is a regular path, rewrite it return rewritePathNode(expr); } // The src/tgtAliases need to be swapped for ON Condition of the forward assoc. // If the QAT assoc is a mixin and forward assoc was propagated, the original // forward definition must have a target in the query source otherwise the ON cond // is not resolvable (exception propagated mixins, as these are defined against the // view signature and not a query source). If the target is not part of the query source, // raise an error. Swap source and target otherwise. function swapTableAliasesForFwdAssoc(fwdAssoc, srcAlias, tgtAlias) { const newSrcAlias = tgtAlias; let newTgtAlias = {}; let i = 0; let fwdOrigin = fwdAssoc; while (fwdOrigin._origin) { fwdOrigin = fwdOrigin._origin; i++; } // If fwdAssoc was propagated and the origin is not a mixin itself (which always // points to the signature of the current view and ensures that the ON cond is // resolvable) make sure that the original assoc target is contained in the local // query source if (assoc.kind === 'mixin' && i > 0 && fwdOrigin.kind !== 'mixin') { const tas = Object.values(env.lead.$tableAliases); const i = tas.findIndex(ta => ta._artifact === fwdOrigin.target._artifact); if (i >= 0 && tas[i].$QA) { newTgtAlias.id = tas[i].$QA.name.id; newTgtAlias._artifact = tas[i]._effectiveType; newTgtAlias._navigation = tas[i].$QA.path[0]._navigation; } else { error(null, [ assocQAT._origin.location, assocQAT._origin ], { name: fwdOrigin.target._artifact.name.id, art: assoc.name.id }, 'Expected association target $(NAME) of association $(ART) to be a query source'); newTgtAlias = Object.assign(newTgtAlias, srcAlias); } } else { newTgtAlias = Object.assign(newTgtAlias, srcAlias); } return [ newSrcAlias, newTgtAlias ]; } function rewritePathNode(pathNode) { let tableAlias; let { path } = pathNode; if (!path) // it's not a path return it return pathNode; let [ head, ...tail ] = path; // don't rewrite path if (internalArtifactKinds.includes(head._artifact.kind)) return pathNode; // strip the absolute path indicators let hasDollarSelfPrefix = false; if ([ '$projection', '$self' ].includes(head.id) && tail.length) { hasDollarSelfPrefix = true; path = tail; } if (!checkPathDictionary(pathNode, env)) return pathNode; if (rhs.mixin) { if (hasDollarSelfPrefix) { /* Do the $projection resolution ONLY in own query not for referenced forward ON condition view YP as select from Y mixin ( toXP: association to XP on $projection.yid = toXP.xid; } into { yid }; view XP as select from X mixin { toYP: association to YP on $self = toYP.toXP; } into { xid, toYP.elt }; X join Y ON ($self = toYP.toXP) => ($projection.yid = toXP.xid) => (Y.yid = X.xid) $projection must be removed from $projection.yid (get's aliased with the mixinAssocQAT.$QA) */ if (env.assocStack.length < 2) { const { value } = env.lead.elements[path[0].id]; /* If the value is an expression in the select block, return the unmodified expression. rewriteGenericPaths will check and rewrite these paths later to the correct ON condition expression. */ if (!value.path) return value; // check for associations, not allowed at this time, trouble in resolving // and addressing the correct foreign key (tuple) [ head, ...tail ] = path; path = value.path.concat(tail); } } else { // $self/$projection without tail is an error: $self = $self } /* If all mixin assoc paths would result in the same join node (that is exactly one shared QAT for all mixin path steps) it would be sufficient to reuse the definition QA (see createQAForMixinAssoc()) for sharing the table alias. As mixin assoc paths may have different filter conditions, separate QATs are created for each distinct filter, resulting in separate JOIN trees requiring individual table aliases. This also requires separate QAs at the assoc QAT to hold the individual table aliases (that's why the definition QA is cloned in mergePathIntoQAT()). Paths in the ON condition referring to the target side are linked to the original mixin QA via head._navigation (done by the compiler), which in turn is childQat._parent (a mixin assoc path step MUST be path root, so _parent IS the mixin definition. Mixin QATs are created at the mixin definition). In order to create the correct table alias path, the definition QA must be replaced with the current childQat.QA (the clone with the correct alias). The original QA is used as template for its clones and can safely be replaced. Example: select from ... mixin { toTgt: association to Tgt on toTgt.elt = elt; } into { toTgt[f1].field1, toTgt[f2].field2 }; toTgt definition has definition QA, ON cond path 'toTgt' refers to definition QA. assoc path 'toTgt[f1].' and 'toTgt[f2]' have separate QATs with QA clones. 'toTgt.elt' must now be rendered for each JOIN using the correct QA clone. */ if (assocQAT.$QA.mixin) assocQAT._parent.$QA = assocQAT.$QA; /* if the $projection path has association path steps make sure to address the element by its last table alias name. Search from the end upwards to the top for the first association path step and cut off path here. */ let i = path.length - 1; while (i >= 0 && !path[i--]._artifact.target) ; // if this mixin ON condition path had a $projection/$self prefix, it could be // that the path of the select list had many many associations, we're only interested in // the last one (see MixinUsage2.cds V.toX as an example) if (hasDollarSelfPrefix) path.splice(0, i + 1); /* If the mixin is a backlink to some forward association, the forward ON condition needs to be added in inverse direction. The challenge is to find the correct QAs for the paths of the forward ON condition. Example: entity A { key id: Integer; } entity B { key id: Integer; toV: association to V on id = toV.id; elt: String; } view V as select from A mixin { toB: association to B on $self = toB.toV; // first use of 'id = toV.id' } into { A.id toB.elt }; view V1 as select from A mixin { toB: association to B on $self = toB.toV; // second use of 'id = toV.id' } into { A.id toB.elt }; Information we have: * this is the forward assoc env.assocStack.length == 2s * name of the forward association (env.assocStack) * the forward association's target side is this view => For all paths on the target side, we have to find the appropriate $tableAlias path._artifact is reference into view.elements, the value of the select item is the path in the select list. The first path step is linked into $tableAliases via _navigation * the forward association's source side is the target of the mixin (the assocQAT.QA) => easy: assocQAT is _navigation * If a $self is used multiple times, the forward ON cond paths are resolved to the original target (in the example above against V). However, we cannot lookup the _navigation link by following the _artifact.value.path[0] as this would always lead to V.query[0].$tableAliases.$A. Instead we need to lookup the element in the combined list of elements made available by the from clause. */ let _navigation; // don't modify original path if (env.assocStack.length === 2) { // a mixin assoc cannot have a structure prefix, it's sufficient to check head if (head.id === env.assocStack.id()) { // source side from view point of view (target side from forward point of view) path = tail; // pop assoc step const elt = env.lead._combined[path[0].id]; if (elt) { if (Array.isArray(elt)) { const names = elt.map(e => (e._origin._main || e._origin).name.id); error(null, [ assocQAT._origin.location, assocQAT._origin ], { elemref: path[0].id, id: assoc.name.id, art: assoc._main, names, }, 'Element $(ELEMREF) referred in association $(ID) of artifact $(ART) is available from multiple query sources $(NAMES)'); return pathNode.path; } // check if element has same origin on both ends if (elt._origin._main !== path[0]._artifact._origin._main) { warning(null, [ assocQAT._origin.location, assocQAT._origin ], { elemref: path[0].id, id: assoc.name.id, art: assoc._main.name.id, name: path[0]._artifact._origin._main.name.id, alias: elt._origin._main.name.id, source: elt._main.name.id, }, 'Element $(ELEMREF) referred in association $(ID) of artifact $(ART) originates from $(NAME) and from $(ALIAS) in $(SOURCE)'); } _navigation = elt._parent; } else { error(null, [ assocQAT._origin.location, assocQAT._origin ], { elemref: path[0].id, id: assoc.name.id, art: (assoc._main || assoc) }, 'Element $(ELEMREF) referred in association $(ID) of artifact $(ART) has not been found'); return pathNode.path; } } else { // target side from view point of view (source side from forward point of view) // if(assocQAT.$QA._artifact === path[0]._artifact._parent) _navigation = assocQAT; } } [ tableAlias, path ] = constructTableAliasAndTailPath(path, _navigation); } else { // ON condition of non-mixin association // strip a structure prefix from this ON cond path (offset -1) path = env.assocStack.stripPrefix(path, -1); [ head, ...tail ] = path; if (prefixes.includes(head.id)) { // target side // no element prefix on target side path = translateONCondPath(tail); tableAlias = tgtAlias; } else { // source side tableAlias = srcAlias; // if path is not an absolute path, prepend element prefix path = translateONCondPath(path, !hasDollarSelfPrefix ? assoc.$elementPrefix : undefined); } } const pathStr = path.map(ps => ps.id).join(pathDelimiter); return constructPathNode([ tableAlias, { id: pathStr, _artifact: pathNode._artifact } ]); } // Return the original association if expr is a backlink term, undefined otherwise function getForwardAssociationExpr(expr) { if (expr.op && expr.op.val === '=' && expr.args.length === 2) return getForwardAssociation(expr.args[0].path, expr.args[1].path); return undefined; } function getForwardAssociation(lhs, rhs) { // [alpha.]BACKLINK.[beta.]FORWARD if (lhs && rhs) { if (rhs.length === 1 && rhs[0].id === '$self' && lhs.length > 1 && hasPrefix(lhs)) return lhs[lhs.length - 1]._artifact; if (lhs.length === 1 && lhs[0].id === '$self' && rhs.length > 1 && hasPrefix(rhs)) return rhs[rhs.length - 1]._artifact; } function hasPrefix(path) { return path.reduce((rc, ps) => (!rc ? (ps.id === env.assocStack.id()) : rc), false); } return undefined; } } // createOnCondition } // createJoinQA /* A QA (QueryArtifact) is a representative for a table/view that must appear in the FROM clause either named directly or indirectly through an association. */ function createQA(env, artifact, alias, namedArgs = undefined) { if (alias === undefined) throw new CompilerAssertion('no alias provided'); const pathStep = { id: (artifact._main || artifact).name.id, _artifact: artifact, _navigation: { name: { select: env.queryIndex + 1 } }, // ??? }; if (namedArgs) pathStep.args = namedArgs; if (isBooleanAnnotation(artifact['@cds.persistence.udf'], true)) pathStep.$syntax = 'udf'; if (isBooleanAnnotation(artifact['@cds.persistence.calcview'], true)) pathStep.$syntax = 'calcview'; const node = constructPathNode( [ pathStep ], alias ); return node; } // Remark CW: why boolean and not just truthy/falsy as usual? See annotationVal() below function isBooleanAnnotation(prop, val = true) { return prop && prop.val !== undefined && prop.val === val && prop.literal === 'boolean'; } function incAliasCount(env, QA) { if (!QA.numberedAlias) { // Debug only: // QA.name.id += '_' + (QA.path[0]._navigation === undefined ? '***navigation_missing***' : QA.path[0]._navigation.name.select) + '_' + env.aliasCount++; QA.name.id += `_${ env.aliasCount++ }`; QA.numberedAlias = true; } } /* Recursively walk over expression and replace any found path with a new path consisting of two path steps. The first path step is the table alias and the second path step is the concatenated string of the original path steps. Leaf _artifact of pathNode is used as the leaf artifact of the new path string. Both the table alias and the original (remaining) path steps are to be produced by getTableAliasAndPathSteps(). tableAlias = [ aliasName, _artifact, _navigation ] path = [ { id: ..., _artifact: ... (unused) } ] */ function rewritePathsInFilterExpression(node, getTableAliasAndPathSteps, env) { const innerEnv = { lead: env.lead, location: env.location, position: env.position, aliasCount: env.aliasCount, walkover: {}, callback: [ function rewritePathNode(pathNode) { if (checkPathDictionary(pathNode, env)) { const head = pathNode.path[0]; if (head._navigation?.kind === '$self') { substituteDollarSelf(pathNode); } else { const [ tableAlias, path ] = getTableAliasAndPathSteps(pathNode); const rewrittenPath = []; const leafArtifact = path.at(-1)._artifact; // Walk from left to right and search for first assoc. If assocs in filters become join relevant in the future, // i.e. not only fk-access, we need to revisit this for (let i = 0; i < path.length; i++) { const pathStep = path[i]; if (pathStep._artifact?.foreignKeys) { const possibleNonAliasedFkName = path.slice(i).map(ps => ps.id).join(pathDelimiter); if (!pathStep._artifact.$flatSrcFKs) setProp(pathStep._artifact, '$flatSrcFKs', flattenElement(pathStep._artifact, true, pathStep._artifact.name.id, pathStep._artifact.name.id)); const fk = pathStep._artifact.$flatSrcFKs.find(f => f._artifact === leafArtifact && f.acc.startsWith(possibleNonAliasedFkName)); if (fk) { rewrittenPath.push(fk); i = path.length; continue; } } rewrittenPath.push(pathStep); } replaceNodeContent(pathNode, constructPathNode([ tableAlias, { id: rewrittenPath.map(ps => ps.id).join(pathDelimiter), _artifact: pathNode._artifact } ])); } } }, ], }; walk(node, innerEnv); } /* Replace the content of the old node with the new one. If newNode is a not a path (expression or constant/literal value), oldPath must be cleared first. If newNode is a path => oldNode._artifact === newNode._artifact, no need to exchange _artifact (as non-iterable property it is not assigned). */ function replaceNodeContent(oldNode, newNode) { if (!newNode.path) { Object.keys(oldNode).forEach((k) => { delete oldNode[k]; }); delete oldNode._artifact; } Object.assign(oldNode, newNode); } /* Replace the table alias node in $tableAliases inplace with the newly created JOIN node See define.js initTableExpression for details where _joinParent and $joinArgsIndex is set. */ function replaceTableAliasInPlace( tableAlias, replacementNode ) { if (tableAlias._joinParent) tableAlias._joinParent.args[tableAlias.$joinArgsIndex] = replacementNode; else tableAlias._parent.from = replacementNode; } /* Collect all of paths to all leafs for a given element respecting the src or the target side of the ON condition. Return an array of column names and it's leaf element. */ function flattenElement(element, srcSide, prefix, acc) { // terminate if element is unstructured if (!element.foreignKeys && !element.elements) return [ { id: prefix, _artifact: element, acc } ]; let paths = []; // get paths of managed assocs (unmanaged assocs are not allowed in FK paths) if (element.foreignKeys) { for (const fkn in element.foreignKeys) { const fk = element.foreignKeys[fkn]; // ignore an unmanaged association if (fk.targetElement._artifact.target && fk.targetElement._artifact.on && !fk.targetElement._artifact.foreignKeys) continue; // once a fk is to be followed, treat all sub-paths as srcSide, this will add fk.name.id only if (srcSide) { paths = paths.concat(flattenElement(fk.targetElement._artifact, true, fk.name.id, fk.targetElement.path.map(ps => ps.id).join(pathDelimiter))); } else { // co