@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
1,174 lines (1,055 loc) • 76.3 kB
JavaScript
'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