@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
1,311 lines (1,186 loc) • 48.8 kB
JavaScript
// Tweak associations: rewrite keys and on conditions
'use strict';
const {
forEachGeneric,
forEachInOrder,
} = require('../base/model');
const { dictLocation, weakLocation, weakRefLocation } = require('../base/location');
const {
setLink,
setArtifactLink,
linkToOrigin,
copyExpr,
forEachUserArtifact,
forEachQueryExpr,
traverseQueryPost,
traverseQueryExtra,
setExpandStatus,
getUnderlyingBuiltinType,
} = require('./utils');
const { Location } = require('../base/location');
const { CompilerAssertion } = require('../base/error');
const $location = Symbol.for( 'cds.$location' );
const $inferred = Symbol.for( 'cds.$inferred' );
// Export function of this file.
function tweakAssocs( model ) {
// Get shared functionality and the message function:
const {
info, warning, error,
} = model.$messageFunctions;
const {
traverseExpr,
checkExpr,
checkOnCondition,
effectiveType,
getOrigin,
extendForeignKeys,
createRemainingAnnotateStatements,
mergeSpecifiedForeignKeys,
navigationEnv,
redirectionChain,
resolveExprInAnnotations,
} = model.$functions;
Object.assign(model.$functions, {
findRewriteTarget,
cachedRedirectionChain,
});
// Phase 5: rewrite associations
model._entities.forEach( rewriteArtifact ); // _entities contains all definitions, sorted.
// Think hard whether an on condition rewrite can lead to a new cyclic
// dependency. If so, we need other messages anyway. TODO: probably dox
// another cyclic check with testMode.js
forEachUserArtifact( model, 'definitions', function check( art ) {
checkOnCondition( art.on, (art.kind !== 'mixin' ? 'on' : 'mixin-on'), art );
checkExpr( art.value, (art.$syntax === 'calc' ? 'calc' : 'column'), art );
if (art.kind === 'select')
forEachQueryExpr( art, checkExpr );
} );
// create “super” ANNOTATE statements for annotations on unknown artifacts:
createRemainingAnnotateStatements();
return;
//--------------------------------------------------------------------------
// Phase 5: rewrite associations
//--------------------------------------------------------------------------
// Only top-level queries and sub queries in FROM
function rewriteArtifact( art ) {
if (!art.query) {
rewriteAssociation( art );
}
else {
traverseQueryExtra( art, ( query ) => {
forEachGeneric( query, 'elements', rewriteAssociation );
} );
}
if (art._service)
forEachGeneric( art, 'elements', complainAboutTargetOutsideService );
if (art.query) {
traverseQueryPost(art.query, false, (query) => {
forEachGeneric( query, 'elements', handleQueryElements );
});
}
}
// function rewriteView( view ) {
// // TODO: we could sort according to the $effectiveSeqNo instead
// // (and then remove traverseQueryExtra)
// if (view.includes) // entities with structure includes:
// forEachGeneric( view, 'elements', rewriteAssociation );
// }
// Check explicit ON / keys with REDIRECTED TO
// TODO: run on all queries, but this is potentially incompatible
// function rewriteViewCheck( view ) {
// traverseQueryPost( view.query, false, ( query ) => {
// forEachGeneric( query, 'elements', rewriteAssociationCheck );
// } );
// }
function complainAboutTargetOutsideService( elem ) {
const target = elem.target && elem.target._artifact;
if (!target || target._service) // assoc to other service is OK
return;
const loc = [ elem.target.location, elem ];
const main = elem._main || elem;
if (!elem.$inferred && !main.$inferred && !model.options.$recompile) {
info( 'assoc-target-not-in-service', loc,
{ target, '#': (elem._main.query ? 'select' : 'define') }, {
std: 'Association target $(TARGET) is outside any service', // not used
define: 'Target $(TARGET) of explicitly defined association is outside any service',
select: 'Target $(TARGET) of explicitly selected association is outside any service',
} );
}
else {
const text = main.$inferred === 'autoexposed' ? 'exposed' : 'std';
// ID published! Used in stakeholder project; if renamed, add to oldMessageIds
info( 'assoc-outside-service', loc, { '#': text, target, service: main._service }, {
std: 'Association target $(TARGET) is outside any service',
// eslint-disable-next-line @stylistic/max-len
exposed: 'If association is published in service $(SERVICE), its target $(TARGET) is outside any service',
} );
}
}
function handleQueryElements( column ) {
rewriteAssociationCheck( column );
if (model.options.noDollarCalc || column._columnParent)
return; // no $calc supported with inline
const { value } = column; // `value` = column expression
if (!value || value.path) // not with references
return;
// TODO: what about non-simple refs (assocs, even with filter/args)?
// with “real” expressions, set $calc according to these
// (with references, $calc might be inherited from the source element)
column.$calc = copyExpr( column.value, null ); // copy while keeping location
if (traverseExpr.STOP ===
traverseExpr( column.$calc, 'rewrite-on', column,
ref => rewriteColumnPath( ref, column ) ))
column.$calc = true;
}
// Check explicit ON / keys with REDIRECTED TO
function rewriteAssociationCheck( element ) {
const elem = element.items || element; // TODO v6: nested items
if (elem.elements)
forEachGeneric( elem, 'elements', rewriteAssociationCheck );
if (!elem.target)
return;
if (elem.on && !elem.on.$inferred) {
const assoc = getOrigin( elem );
if (assoc && assoc.foreignKeys) {
error( 'rewrite-key-for-unmanaged', [ elem.on.location, elem ],
{ keyword: 'on', art: assocWithExplicitSpec( assoc ) },
// eslint-disable-next-line @stylistic/max-len
'Do not specify an $(KEYWORD) condition when redirecting the managed association $(ART)' );
}
checkIgnoredFilter( elem );
}
else if (elem.foreignKeys && !inferredForeignKeys( elem.foreignKeys )) {
const assoc = getOrigin( elem );
if (assoc?.on) {
error( 'rewrite-on-for-managed',
[ elem.foreignKeys[$location] || dictLocation( elem.foreignKeys ), elem ],
{ art: assocWithExplicitSpec( assoc ) },
'Do not specify foreign keys when redirecting the unmanaged association $(ART)' );
}
else if (assoc?.foreignKeys) {
// same sequence is not checked
rewriteKeysMatch( elem, assoc );
rewriteKeysCovered( assoc, elem );
}
checkIgnoredFilter( elem );
}
}
/**
* Publishing an association with filters is allowed, but the filter is ignored
* if the association is redirected. That indicates modeling mistakes, so we
* emit a warning.
*/
function checkIgnoredFilter( elem ) {
const lastStep = elem.value?.path?.[elem.value.path.length - 1];
if (lastStep?.where) {
const loc = lastStep.where.location;
const variant = elem.foreignKeys ? 'fKey' : 'onCond';
warning( 'query-ignoring-filter', [ loc, elem ], { '#': variant } );
}
}
function rewriteKeysMatch( thisAssoc, otherAssoc ) {
const { foreignKeys } = thisAssoc;
for (const name in foreignKeys) {
if (otherAssoc.foreignKeys[name])
continue; // we would do a basic type check later
const key = foreignKeys[name];
const baseAssoc = assocWithExplicitSpec( otherAssoc );
if (inferredForeignKeys( baseAssoc.foreignKeys )) { // still inferred = via target keys
error( 'rewrite-key-not-matched-implicit', [ key.name.location, key ],
{ name, target: baseAssoc.target },
'No key $(NAME) is defined in original target $(TARGET)' );
}
else {
error( 'rewrite-key-not-matched-explicit', [ key.name.location, key ],
{ name, art: baseAssoc },
'No foreign key $(NAME) is specified in association $(ART)' );
}
}
}
function rewriteKeysCovered( thisAssoc, otherAssoc ) {
const names = [];
const { foreignKeys } = thisAssoc;
for (const name in foreignKeys) {
if (!otherAssoc.foreignKeys[name])
names.push( name );
}
if (names.length) {
const loc = otherAssoc.foreignKeys[$location] || dictLocation( otherAssoc.foreignKeys );
const location = loc && (!loc.endCol
? loc
: new Location( loc.file, loc.endLine, loc.endCol - 1, loc.endLine, loc.endCol ));
const baseAssoc = assocWithExplicitSpec( thisAssoc );
if (inferredForeignKeys( baseAssoc.foreignKeys )) { // still inferred = via target keys
error( 'rewrite-key-not-covered-implicit', [ location, otherAssoc ],
{ names, target: baseAssoc.target },
{
std: 'Specify keys $(NAMES) of original target $(TARGET) as foreign keys',
one: 'Specify key $(NAMES) of original target $(TARGET) as foreign key',
} );
}
else {
error( 'rewrite-key-not-covered-explicit', [ location, otherAssoc ],
{ names, art: otherAssoc },
{
std: 'Specify foreign keys $(NAMES) of association $(ART)',
one: 'Specify foreign key $(NAMES) of association $(ART)',
} );
}
}
}
function assocWithExplicitSpec( assoc ) {
while (assoc.foreignKeys && inferredForeignKeys( assoc.foreignKeys, 'keys' ) ||
assoc.on && assoc.on.$inferred)
assoc = getOrigin( assoc );
return assoc;
}
function rewriteAssociation( element ) {
doRewriteAssociation( element );
if (element.target) {
extendForeignKeys( element );
if (element.foreignKeys$) {
// TODO: Also checkSpecifiedElement?
mergeSpecifiedForeignKeys( element );
}
for (const key in element.foreignKeys)
// TODO: This will re-evaluate all annotations
resolveExprInAnnotations( element.foreignKeys[key] );
}
}
// only to be used by rewriteAssociation()
function doRewriteAssociation( element ) {
let elem = element.items || element; // TODO v6: nested items
if (elem.elements)
forEachGeneric( elem, 'elements', rewriteAssociation );
if (elem.targetAspect?.elements)
forEachGeneric( elem.targetAspect, 'elements', rewriteAssociation );
if (!originTarget( elem ))
return;
// console.log(message( null, elem.location, elem,
// {art:assoc,target,ftype:JSON.stringify(ftype)}, 'Info','RA').toString())
// With cyclic dependencies on select items, testing for the _effectiveType to
// be 0 (test above) is not enough if we we have an explicit redirection
// target -> avoid infloop ourselves with _status.
// TODO: this should be good now
const chain = [];
while (!elem.on && elem.foreignKeys == null) {
chain.push( elem );
if (elem._status === 'rewrite') { // circular dependency (already reported)
for (const e of chain)
setLink( e, '_status', null ); // XSN TODO: nonenum _status -> enum $status
return;
}
setLink( elem, '_status', 'rewrite' );
elem = getOrigin( elem );
if (!elem || elem.builtin) // safety
return;
}
chain.reverse();
for (const art of chain) {
setLink( elem, '_status', null );
if (elem.on)
rewriteCondition( art, elem );
else if (elem.foreignKeys)
rewriteKeys( art, elem );
if (art.on)
removeManagedPropsFromUnmanaged( art );
elem = art;
}
}
/**
* Remove properties from unmanaged association `elem` that are only valid
* on managed associations. Only set to `NULL` (special value for propagator),
* if necessary, i.e. the value is set on the `_origin`-chain.
*/
function removeManagedPropsFromUnmanaged( elem ) {
removeProp( 'notNull' );
removeProp( 'default' );
function removeProp( prop ) {
let origin = elem;
while (origin) {
if (origin[prop]) { // regardless of the value, reset the property
const location = weakLocation( elem.name.location );
elem[prop] = { $inferred: 'NULL', val: undefined, location };
break;
}
origin = getOrigin( origin );
}
}
}
/** Returns the element's origin's target artifact. */
function originTarget( elem ) {
const assoc = !elem.expand && getOrigin( elem );
const ftype = assoc && effectiveType( assoc );
return ftype && ftype.target && ftype.target._artifact;
}
function inferredForeignKeys( foreignKeys, ignore ) {
return foreignKeys[$inferred] && foreignKeys[$inferred] !== ignore;
}
function rewriteKeys( elem, assoc ) {
addConditionFromAssocPublishing( elem, assoc, null );
if (elem.on)
return; // foreign keys were transformed into ON-condition
// TODO: split this function: create foreign keys without `targetElement`
// already in Phase 2: redirectImplicitly()
elem.foreignKeys = Object.create(null); // set already here (also for zero foreign keys)
forEachInOrder( assoc, 'foreignKeys', ( orig, name ) => {
const location = weakRefLocation( elem.target );
const fk = linkToOrigin( orig, name, elem, 'foreignKeys', location );
fk.$inferred = 'rewrite'; // Override existing value; TODO: other $inferred value?
setLink( fk, '_effectiveType', fk );
fk.targetElement = copyExpr( orig.targetElement, location );
if (elem._redirected)
rewriteKey( elem, fk );
} );
if (elem.foreignKeys) // Possibly no fk was set
elem.foreignKeys[$inferred] = 'rewrite';
}
function rewriteKey( elem, fk ) {
const { targetElement } = fk;
let projectedKey = null;
// rewrite along redirection chain
for (const alias of elem._redirected) {
if (alias.kind === '$tableAlias') {
projectedKey = firstProjectionForPath( targetElement.path, 0, alias, null );
if (projectedKey.elem) {
const item = targetElement.path[projectedKey.index];
item.id = projectedKey.elem.name.id;
if (projectedKey.index > 0)
targetElement.path.splice(0, projectedKey.index);
}
else {
setArtifactLink( targetElement.path[0], null );
setArtifactLink( targetElement, null );
const culprit = !elem.target.$inferred && elem.target ||
elem.value?.path?.[elem.value.path.length - 1] ||
elem;
// TODO: probably better to collect the non-projected foreign keys
// and have one message for all
error('rewrite-undefined-key', [ weakLocation( culprit.location ), elem ], {
'#': 'std',
id: targetElement.path.map(p => p.id).join('.'),
target: alias._main,
name: elem.name.id,
});
return null;
}
}
else {
// e.g. redirection target is entity that includes original target
projectedKey = { elem: findTargetElement( alias, targetElement ) };
}
}
if (projectedKey?.elem) {
const item = targetElement.path[0];
setArtifactLink( item, projectedKey.elem );
setArtifactLink( targetElement, projectedKey.elem );
return projectedKey.elem;
}
return null;
}
/**
* Find the target element in the given redirection target.
* Used to find the target element in entities that include the original
* target entity.
*
* @param redirected
* @param targetElement
* @returns {*|null}
*/
function findTargetElement( redirected, targetElement ) {
for (const step of targetElement.path) {
redirected = redirected.elements?.[step.id];
if (!redirected)
return null;
}
return redirected;
}
// TODO: there is no need to rewrite the on condition of non-leading queries,
// i.e. we could just have on = {…}
// TODO: re-check $self rewrite (with managed composition of aspects),
// and actually also $self inside anonymous aspect definitions
// (not entirely urgent as we do not analyse it further, at least sole "$self")
function rewriteCondition( elem, assoc ) {
// the ON condition might need to be rewritten even if the target stays the
// same (TODO later: set status whether rewrite changes anything),
// especially problematic are refs starting with $self:
setExpandStatus( elem, 'target' );
// There were previous issues in resolving the target artifact.
// Avoid further compiler messages.
if (!elem.target._artifact)
return;
if (elem._parent?.kind === 'element') {
// unmanaged association as sub element not supported yet
// TODO: Only report once for multi-include chains, see
// Associations/SubElements/UnmanagedInSubElement.err.cds
error( 'type-unsupported-rewrite', [ elem.location, elem ], { '#': 'sub-element' } );
removeArtifactLinks();
return;
}
const nav = (elem._main?.query && elem.value)
? pathNavigation( elem.value ) // redirected source elem or mixin
: { navigation: assoc }; // redirected user-provided
elem.on = copyExpr( assoc.on,
// replace location in ON except if from mixin element
nav.tableAlias && elem.name.location );
elem.on.$inferred = 'copy';
const { navigation } = nav;
if (!navigation) { // TODO: what about $projection.assoc as myAssoc ?
if (elem._columnParent) {
error( 'rewrite-not-supported', [ elem.target.location, elem ], { '#': 'inline-expand' } );
removeArtifactLinks();
}
return; // should not happen: $projection, $magic, or ref to const
}
if (!nav.tableAlias || nav.tableAlias.path) {
const navEnv = followNavigationPath( elem.value?.path, nav ) || nav.tableAlias;
traverseExpr( elem.on, 'rewrite-on', elem,
( expr ) => {
rewriteExpr( expr, elem, nav.tableAlias, navEnv );
return traverseExpr.SKIP; // TODO: really necessary?
} );
}
else if (elem._columnParent) {
error( 'rewrite-not-supported', [ elem.target.location, elem ], { '#': 'inline-expand' } );
removeArtifactLinks();
return;
}
else {
// TODO: support that, now that the ON condition is rewritten in the right order
error( null, [ elem.value.location, elem ], {},
'Selecting unmanaged associations from a sub query is not supported' );
removeArtifactLinks();
return;
}
addConditionFromAssocPublishing( elem, assoc, nav );
elem.on.$inferred = 'rewrite';
/**
* Clear all `_artifact` links in the ON-condition to avoid follow-up
* issues during ON-condition rewriting of associations that inherit
* the ON-condition.
*/
function removeArtifactLinks() {
traverseExpr( elem.on, 'rewrite-on', elem, (expr) => {
setArtifactLink( expr, null );
return traverseExpr.SKIP; // TODO: necessary?
} );
}
}
/**
* If an unmanaged association is being published, we add a potential
* filter to the ON-condition and use its cardinality.
* If a managed association is published, we transform it into an unmanaged
* and do the same.
*
* The added condition (filter) is already rewritten relative to `elem`.
*/
function addConditionFromAssocPublishing( elem, assoc, nav ) {
if (elem.$inferred || elem._main?.$inferred === 'composition-entity') {
// filter was copied in original element already
return;
}
const publishAssoc = (elem._main?.query || elem.$syntax === 'calc') &&
elem.value?.path?.length > 0;
if (!publishAssoc)
return;
nav ??= (elem._main?.query && elem.value)
? pathNavigation( elem.value ) // redirected source elem or mixin
: { navigation: assoc }; // redirected user-provided
const { location } = elem.name;
const lastStep = elem.value.path[elem.value.path.length - 1];
if (!lastStep || !lastStep.where)
return;
if (lastStep.cardinality) {
elem.cardinality ??= { ...assoc.cardinality };
elem.cardinality.location = location;
elem.cardinality.$inferred = 'rewrite';
for (const card of [ 'sourceMin', 'targetMin', 'targetMax' ]) {
if (lastStep.cardinality[card])
elem.cardinality[card] = copyExpr( lastStep.cardinality[card], location );
}
}
// If there are foreign keys, transform them into an ON-condition first.
if (assoc.foreignKeys) {
const cond = foreignKeysToOnCondition( elem, assoc, nav );
if (cond) {
elem.on = cond;
elem.foreignKeys = undefined;
}
}
elem.on = {
op: { val: 'ixpr', location },
args: [
{ ...elem.on, $parens: [ assoc.location ] },
{ val: 'and', literal: 'token', location },
filterToCondition( lastStep, elem, nav ),
],
location,
$inferred: 'copy',
};
// Published paths with filters are always associations, never
// compositions, hence we need to change the type to avoid type propagation.
const assocType = { id: 'cds.Association', location };
setArtifactLink( assocType, model.definitions['cds.Association'] );
elem.type = {
path: [ assocType ],
scope: 'global',
location,
$inferred: '$generated',
};
setArtifactLink( elem.type, assocType._artifact );
const isComp = (getUnderlyingBuiltinType( assoc )?.name?.id === 'cds.Composition');
if (isComp) {
elem.$enclosed = {
val: true,
literal: 'boolean',
location,
$inferred: '$generated',
};
}
}
/**
* Transform a filter on `assocPathStep` into an ON-condition.
* Paths inside the filter are rewritten relative to `assoc`, so they can be redirected
* using `rewriteExpr()` later on. `$self` paths remain unchanged.
*/
function filterToCondition( assocPathStep, elem, nav ) {
const cond = copyExpr( assocPathStep.where );
cond.$parens = [ assocPathStep.location ];
const navEnv = nav && followNavigationPath( elem.value?.path, nav ) || nav?.tableAlias;
traverseExpr( cond, 'rewrite-filter', elem, (expr) => {
if (!expr.path || expr.path.length === 0)
return traverseExpr.SKIP;
const root = expr.path[0]._navigation || expr.path[0]._artifact;
if (!root)
return traverseExpr.SKIP; // only for compile error, e.g. missing definition
if (root.kind === '$self') {
// $projection -> $self for recompilability
expr.path[0].id = '$self';
}
else if (!root.builtin && root.kind !== 'builtin') {
expr.path.unshift({
id: assocPathStep.id,
location: elem.name.location,
});
setLink( expr.path[0], '_artifact', assocPathStep._artifact );
if (assocPathStep._navigation?.kind === 'mixin') {
// _navigation link necessary because condition is rewritten
// inside the same view (needed for mixins).
setLink( expr.path[0], '_navigation', assocPathStep._navigation );
}
// up to here, filter is relative to original association
rewriteExpr( expr, elem, nav?.tableAlias, navEnv );
}
return traverseExpr.SKIP;
} );
checkOnCondition( cond, 'on', elem );
return cond;
}
// Caller must ensure ON-condition correctness via rewriteExpr()!
function foreignKeysToOnCondition( elem, assoc, nav ) {
if (model.options.testMode && !nav.tableAlias && !elem._columnParent && elem.$syntax !== 'calc')
throw new CompilerAssertion('rewriting keys to cond: no tableAlias but not inline/calc');
if ((!nav.tableAlias && elem.$syntax !== 'calc') || elem._parent?.kind === 'element' ||
(nav && nav.item && nav.item !== elem.value.path[elem.value.path.length - 1])) {
// - no nav.tableAlias for mixins or inside inline; mixins can't have managed assocs, though.
// - _parent is element for expand
// - nav.item is different for multi-path steps e.g. `sub.assoc`, which is not supported, yet
// TODO: Support this
error( 'rewrite-not-supported', [ elem.value.location, elem ] );
return null;
}
let cond = [];
forEachInOrder( assoc, 'foreignKeys', function keyToCond( fKey ) {
// Format: lhs = rhs
// assoc.id = assoc_id
// lhs and rhs look the same but are rewritten differently. We must ensure that
// the rhs is rewritten to a projected element (or it must remain the assoc's
// foreign key in case of calc elements).
const lhs = {
path: [
{ id: assoc.name.id, location: elem.name.location },
...copyExpr( fKey.targetElement.path, weakLocation( elem.name.location ) ),
],
location: elem.name.location,
};
setLink( lhs.path[0], '_artifact', assoc );
setLink( lhs, '_artifact', lhs.path[lhs.path.length - 1]._artifact );
rewritePath( lhs, lhs.path[0], assoc, elem, elem.value.location ); // different to rhs!
const rhs = {
path: [
// use origin's name; elem could have alias
{ id: assoc.name.id, location: elem.name.location },
...copyExpr( fKey.targetElement.path, weakLocation( elem.name.location ) ),
],
location: elem.name.location,
};
setLink( rhs.path[0], '_artifact', assoc );
setLink( rhs, '_artifact', rhs.path[rhs.path.length - 1]._artifact );
if (elem.$syntax !== 'calc') {
// Not passing an element, as we don't want to use our own filtered association here!
// That's done for lhs.
const projectedFk = firstProjectionForPath( rhs.path, 0, nav.tableAlias, null );
// different to lhs!
rewritePath( rhs, projectedFk.item, elem, projectedFk.elem, elem.value.location );
}
const fkCond = {
op: { val: 'ixpr', location: elem.name.location },
args: [
lhs,
{ val: '=', literal: 'token', location: elem.name.location },
rhs,
],
location: elem.name.location,
};
cond.push(fkCond);
} );
if (cond.length === 0) {
const lastStep = elem.value.path[elem.value.path.length - 1];
error( 'expr-missing-foreign-key', [ lastStep.location, elem ], {
'#': 'publishingFilter',
id: lastStep.id,
} );
return null;
}
cond = (cond.length === 1) ? cond[0]
: {
op: { val: 'and', location: elem.name.location },
args: cond,
location: elem.name.location,
};
return cond;
}
/**
* @param expr
* @param assoc
* @param tableAlias
* @param navEnv Navigation element / table alias, used to traverse/rewrite the path.
*/
function rewriteExpr( expr, assoc, tableAlias, navEnv = tableAlias ) {
// Rewrite ON condition (resulting in outside perspective) for association
// 'assoc' in query or including entity from ON cond of mixin element /
// element in included structure / element in source ref/d by table alias.
// TODO: complain about $self (unclear semantics)
if (!expr.path || !expr._artifact)
return;
if (!assoc._main)
return;
if (navEnv) { // from ON cond of element in source ref/d by table alias
const root = expr.path[0]._navigation || expr.path[0]._artifact;
if (!root || root.kind === 'builtin')
return; // not $self or source element, e.g. builtin
// parameters are not allowed in ON-conditions; error emitted elsewhere already
if (expr.scope === 'param' || root.kind === '$parameters')
return;
rewritePathForEnv( expr, navEnv, assoc );
}
else if (assoc._main.query) { // from ON cond of mixin element in query
// here also $calc
const root = expr.path[0]._navigation || expr.path[0]._artifact;
if (expr.scope === 'param' || root?.kind === '$parameters') {
if (assoc.$errorReported !== 'assoc-unexpected-scope') {
error( 'assoc-unexpected-scope', [ assoc.value.location, assoc ],
{ id: assoc.value._artifact.name.id },
// eslint-disable-next-line @stylistic/max-len
'Association $(ID) can\'t be projected because its ON-condition refers to a parameter' );
assoc.$errorReported = 'assoc-unexpected-scope';
}
return;
}
if (expr.path[0]._navigation) { // rewrite: src elem, mixin, $self[.elem]
const nav = pathNavigation( expr );
const elem = (assoc._origin === root) ? assoc : navProjection( nav.navigation, assoc );
// TODO: Use rewritePathForEnv(); make it handle mixins
rewritePath( expr, nav.item, assoc, elem,
nav.item ? nav.item.location : expr.path[0].location );
}
}
else { // from ON cond of element that was included (i.e. from included structure)
const root = expr.path[0]._navigation || expr.path[0]._artifact;
if (root.builtin || root.kind !== '$self' && root.kind !== 'element')
return;
const item = expr.path[root.kind === '$self' ? 1 : 0];
if (!item)
return; // just $self
// corresponding elem in including structure or…
let elem = (assoc._main.items || assoc._main).elements[item.id];
if (assoc.$syntax === 'calc' && assoc._origin === elem) {
// … calc element where "elem" points to the referenced (possibly included)
// sibling element (association).
elem = assoc;
}
if (!elem)
return; // See #11755
if (!(elem === item._artifact || // redirection for explicit def
elem._origin === item._artifact)) {
const art = assoc._origin;
// eslint-disable-next-line @stylistic/max-len
warning( 'rewrite-shadowed', [ elem.name.location, elem ], { art: art && effectiveType( art ) }, {
// eslint-disable-next-line @stylistic/max-len
std: 'This element is not originally referred to in the ON-condition of association $(ART)',
// eslint-disable-next-line @stylistic/max-len
element: 'This element is not originally referred to in the ON-condition of association $(MEMBER) of $(ART)',
} );
}
rewritePath( expr, item, assoc, elem, null );
}
}
/**
* Rewrite the given reference by using projected elements of the given
* navigation environment.
*
* @param {XSN.Expression} ref
* @param {object} navEnv
* @param {XSN.Artifact} user
*/
function rewritePathForEnv( ref, navEnv, user ) {
// TODO: combine with rewriteGenericAnnoPath() of xpr-rewrite
// reset artifact link; we'll set it again if there are no errors
setArtifactLink( ref, null );
const rootItem = ref.path[0];
const root = ref.path[0]._navigation || ref.path[0]._artifact;
const startIndex = (root.kind === '$self' ? 1 : 0);
if (root.kind === '$self') {
let rootEnv = navEnv;
while (rootEnv?.kind === '$navElement') {
if (rootEnv._origin?.target?._artifact === root._origin)
break;
rootEnv = rootEnv._parent;
}
navEnv = rootEnv;
}
// Store the original artifact, so that we can use it to
// calculate a redirection chain later on.
ref.path.forEach((item) => {
if (item._artifact)
setLink( item, '_originalArtifact', item._artifact );
});
let env = navEnv;
let art = rootItem._artifact;
let isTargetSide = null;
for (let i = startIndex; i < ref.path.length; ++i) {
if (i > startIndex && art.target) {
// TODO: Can we combine this with the code from xpr-rewrite.js
// If the current artifact is an association, we need to respect the redirection
// chain from original target to new one. We need to use '_originalArtifact' due
// to secondary associations and their redirection chains. See comment in
// test3/Redirections/SecondaryAssocs/RedirectedPathRewriteOne.cds
// FIXME: Won't work with associations in projected structures.
const origTarget = ref.path[i - 1]?._originalArtifact?.target?._artifact;
const chain = cachedRedirectionChain( art, origTarget );
if (!chain) {
missingProjection( ref, i, user, false );
return;
}
for (const alias of chain) {
art = rewritePathItemForEnv( ref, alias, i, user );
isTargetSide ??= (art === user);
if (!art) {
missingProjection( ref, i, user, isTargetSide );
return;
}
}
}
art = rewritePathItemForEnv( ref, env, i, user );
isTargetSide ??= (art === user);
if (!art) {
missingProjection( ref, i, user, isTargetSide );
return;
}
env = navigationEnv( art, null, null, 'nav' );
}
setArtifactLink( ref, art );
if (startIndex === 0 && rootItem.id.startsWith('$')) {
// TODO: What about filters? Also rewritten there?
// After rewriting, if an element starts with `$` -> add root prefix
// FIXME: "user" not correct for association inside sub-element,
// because `user._parent` is assumed to be the query
prependSelfToPath( ref.path, user );
}
}
function rewritePathItemForEnv( ref, navEnv, index, user ) {
const rewriteTarget = findRewriteTarget( ref, index, navEnv, user );
const found = rewriteTarget[0];
if (!found) {
setArtifactLink( ref.path[index], found );
return found;
}
if (rewriteTarget[1] > index) {
// we keep the last segment, in case it has non-enumerable properties
ref.path[index] = ref.path[rewriteTarget[1]];
ref.path.splice(index + 1, rewriteTarget[1] - index);
}
const item = ref.path[index];
if (item.id !== found.name.id || (rewriteTarget[1] - index) !== 0)
item.id = found.name.id;
return setArtifactLink( ref.path[index], found );
}
/**
* @param {object} ref
* @param {number} index
* @param {XSN.Artifact} user
* @param {boolean} isTargetSide
*/
function missingProjection( ref, index, user, isTargetSide ) {
const item = ref.path[index];
if (!isTargetSide) {
const { location } = user.value;
const rootItem = ref.path[0];
const elemref = rootItem._navigation?.kind === '$self' ? ref.path.slice(1) : ref.path;
// TODO: Fix message for sub-elements: `s: { a: Association on x=1, x: Integer};` for x
error( 'rewrite-not-projected', [ location, user ], {
name: user.name.id,
art: item._artifact || item._originalArtifact,
elemref: { ref: elemref },
} );
}
else {
const isExplicit = user.target && !user.target.$inferred;
const loc = isExplicit ? user.target.location : item.location;
error( 'query-undefined-element', [ loc, user ], {
'#': isExplicit ? 'redirected' : 'std',
id: item.id,
name: user.name.id,
target: user.target._artifact,
keyword: 'redirected to',
} );
}
}
/**
* Rewrite the reference `ref` with first elem/mixin ref item `item` for user
* `assoc` (the query element), `elem` is the first (or preferred) query element
* for item.
*/
function rewriteColumnPath( ref, column ) {
if (ref.query)
return traverseExpr.STOP; // sub queries rewrite not supported
if (!ref._artifact)
return null;
const root = ref.path?.[0];
const nav = pathNavigation( ref );
if (nav.navigation) { // TabAlias.elem, elem, mixin
const elem = navProjection( nav.navigation, null );
// TODO?: Use rewritePathForEnv(); make it handle mixins?
if (rewritePath( ref, nav.item, column, elem, null ))
return traverseExpr.STOP;
}
else if (ref.scope === 'param' || root?.kind === '$parameters') {
return traverseExpr.STOP;
}
return null;
}
/**
* Rewrite the reference `ref` with first elem/mixin ref item `item` for user
* `assoc` (the query element), `elem` is the first (or preferred) query element
* for item.
*/
function rewritePath( ref, item, assoc, elem, location ) {
const { path } = ref;
const root = path[0];
if (!elem) {
if (location) {
const elemref = root._navigation?.kind === '$self' ? path.slice(1) : path;
// TODO: Fix message for sub-elements: `s: { a: Association on x=1, x: Integer};` for x
error( 'rewrite-not-projected', [ location, assoc ], {
name: assoc.name.id, art: elemref[0]._artifact, elemref: { ref: elemref },
} );
}
delete root._navigation;
setArtifactLink( root, elem );
setArtifactLink( ref, elem );
return true; // ERROR
}
if (item !== root) { // TableAlias.item, $self.item
// e.g. mixin ON-condition: Base.foo -> $self.foo or multi-path projection,
// $projection -> $self
root.id = '$self';
setLink( root, '_navigation', assoc._parent.$tableAliases.$self );
setArtifactLink( root, assoc._parent );
if (item) {
const i = path.indexOf(item);
ref.path = [ root, ...path.slice( i, path.length ) ];
}
}
else if (elem.name.id.charAt(0) === '$') {
prependSelfToPath( path, assoc );
}
else {
setLink( root, '_navigation', elem );
}
if (!elem.name) // nothing to do for own $projection, $projection.elem
return false; // (except having it renamed to $self)
item.id = elem.name.id;
let state = null;
for (const i of path) {
if (!state) {
if (i === item)
state = setArtifactLink( i, elem );
}
else {
state = rewriteItem( state, i, assoc, !location );
if (state && state !== true)
continue;
if (!state && !location)
return true;
break;
}
}
if (state !== true)
setArtifactLink( ref, state );
return false;
}
function prependSelfToPath( path, elem ) {
const root = { id: '$self', location: path[0].location };
setLink( root, '_navigation', elem._parent.$tableAliases.$self );
setArtifactLink( root, elem._parent );
path.unshift( root );
}
/**
* @param elem "Navigation environment" (element) for `item`.
* @param item Path segment to rewrite.
* @param assoc Published association of query.
*/
function rewriteItem( elem, item, assoc, noError ) {
if (!elem._redirected)
return true;
let name = item.id;
for (const alias of elem._redirected) {
// TODO: a message for the same situation as msg 'rewrite-shadowed'?
if (alias.kind === '$tableAlias') { // _redirected also contains structures for includes
// TODO: if there is a "multi-step" redirection, we should probably
// consider intermediate "preferred" elements - not just `assoc`,
// but its origins, too.
const proj = navProjection( alias.elements[name], assoc );
name = proj?.name?.id;
if (!name)
break;
item.id = name;
// TODO: Why not break here? Test test3/scenarios/AFC/db/view/consumption/C_ScopedRole.cds
}
}
let env = name && elem._effectiveType; // should have been computed
// refs in ON cannot navigate along `items`, no need to consider `items` here
if (env?.target)
env = env.target._artifact?._effectiveType;
const found = setArtifactLink( item, env?.elements?.[name] );
if (found || noError)
return found;
const isExplicit = elem.target && !elem.target.$inferred;
const loc = isExplicit ? elem.target.location : item.location;
error( 'query-undefined-element', [ loc, assoc ], {
'#': isExplicit ? 'redirected' : 'std',
id: name || item.id,
name: elem.name.id,
target: elem.target._artifact,
keyword: 'redirected to',
} );
return null;
}
/**
* Get the redirection chain between the element's target and the original target.
* Returns `null` if there is no valid chain.
* Uses `_redirected` if valid.
*
* @param {XSN.Artifact} elem
* @param {XSN.Artifact} origTarget
* @returns {null|XSN.Artifact[]}
*/
function cachedRedirectionChain( elem, origTarget ) {
const target = elem.target?._artifact;
if (!target || !origTarget)
return null;
if (target === origTarget)
return [];
if (elem._redirected === null) {
// means: "don't touch paths after assoc"
// TODO: figure out if we can assume that here as well
return [];
}
if (elem._redirected) {
// No need to recalculate if the original target is already in '_redirected'.
const i = elem._redirected.findIndex(ta => ta._origin === origTarget);
if (i > -1)
return elem._redirected.slice(i); // TODO: check if it is always "i===0".
}
return redirectionChain( elem, target, origTarget, true );
}
}
function findRewriteTarget( expr, index, env, user ) {
if (env.kind === '$navElement' || env.kind === '$tableAlias') {
const r = firstProjectionForPath( expr.path, index, env, user );
return [ r.elem, r.index ];
}
const item = expr.path[index];
// If the artifact is already in the same definition, we must not check the query.
// Or if it is not a query -> no $navElement -> use `elements`
if (item._artifact?._main === env || !env.query && env.kind !== 'select') {
if (env.elements?.[item.id])
return [ env.elements[item.id], index ];
return [ null, expr.path.length ];
}
const items = (env._leadingQuery || env)._combined?.[item.id];
const allNavs = !items || Array.isArray(items) ? items : [ items ];
// If the annotation target itself has a table alias, require projections of that
// table alias. Of course, that only works if we're talking about the same query.
const tableAlias = (user._main?._origin === item._artifact?._main &&
user.value?.path[0]?._navigation?.kind === '$tableAlias')
? user.value.path[0]._navigation : null;
// Look at all table aliase that could project `item` and only select
// those that have actual projections.
const navs = allNavs?.filter(p => p._origin === item._artifact &&
(!tableAlias || tableAlias === p._parent));
if (!navs || navs.length === 0)
return [ null, expr.path.length ];
// If there are multiple navigations for the element, just use the first that matches.
// In case of table aliases, it's just one.
for (const nav of navs) {
const r = firstProjectionForPath( expr.path, index, nav._parent, user );
if (r.elem)
return [ r.elem, r.index ];
}
return [ null, expr.path.length ];
}
/**
* For a path `a.b.c.d`, return a projection for the first path item that is projected,
* starting at `startIndex` in this path using the given navigation (table alias or
* navigation element).
* For example, if a query has multiple projections such as `a.b, a, a.b.c`, the
* _first_ possible projection will be used and the caller can rewrite `a.b.c.d` to `b.c.d`.
* This avoids `extend`s affect the ON-condition.
*
* The returned object `ret` has `ret.item`, which is the path item at index `ret.index`
* that is projected. `ret.elem` is the element projection.
*
* If nothing was found, `ret.elem` is null, and `ret.item` is the last segment for which
* there was a $navElement.
*
* @param {any[]} path
* @param {number} startIndex
* @param {object} nav
* @param {object} elem Preferred association/element that should be used if projected.
* @return {{elem: object, item: object}|null}
*/
function firstProjectionForPath( path, startIndex, nav, elem ) {
if (startIndex >= path.length) // e.g. just `$self` path item
return { item: undefined, elem: {} };
let tableAlias = nav;
while (tableAlias.kind === '$navElement')
tableAlias = tableAlias._parent;
// We want to use the _first_ valid projection that is written by the user (if the preferred
// `assoc` is not directly projected). To achieve that, look into the query's elements.
const selectedElements = Object.values(tableAlias._parent.elements);
let proj = null;
let navItem = nav;
let navIndex = startIndex;
for (; navIndex < path.length; ++navIndex) {
const item = path[navIndex];
navItem = item?.id && navItem.elements?.[item.id];
if (!navItem) {
break;
}
else if (navItem._projections || navItem._complexProjections) {
const projElem = navProjection( navItem, elem );
if (projElem && projElem === elem) {
// in case the specified association is found, _always_ use it.
return { index: navIndex, item, elem };
}
else if (projElem) {
const queryIndex = selectedElements.indexOf(projElem);
if (!proj || queryIndex < proj.queryIndex) {
proj = {
index: navIndex, item, elem: projElem, queryIndex,
};
}
}
}
}
if (proj)
return proj;
const index = (navIndex - 1) <= startIndex ? startIndex : (navIndex - 1);
return { index, item: path[index], elem: null };
}
/**
* Follow the navigation along the given path to its N-1 path step, so
* that the last step can be resolved against the returned navigation like
* `returnValue.elements[last.id]`.
*
* @param {XSN.Path} path
* @param {object} nav
* @returns {object|null}
*/
function followNavigationPath( path, nav ) {
if (!nav.item || !path || path.length === 1)
return nav.tableAlias;
const startIndex = path.indexOf(nav.item);
if (startIndex === -1)
return null;
// navigation is already at last path step
if (startIndex === path.length - 1) {
return nav.navigation?.kind === '$navElement'
? nav.navigation._parent
: nav.tableAlias;
}
let navItem = nav.navigation || nav.tableAlias;
for (let i = startIndex + 1; i < path.length - 1; ++i) {
const item = path[i];
navItem = item?.id && navItem.elements?.[item.id];
if (!navItem)
return null;
}
return navItem;
}
/**
* Return condensed info about reference in select item
* - tableAlias.elem -> { navigation: navElem, item: path[1], tableAlias }
* - sourceElem (in query) -> { navigation: navElem, item: path[0], tableAlias }
* - mixinElem -> { navigation: mixinElement, item: path[0] }
* - $projection.elem -> also $self.item -> { item: path[1], tableAlias: $self }
* - $self -> { item: undefined, tableAlias: $self }
* - $parameters.P, :P -> {}
* - $now -> {}
* - undef, redef -> {}
* With 'navigation': store that navigation._artifact is projected
* With 'navigation': rewrite its ON condition
* With navigation: Do KEY propagation
*
* TODO: re-think this function, copied in populate.js and tweak-assocs.js
*/
function pathNavigation( ref ) {
// currently, indirectly projectable elements are not included - we might
// keep it this way! If we want them to be included - be aware: cycles
if (!ref._artifact)
return {};
let item = ref.path && ref.path[0];
const root = item && item._navigation;
if (!root)
return {};
if (root.kind === '$navElement')
return { navigation: root, item, tableAlias: root._parent };
if (root.kind === 'mixin')
return { navigation: root, item };
item = ref.path[1];
if (root.kind === '$self')
return { item, tableAlias: root };
if (root.kind !== '$tableAlias' || ref.path.length < 2)
return {}; // should not happen
return { navigation: root.elements[item.id], item, tableAlias: root };
}
/**
* Return the first (or preferred) query elements which projections the navigation
* element `navigation` (i.e. source element belonging to a specific table alias).
*/
function navProjection( navigation, preferred ) {
// TODO: Info if more than one possibility?
if (!navigation)
return {};
if (!navigation._projections && !navigation._complexProjections)
return null;
// _complexProjections contains projections that are not "simple",
// i.e. contain a filter or arguments. Only used if it contains our
// preferred association.
if (preferred && ( navigation._complexProjections?.includes( preferred ) ||
navigation._projections?.includes( preferred )))
return preferred;
return navigation._projections?.[0] || null;
}
module.exports = tweakAssocs;