@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
828 lines (744 loc) • 28.3 kB
JavaScript
'use strict';
const { makeMessageFunction } = require('../base/messages');
const { setProp } = require('../utils/objectUtils');
const { forEachKey } = require('../utils/objectUtils');
const {
applyAnnotationsFromExtensions,
forEachDefinition,
forEachGeneric,
forAllQueries,
} = require('../model/csnUtils');
const { CompilerAssertion } = require('../base/error');
const {
cloneCsnDict,
cloneCsnNonDict,
sortCsnDefinitionsForTests,
sortCsn,
} = require('../base/cloneCsn');
const annoPersistenceSkip = '@cds.persistence.skip';
/**
* Callback function returning `true` if the localization view should be created.
* @callback AcceptLocalizedViewCallback
* @param {string} viewName localization view name
* @param {string} originalName Artifact name of the original view
*/
/**
* Create transitive localized convenience views.
*
* A convenience view is created if (a) the entity/view has a localized element[^1]
* or (b) if it exposes an association leading to a localized-tagged target.
* The second part (b) is only performed if option `fewerLocalizedViews` is
* disabled.
*
* INTERNALS:
* We have three kinds of localized convenience views:
*
* 1. "direct ones" using coalesce() for the table entities with localized
* elements[^1]: as projection on the original and '.texts' entity (created in extend.js)
* 2. for _table_ entities with associations to entities which have a localized
* convenience: as projection on the original
* 3. for _view_ entities with either localized elements[^1] or associations
* to entities which have a localized convenience view:
* as view using a copy of the original query, but replacing all sources by
* their localized convenience view variant if present
*
* [^1]: That is, the element has `localized: true`.
*
* First, all "direct ones" are built (1). Then we build all 2 and 3
* transitively (i.e. as long as an entity has an association which directly or
* indirectly leads to an entity with localized elements, we create a localized
* variant for it), and finally make sure via redirection that associations in
* localized convenience views have as target the localized convenience view
* variant if present.
*
* @param {CSN.Model} csn
* Input CSN model. Should not have existing convenience views.
*
* @param {CSN.Options} options
*
* @param {object} config
* Configuration for creating convenience views. Non-user visible options.
*
* @param {boolean} [config.useJoins]
* If true, rewrite the "localized" association to a join in direct convenience views.
*
* @param {AcceptLocalizedViewCallback} [config.acceptLocalizedView]
* A callback that can be used to suppress the creation of localized convenience views
* if desired. For example, if you want to know which definitions get a convenience view
* but don't actually want to create them.
*
* @param {boolean} [config.ignoreUnknownExtensions]
* If true, do not emit a warning for annotations on unknown `localized.*` views.
*/
function _addLocalizationViews(csn, options, config) {
const messageFunctions = makeMessageFunction(csn, options, 'localized');
if (checkExistingLocalizationViews(csn, options, messageFunctions))
return csn;
const { useJoins, acceptLocalizedView, ignoreUnknownExtensions } = config;
const noCoalesce = (options.localizedLanguageFallback === 'none' ||
options.localizedWithoutCoalesce);
// default is true, hence only check for explicitly disabled option
const ignoreAssocToLocalized = options.fewerLocalizedViews !== false;
/**
* Indicator that a definition is localized and has a convenience view.
* localizedViewsFor[name]'s value should be the name of the convenience view.
* @type {Record<string, string>}
*/
const localizedViewFor = Object.create(null);
/**
* Whether a convenience view was generated for another view.
* In that case we have a _vertical_ view.
* @type {Record<string, boolean>}
*/
const createdForView = Object.create(null); // $inferred = 'LOCALIZED-VERTICAL'
/**
* Whether a convenience view was generated for an entity that is localized.
* In that case we have a _horizontal_ view.
* @type {Record<string, boolean>}
*/
const createdForEntity = Object.create(null); // $inferred = 'LOCALIZED-HORIZONTAL'
/**
* List of artifacts for which the view/entity is a target.
* Used to transitively create convenience views.
* @type {Record<string, string[]>}
*/
const targetFor = Object.create(null);
createDirectConvenienceViews(); // 1
createTransitiveConvenienceViews(); // 2 + 3
applyAnnotationsForLocalizedViews();
sortLocalizedForTests(csn, options);
messageFunctions.throwWithError();
return csn;
/**
* Create direct convenience localization views for entities that have localized elements.
* Only entities that have `localized` elements are used. `localized` in types or sub-elements
* are not respected.
*/
function createDirectConvenienceViews() {
forEachDefinition(csn, (art, artName) => {
if (art.kind !== 'entity' || art.query || art.projection)
// Ignore non-entities and views. The latter are handled at a later point (step 2+3).
return;
if (isInLocalizedNamespace(artName))
// We already issued a warning for it in hasExistingLocalizationViews()
return;
const localized = getLocalizedTextElements( artName );
if (localized)
addLocalizedView( artName, localized );
});
}
/**
* Add a localized convenience view for the given artifact.
* Can either be an entity or view. `localizedElements` are the elements which
* are needed for creating a horizontal convenience view, i.e. only required
* for entities.
*
* @param {string} artName
* @param {string[]} [localizedElements=[]]
*/
function addLocalizedView( artName, localizedElements = [] ) {
const art = csn.definitions[artName];
const artPath = [ 'definitions', artName ];
const viewName = `localized.${ artName }`;
if (csn.definitions[viewName]) {
// Already exists, skip creation.
messageFunctions.info( null, artPath, null, 'Convenience view can\'t be created due to conflicting names' );
return;
}
localizedViewFor[artName] = viewName;
if (acceptLocalizedView && !acceptLocalizedView(viewName, artName))
return;
let view;
if (art.query || art.projection)
view = createLocalizedViewForView(art, viewName);
else
view = createLocalizedViewForEntity(art, artName, viewName, localizedElements);
copyPersistenceAnnotations(view, art);
csn.definitions[viewName] = view;
}
/**
* Create a localized data view for the given entity `art` with `localizedElements`.
* In JOIN mode the FROM query is rewritten to remove associations and the
* columns are expanded.
*
* @param {CSN.Definition} entity
* @param {string} entityName
* @param {string} viewName Name of the localized view.
* @param {string[]} [localizedElements]
* @returns {CSN.View}
*/
function createLocalizedViewForEntity( entity, entityName, viewName, localizedElements = [] ) {
// Only use joins if requested and text elements are provided.
const shouldUseJoin = useJoins && !!localizedElements.length;
const columns = [ ];
const convenienceView = {
'@odata.draft.enabled': false,
kind: 'entity',
query: {
SELECT: {
from: createFromClauseForEntity(),
columns,
},
},
elements: cloneCsnDict(entity.elements, options),
};
copyLocation(convenienceView, entity);
copyLocation(convenienceView.query, entity);
createdForEntity[viewName] = true;
if (shouldUseJoin)
// Expand elements; (variant 1)
columns.push( ...columnsForEntityWithExcludeList( entity, 'L_0', localizedElements ) );
else
columns.push( '*' ); // (variant 2)
for (const originalElement of localizedElements) {
columns.push( createColumnLocalizedElement( originalElement, shouldUseJoin ) );
addCoreComputedIfNecessary(convenienceView.elements, originalElement);
}
return convenienceView;
function createFromClauseForEntity() {
if (!shouldUseJoin)
return createColumnRef( [ entityName ], 'L');
const from = {
join: 'left',
args: [
createColumnRef( [ entityName ], 'L_0'),
createColumnRef( [ textsEntityName(entityName) ], 'localized_1' ),
],
on: [],
};
from.on.push(...createJoinConditionFromLocaleElement());
return from;
}
function createJoinConditionFromLocaleElement() {
const targetAlias = 'localized_1';
const sourceAlias = 'L_0';
return adaptExpr(entity.elements.localized.on);
function adaptExpr(expr) {
// We only support a few specific ON-conditions, not generic expressions.
// In case of unsupported ON-conditions, we emit an error.
const res = expr.map((x) => {
if (!x || typeof x === 'string')
return x;
if (x.xpr)
return { xpr: adaptExpr(x.xpr) };
if (x.ref && !x.ref.some(ref => ref.args || ref.where))
return adaptRef(x);
messageFunctions.error(
'def-invalid-localized',
[ 'definitions', entityName, 'elements', 'localized', 'on' ],
{ name: 'localized', alias: entityName },
'Element $(NAME) of entity $(ALIAS) does not have a supported ON-condition'
);
return x;
});
// the `localized` association does not contain the `tenant` element, so we need to add it here
const addTenantCol = options.tenantDiscriminator && entity.elements.tenant?.key;
if (addTenantCol)
return [ { ref: [ targetAlias, 'tenant' ] }, '=', { ref: [ sourceAlias, 'tenant' ] }, 'AND', { xpr: [ ...res ] } ];
return res;
}
function adaptRef(expr) {
if (expr.ref[0].charAt(0) === '$') // variable
return { ref: [ ...expr.ref ] };
if (expr.ref[0] === 'localized') // target side
return { ref: [ targetAlias, ...expr.ref.slice(1) ] };
return { ref: [ sourceAlias, ...expr.ref ] }; // source side
}
}
}
/**
* Create a localized convenience view for the given definition `art`.
* Does _not_ rewrite references.
*
* @param {CSN.Definition} art View for which a convenience view should be created.
* @param {string} viewName Name of the to-be created convenience view.
* @returns {CSN.View}
*/
function createLocalizedViewForView( art, viewName ) {
const convenienceView = {
kind: 'entity',
'@odata.draft.enabled': false,
};
if (art.query)
convenienceView.query = cloneCsnNonDict(art.query, options);
else if (art.projection)
convenienceView.projection = cloneCsnNonDict(art.projection, options);
convenienceView.elements = cloneCsnDict(art.elements, options);
createdForView[viewName] = true;
copyLocation(convenienceView, art);
Object.keys(convenienceView.elements).forEach((elemName) => {
addCoreComputedIfNecessary(convenienceView.elements, elemName);
});
if (art.params)
convenienceView.params = cloneCsnDict(art.params, options);
return convenienceView;
}
/** @return {CSN.Column} */
function createColumnLocalizedElement(elementName, shouldUseJoins) {
// In JOIN mode the association is removed. We use `_N` suffixes for minimal
// test-ref-diffs.
// TODO: Remove `L_0` special handling.
const mainName = shouldUseJoins ? 'L_0' : 'L';
const localizedNames = shouldUseJoins ? [ 'localized_1' ] : [ 'L', 'localized' ];
if (noCoalesce)
return createColumnRef( [ ...localizedNames, elementName ], elementName );
return {
func: 'coalesce',
args: [
createColumnRef( [ ...localizedNames, elementName ] ),
createColumnRef( [ mainName, elementName ] ),
],
as: elementName,
};
}
/**
* Update the view element in such a way that it is compatible to the old XSN
* based localized functionality.
* Also, because `coalesce` is a function, mark the element `@Core.Computed`
* if necessary.
*
* @param {object} elementsDict
* @param {string} elementName
*/
function addCoreComputedIfNecessary(elementsDict, elementName) {
const element = elementsDict[elementName];
if (!element.localized)
return;
if (noCoalesce) {
// In the XSN based localized functionality, `localized` was set to `false`
// because of the propagator and the `texts` entity. The element is not
// computed because it is directly referenced.
// We imitate this behavior here to get a smaller test-file diff.
element.localized = false;
}
else if (!element.key && !element.$key) {
// Because in coalesce mode a function is used, localized non-key elements
// are not directly referenced which results in a `@Core.Computed` annotation.
element['@Core.Computed'] = true;
}
}
/**
* Returns all text element names for a definition `<artName>` if its texts entity
* exists and `<artName>` has localized fields. Otherwise, `null` is returned.
* Text elements are non-key localized elements.
*
* @param {string} artName Artifact name
* @return {string[] | null}
*/
function getLocalizedTextElements( artName ) {
const art = csn.definitions[artName];
const artPath = [ 'definitions', artName ];
const localizedElements = [];
forEachGeneric(art, 'elements', (elem, elemName, _prop) => {
if (elem.$ignore) // from SAP HANA backend
return;
if (elem.localized && !elem.key && !elem.$key)
localizedElements.push( elemName );
}, artPath);
if (!localizedElements.length) {
// Nothing to do: no localized fields or all localized fields are keys
return null;
}
if (!art.elements.localized) {
messageFunctions.info('def-expected-localized', artPath, { '#': 'missing', name: artName, alias: 'localized' });
return null;
}
if (!art.elements.localized.target) {
messageFunctions.info('def-expected-localized', artPath, { '#': 'non-assoc', name: artName, alias: 'localized' });
return null;
}
const textsName = textsEntityName( artName );
const textsEntity = csn.definitions[textsName];
if (!art[annoPersistenceSkip] && textsEntity[annoPersistenceSkip]) {
messageFunctions.message(
'anno-unexpected-localized-skip', artPath,
{ name: textsName, art: artName, anno: annoPersistenceSkip }
);
return null;
}
// Due to recompilation / flattening, properties may have been propagated from "type-of".
// That means we have localized elements with no corresponding element in the texts-entity.
// Hence, we simply filter here.
return localizedElements.filter(elemName => textsEntity.elements[elemName]);
}
/**
* Transitively create convenience views for entities/views that have
* associations to localized entities, views that themselves have such
* a dependency or views that contain projections on localized elements.
*
* The algorithm is as follows:
*
* 1. For each view with elements that have `localized: true` markers:
* => add view to array `entities`
* For each view/entity with associations (if fewerLocalizedViews is false):
* - If target is NOT localized => add view/entity to target's `_targetFor` property
* - If target is localized => add view/entity to array `entities`
* 2. As long as `entities` has entries:
* a. For each entry in `entities`
* - Create a convenience view
* - If the entry has a `_targetFor` property, add its entries to `nextEntities`
* because they now have a transitive dependency on a localized view.
* b. Copy all entries from `nextEntities` to `entities`.
* c. Clear `nextEntities`.
* 3. Rewrite all references to the localized variants.
*/
function createTransitiveConvenienceViews() {
let entities = [];
forEachDefinition( csn, collectLocalizedEntities );
let nextEntities = [];
while (entities.length) {
entities.forEach( createViewAndCollectSources );
entities = [ ...nextEntities ];
nextEntities = [];
}
forEachDefinition( csn, rewriteToLocalized );
return;
function collectLocalizedEntities( art, artName ) {
if (art.kind !== 'entity')
// Ignore non-entities but also process entities because of associations.
return;
if (isInLocalizedNamespace(artName))
// Ignore existing `localized.` views.
return;
if (localizedViewFor[artName])
// Entity already has a convenience view.
return;
_collectFromElements(art.elements);
function _collectFromElements(elements) {
if (!elements)
return;
// Element may be localized or has an association to localized entity.
for (const elemName in elements) {
const elem = elements[elemName];
if ((art.query || art.projection) && elem.localized && !elem.key && !elem.$key) {
// e.g. projections ; ignore if key is present (warning already issued) or
// if the artifact is an entity (already processed in (1))
entities.push(artName);
}
else if (!ignoreAssocToLocalized && elem.target) {
// If the target has a localized view then we are localized as well.
// Only necessary if "fewerLocalizedView" is disabled.
const def = csn.definitions[elem.target];
if (!def)
continue;
if (localizedViewFor[elem.target]) {
// The target may already be localized and if so, then add the artifact
// to the to-be-processed entities.
entities.push(artName);
}
else {
// Otherwise the target view may become localized at a later point so
// we should add it to a reverse-dependency list.
targetFor[elem.target] ??= [];
targetFor[elem.target].push(artName);
}
}
else {
// recursive check
_collectFromElements(elem.elements);
}
}
}
}
/**
* Create a localization view for `artName` and add views/entities that depend
* on `artName` to `nextEntities`
*
* @param {string} artName
*/
function createViewAndCollectSources( artName ) {
if (localizedViewFor[artName]) {
// view/entity was already processed
return;
}
addLocalizedView(artName);
if (!ignoreAssocToLocalized && targetFor[artName])
nextEntities.push(...targetFor[artName]);
delete targetFor[artName];
}
}
/**
* Rewrites query/association references inside `art` to "localized"-ones if they exist.
*
* @param {CSN.Definition} art
* @param {string} artName
*/
function rewriteToLocalized( art, artName ) {
if (createdForEntity[artName]) {
// For entity convenience views only references in elements need to be rewritten.
// a.k.a 'LOCALIZED-HORIZONTAL'
forEachGeneric(art, 'elements', elem => rewriteDirectRefPropsToLocalized(elem));
}
else if (createdForView[artName]) {
// For view convenience views (i.e. transitive views) we need to rewrite `from`
// references as well as need to handle `mixin` elements.
// a.k.a 'LOCALIZED-VERTICAL'
forAllQueries(art.query || { SELECT: art.projection }, (query) => {
query = query.SELECT || query.SET || query;
if (query.from)
rewriteFrom(query.from);
if (query.mixin)
forEachGeneric(query, 'mixin', elem => rewriteDirectRefPropsToLocalized(elem));
(query.columns || []).forEach((column) => {
if (column && typeof column === 'object' && column.cast)
rewriteDirectRefPropsToLocalized(column.cast);
});
}, [ 'definitions', artName ]);
forEachGeneric(art, 'elements', elem => rewriteDirectRefPropsToLocalized(elem));
}
}
/**
* A query's FROM clause may be a simple ref but could also be more complex
* and contain `args` that themselves are JOINs with `args`.
* So rewrite the references recursively.
*
* @param {CSN.QueryFrom} from
*/
function rewriteFrom(from) {
rewriteRefToLocalized( from );
if (Array.isArray(from.args))
from.args.forEach(arg => rewriteFrom(arg));
}
/**
* Rewrites type references in `obj[ 'ref' | 'target' | 'on' ]]`.
* Does _not_ do so recursively!
*
* @param {object} obj
*/
function rewriteDirectRefPropsToLocalized( obj ) {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return;
for (const prop of [ 'ref', 'target' ]) {
const val = obj[prop];
if (prop === 'ref') {
rewriteRefToLocalized(obj);
}
else if (Array.isArray(val)) {
val.forEach(rewriteDirectRefPropsToLocalized);
}
else if (typeof val === 'string') {
const def = csn.definitions[val];
if (def && localizedViewFor[val])
obj[prop] = localizedViewFor[val];
}
}
}
/**
* Rewrites the type reference `obj.ref`.
*
* @param {object} obj
* @todo Aliases?
*/
function rewriteRefToLocalized( obj ) {
if (!obj || !obj.ref)
return;
const ref = Array.isArray(obj.ref) ? obj.ref[0] : obj.ref;
if (typeof ref === 'string') {
if (localizedViewFor[ref]) {
if (Array.isArray(obj.ref))
obj.ref[0] = localizedViewFor[ref];
else
obj.ref = localizedViewFor[ref];
}
}
else if (ref.id) {
if (localizedViewFor[ref.id])
obj.ref[0].id = localizedViewFor[ref.id];
}
else if (options.testMode) {
throw new CompilerAssertion('Debug me: Unhandled reference during localized-rewrite!');
}
}
/**
* @param {string} artName
*/
function textsEntityName(artName) {
// We can assume that the element exists.
return csn.definitions[artName].elements.localized.target;
}
/**
* In case that the user tried to annotate `localized.*` artifacts, apply them.
*/
function applyAnnotationsForLocalizedViews() {
applyAnnotationsFromExtensions(csn, {
override: true,
filter: name => name.startsWith('localized.'),
notFound(name, index) {
if (!ignoreUnknownExtensions) {
messageFunctions.message('ext-undefined-def', [ 'extensions', index ],
{ art: name });
}
},
}, messageFunctions.error);
forEachDefinition(csn, checkAnnotationsOnLocalized);
}
/**
* @param {CSN.Definition} def
* @param {string} defName
*/
function checkAnnotationsOnLocalized(def, defName) {
const localizedPrefix = 'localized.';
if (defName.startsWith(localizedPrefix)) {
const artName = defName.substring(localizedPrefix.length);
const art = csn.definitions[artName];
if (def[annoPersistenceSkip] && !art?.[annoPersistenceSkip]) {
messageFunctions.message( 'anno-unexpected-localized-skip', [ 'definitions', defName ], {
'#': 'view',
name: defName,
art: artName,
anno: annoPersistenceSkip,
});
}
}
}
}
/**
* Create transitive localized convenience views to the given CSN.
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param [config]
*/
function addLocalizationViews(csn, options, config = {}) {
return _addLocalizationViews(csn, options, { ...config, useJoins: false });
}
/**
* Create transitive localized convenience views to the given CSN but
* rewrite the "localized" association to joins in direct entity convenience
* views. This is needed e.g. by SQL for SQLite where A2J is used.
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param [config]
*/
function addLocalizationViewsWithJoins(csn, options, config = {}) {
return _addLocalizationViews(csn, options, { ...config, useJoins: true });
}
/**
* @param {string[]} ref Reference path
* @param {string} [as] Alias for path.
* @return {CSN.Column}
*/
function createColumnRef(ref, as) {
const column = { ref };
if (as)
column.as = as;
// @ts-ignore
return column;
}
/**
* Create columns for the given entity's elements.
* Only create columns for elements that are not part of the excludeList.
*
* @param {CSN.Definition} entity
* @param {string} entityName
* @param {string[]} excludeList
* @returns {CSN.Column[]}
*/
function columnsForEntityWithExcludeList(entity, entityName, excludeList) {
// @ts-ignore
return Object.keys(entity.elements)
.filter(elementName => !excludeList.includes(elementName))
.map(elementName => ({ ref: [ entityName, elementName ] }));
}
/**
* Copy `source.$location` as a non-enumerable to `target.$location`.
*
* @param {object} target
* @param {object} source
*/
function copyLocation(target, source) {
if (source.$location)
setProp(target, '$location', source.$location);
}
/**
* Copy @cds.persistence.skip annotations from the source to
* the target. Ignores existing annotations on the _target_.
*
* @param {CSN.Artifact} target
* @param {CSN.Artifact} source
*/
function copyPersistenceAnnotations(target, source) {
forEachKey(source, (anno) => {
// Note:
// v3/v4: Because `.exists` is copied to the convenience view, it could
// lead to some localization views referencing non-existing ones.
// But that is the contract: User says that it already exists!
// v2/>=v5, `.exists` is never copied.
if (anno === annoPersistenceSkip)
target[anno] = source[anno];
});
}
/**
* Warns about the first existing `localized.` view.
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param {object} messageFunctions
*/
function checkExistingLocalizationViews(csn, options, messageFunctions) {
if (!csn || !csn.definitions)
return false;
let hasExistingViews = false;
let hasNonViews = false;
forEachDefinition(csn, (def, name) => {
if (isInLocalizedNamespace(name) || name === 'localized') {
if (!def.query && !def.projection) {
if (!name.endsWith('.texts')) {
hasNonViews = true;
messageFunctions.error(
'reserved-namespace-localized', [ 'definitions', name ], { name: 'localized' },
'The namespace $(NAME) is reserved for localization views'
);
}
}
else if (!hasExistingViews) {
hasExistingViews = true;
messageFunctions.info(
null, [ 'definitions', name ], {},
'Input CSN already contains localization views, no further ones will be created'
);
}
}
});
return hasExistingViews || hasNonViews;
}
/**
* @param {string} name
* @returns {boolean}
*/
function isInLocalizedNamespace(name) {
return name === 'localized' || name.startsWith('localized.');
}
/**
* Return true if the given artifact has a localized convenience view in the CSN model.
*
* @param {CSN.Model} csn
* @param {string} artifactName
* @returns {boolean}
*/
function hasLocalizedConvenienceView(csn, artifactName) {
return !isInLocalizedNamespace(artifactName) && !!csn.definitions[`localized.${ artifactName }`];
}
/**
* For tests (testMode), sort the generated localized definitions, i.e. their props,
* but also sort 'csn.definitions' if requested.
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
*/
function sortLocalizedForTests(csn, options) {
if (options.testMode) {
for (const defName in csn.definitions) {
if (defName.startsWith('localized.'))
csn.definitions[defName] = sortCsn(csn.definitions[defName], options);
}
}
sortCsnDefinitionsForTests(csn, options);
}
module.exports = {
addLocalizationViews,
addLocalizationViewsWithJoins,
isInLocalizedNamespace,
hasLocalizedConvenienceView,
};