@sap/cds-compiler
Version:
CDS (Core Data Services) compiler and backends
513 lines (460 loc) • 19.5 kB
JavaScript
;
const {
getUtils, applyTransformationsOnNonDictionary, forEachDefinition,
} = require('../../model/csnUtils');
const { implicitAs, columnAlias, pathId } = require('../../model/csnRefs');
const { ModelError } = require('../../base/error');
const { setProp } = require('../../base/model');
const { cloneCsnNonDict } = require('../../model/cloneCsn');
/**
* If a mixin association is published, return the mixin association.
*
* @param {CSN.Query} query Query of the artifact to check
* @param {object} association Association (Element) published by the view
* @param {string} associationName
* @returns {object} The mixin association
*/
function getMixinAssocOfQueryIfPublished( query, association, associationName ) {
if (query?.SELECT?.mixin) {
const aliasedColumnsMap = Object.create(null);
for (const column of query.SELECT.columns || []) {
if (column.as && column.ref?.length === 1)
aliasedColumnsMap[column.as] = column;
}
for (const elem of Object.keys(query.SELECT.mixin)) {
const mixinElement = query.SELECT.mixin[elem];
let originalName = associationName;
if (aliasedColumnsMap[associationName])
originalName = pathId(aliasedColumnsMap[associationName].ref[0]);
if (elem === originalName)
return { mixinElement, mixinName: originalName };
}
}
return {};
}
/**
* Check whether the given artifact uses the given mixin association.
*
* We can rely on the fact that there can be no usage starting with $self/$projection,
* since lib/checks/selectItems.js forbids that.
*
* @param {CSN.Query} query Query of the artifact to check
* @param {object} association Mixin association (Element) to check for
* @param {string} associationName
* @returns {boolean} True if used
*/
function usesMixinAssociation( query, association, associationName ) {
if (query && query.SELECT && query.SELECT.columns) {
for (const column of query.SELECT.columns) {
if (typeof column === 'object' && column.ref && column.ref.length > 1 && (column.ref[0] === associationName || column.ref[0].id === associationName))
return true;
}
}
return false;
}
/**
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param {{error: Function, info: Function}} messageFunctions
* @returns {(query: CSN.Query, artifact: CSN.Artifact, artName: string, path: CSN.Path) => void} Transformer function for views
*/
function getViewTransformer( csn, options, messageFunctions ) {
const csnUtils = getUtils(csn);
const {
get$combined, isAssocOrComposition,
inspectRef, queryOrMain, // csnRefs
} = csnUtils;
const { error, info } = messageFunctions;
const doA2J = !(options.transformation === 'hdbcds' && options.sqlMapping === 'hdbcds');
return transformViewOrEntity;
/**
*
* check all queries/subqueries for mixin publishing inside of unions -> forbidden in hdbcds
*
* @param {CSN.Query} query
* @param {CSN.Elements} elements
* @param {CSN.Path} path
*/
function checkForMixinPublishing( query, elements, path ) {
for (const elementName in elements) {
const element = elements[elementName];
if (element.target) {
let colLocation;
for (let i = 0; i < query.SELECT.columns.length; i++) {
const col = query.SELECT.columns[i];
if (col.ref && col.ref.length === 1) {
if (!colLocation && col.ref[0] === elementName)
colLocation = i;
if (col.as === elementName)
colLocation = i;
}
}
if (colLocation) {
const matchingCol = query.SELECT.columns[colLocation];
const possibleMixinName = matchingCol.ref[0];
const isMixin = query.SELECT.mixin[possibleMixinName] !== undefined;
if (element.target && isMixin) {
error(null, path.concat([ 'columns', colLocation ]), { id: elementName, name: possibleMixinName, '#': possibleMixinName === elementName ? 'std' : 'renamed' }, {
std: 'Element $(ID) is a mixin association and can\'t be published in a UNION',
renamed: 'Element $(ID) is a mixin association ($(NAME))and can\'t be published in a UNION',
});
}
}
}
}
}
/**
* For things that are not explicitly found in the columns but still present in the elements, add them to the columnMap.
*
* This can happen for:
* - projections, as we might not have .columns at all
* - *, as we don't resolve it for hdbcds with hdbcds-naming
*
* We ensure that we attach a table alias before each column
*
* @param {CSN.Query} query
* @param {boolean} isProjection
* @param {boolean} isSelectStar
* @param {object} $combined
* @param {object} columnMap
* @param {string} elemName
*/
function addProjectionOrStarElement( query, isProjection, isSelectStar, $combined, columnMap, elemName ) {
// Prepend an alias if present
let alias = (isProjection || isSelectStar) &&
(query.SELECT.from.as || (query.SELECT.from.ref && implicitAs(query.SELECT.from.ref)));
// In case of * and no explicit alias
// find the source of the col by looking at $combined and prepend it
if (isSelectStar && !alias && !isProjection) {
if (!$combined)
$combined = get$combined(query);
const matchingCombined = $combined[elemName];
// Internal errors - this should never happen!
if (matchingCombined.length > 1) { // should already be caught by compiler
throw new ModelError(`Ambiguous name - can't be resolved: ${ elemName }. Found in: ${ matchingCombined.map(o => o.parent) }`);
}
else if (matchingCombined.length === 0) { // no clue how this could happen? Invalid CSN?
throw new ModelError(`No matching entry found in UNION of all elements for: ${ elemName }`);
}
alias = matchingCombined[0].parent;
}
if (alias)
columnMap[elemName] = { ref: [ alias, elemName ] };
else
columnMap[elemName] = { ref: [ elemName ] };
}
/**
* Check for invalid association publishing (in Union or in Subquery) (for hdbcds) and
* create the __clone for publishing stuff.
*
* @todo Factor out the checks
* @param {CSN.Query} query
* @param {object} elements
* @param {object} columnMap
* @param {WeakMap} publishedMixins Map to collect the published mixins
* @param {CSN.Element} elem
* @param {string} elemName
* @param {CSN.Path} elementsPath Path pointing to elements
* @param {CSN.Path} queryPath Path pointing to the query
*/
function handleAssociationElement( query, elements, columnMap, publishedMixins, elem, elemName, elementsPath, queryPath ) {
if (isUnion(queryPath) && options.transformation === 'hdbcds') {
if (doA2J) {
info('query-ignoring-assoc-in-union', queryPath, { name: elemName, '#': elem.keys ? 'managed' : 'std' });
elem.$ignore = true;
}
else {
error(null, queryPath, { name: elemName }, 'Association $(NAME) can\'t be published in a SAP HANA CDS UNION');
}
}
else if (queryPath.length > 4 && options.transformation === 'hdbcds') { // path.length > 4 -> is a subquery
error(null, queryPath, { name: elemName },
'Association $(NAME) can\'t be published in a subquery');
}
else {
const isNotMixinByItself = checkIsNotMixinByItself(query, columnMap, elemName);
const { mixinElement, mixinName } = getMixinAssocOfQueryIfPublished(query, elem, elemName);
if (isNotMixinByItself || mixinElement !== undefined) {
// If the mixin is only published and not used, only display the __ clone. Kill the "original".
if (mixinElement !== undefined && !usesMixinAssociation(query, elem, elemName))
delete query.SELECT.mixin[mixinName];
// Create an unused alias name for the MIXIN - use 3 _ to avoid collision with usings
let mixinElemName = `___${ mixinName || elemName }`;
while (elements[mixinElemName])
mixinElemName = `_${ mixinElemName }`;
// Copy the association element to the MIXIN clause under its alias name
// Needs to be a deep copy, as we transform the on-condition
const mixinElem = cloneCsnNonDict(elem, options);
if (query.SELECT && !query.SELECT.mixin)
query.SELECT.mixin = Object.create(null);
// Clone 'on'-condition, pre-pending '$projection' to paths where appropriate,
// and fixing the association alias just created
if (mixinElem.on) {
mixinElem.on = applyTransformationsOnNonDictionary(mixinElem, 'on', {
ref: (parent, prop, ref, refpath) => {
if (ref[0] === elemName) {
ref[0] = mixinElemName;
}
else if (!(ref[0] && ref[0].startsWith('$'))) {
ref.unshift('$projection');
}
else if (ref[0] && ref[0].startsWith('$')) {
// TODO: I think this is non-sense. Stuff with $ is either magic or must start with $self, right?
const { scope } = inspectRef(refpath);
if (scope !== '$magic' && scope !== '$self')
ref.unshift('$projection');
}
parent.ref = ref;
return ref;
},
}, {}, elementsPath.concat(elemName));
}
if (!mixinElem.$ignore)
columnMap[elemName] = { ref: [ mixinElemName ], as: elemName };
if (query.SELECT) {
query.SELECT.mixin[mixinElemName] = mixinElem;
publishedMixins.set(mixinElem, true);
}
}
}
}
/**
* If following an association, explicitly set the implicit alias
* due to an issue with HANA - only for hdbcds-hdbcds, I assume flattening
* takes care of this for the other cases already
*
* @param {CSN.Column} col
* @param {CSN.Path} path
*/
function addImplicitAliasWithAssoc( col, path ) {
if (!col.as && col.ref && col.ref.length > 1) {
const { links } = inspectRef(path);
if (links && links.slice(0, -1).some(({ art }) => art && isAssocOrComposition(art)))
col.as = getLastRefStepString(col.ref);
}
}
/**
* If simply selecting from a param like `:param`, we need to add an implicit alias like `:param as param`
* due to an issue with HANA
*
* @param {CSN.Column} col
*/
function addImplicitAliasWithLonelyParam( col ) {
if (!col.as && col.param)
col.as = getLastRefStepString(col.ref);
}
/**
* Loop over the columns and call all the given functions with the column and the path
*
* @param {Function[]} functions
* @param {CSN.Column[]} columns
* @param {CSN.Path} path
*/
function processColumns( functions, columns, path ) {
for (let i = 0; i < columns.length; i++) {
const col = columns[i];
functions.forEach(fn => fn(col, path.concat(i)));
}
}
/**
* @param {CSN.Query} query
* @param {CSN.Artifact} artifact
* @param {string} artName
* @param {CSN.Path} path
*/
function transformViewOrEntity( query, artifact, artName, path ) {
const ignoreAssociations = options.sqlDialect === 'hana' && options.withHanaAssociations === false;
csnUtils.initDefinition(artifact);
const { elements } = queryOrMain(query, artifact); // TODO: use queryForElements
// We use the elements from the leading query/main artifact - adapt the path
const elementsPath = elements === artifact.elements ? path.slice(0, 2).concat('elements') : path.concat('elements');
const queryPath = path;
let hasNonAssocElements = false;
const isSelect = query && query.SELECT;
const isProjection = !!artifact.projection || query && query.SELECT && !query.SELECT.columns;
const columnMap = getColumnMap(query, csnUtils);
const isSelectStar = query && query.SELECT && query.SELECT.columns && query.SELECT.columns.indexOf('*') !== -1;
// check all queries/subqueries for mixin publishing inside of unions -> forbidden in hdbcds
if (query && options.transformation === 'hdbcds' && query.SELECT && query.SELECT.mixin && path.indexOf('SET') !== -1)
checkForMixinPublishing(query, elements, path);
// Second walk through the entity elements: Deal with associations (might also result in new elements)
// Will be initialized JIT inside the elements-loop
let $combined;
const publishedMixins = new WeakMap();
for (const elemName in elements) {
const elem = elements[elemName];
if (isSelect) {
if (!columnMap[elemName])
addProjectionOrStarElement(query, isProjection, isSelectStar, $combined, columnMap, elemName);
}
// Views must have at least one element that is not an unmanaged assoc
if (!elem.on && !elem.$ignore)
hasNonAssocElements = true;
// (180 b) Create MIXINs for association elements in projections or views (those that are not mixins by themselves)
// CDXCORE-585: Allow mixin associations to be used and published in parallel
if (query !== undefined && elem.target)
handleAssociationElement(query, elements, columnMap, publishedMixins, elem, elemName, elementsPath, queryPath);
}
if (query && !hasNonAssocElements) {
// Complain if there are no elements other than unmanaged associations or associations without keys.
error('def-missing-element', [ 'definitions', artName ], { '#': 'view' });
}
if (isSelect) {
// Build new columns from the column map - bring elements and columns back in sync basically
query.SELECT.columns = Object.keys(elements).filter(elem => !elements[elem].$ignore && !(elements[elem].target && ignoreAssociations)).map(key => stripLeadingSelf(columnMap[key]));
// If following an association, explicitly set the implicit alias
// due to an issue with HANA - this seems to only have an effect on ref files with hdbcds-hdbcds, so only run then
const columnProcessors = [];
if (options.transformation === 'hdbcds' || options.transformation === 'sql' && options.sqlDialect === 'hana')
columnProcessors.push(addImplicitAliasWithLonelyParam);
if (options.transformation === 'hdbcds' && options.sqlMapping === 'hdbcds')
columnProcessors.push(addImplicitAliasWithAssoc);
if (columnProcessors.length > 0)
processColumns(columnProcessors, query.SELECT.columns, path.concat('columns'));
delete query.SELECT.excluding; // just to make the output of the new transformer the same as the old
// A2J turned usages into JOINs, we must now remove all non-published mixins (i.e. only keep the clones)
if (query.SELECT.mixin && doA2J) {
for (const [ name, mixin ] of Object.entries(query.SELECT.mixin)) {
if (!publishedMixins.has(mixin))
delete query.SELECT.mixin[name];
}
}
}
}
}
/**
* Walk the given path and check if we are in a UNION.
* This will return true when it is called on the subquery inside of a SET.args property.
*
* @param {CSN.Path} path
* @returns {boolean}
*/
function isUnion( path ) {
const subquery = path[path.length - 1];
const queryIndex = path[path.length - 2];
const args = path[path.length - 3];
const unionOperator = path[path.length - 4];
return path.length > 3 && (subquery === 'SET' || subquery === 'SELECT') && typeof queryIndex === 'number' && queryIndex >= 0 && args === 'args' && unionOperator === 'SET';
}
/**
* Strip of leading $self of the ref
*
* @param {object} col A column
* @returns {object}
*/
function stripLeadingSelf( col ) {
if (col.ref && col.ref.length > 1 && col.ref[0] === '$self')
col.ref = col.ref.slice(1);
return col;
}
/**
* Check that the given element is not a simple mixin-publishing
*
* @param {CSN.Query} query
* @param {object} columnMap
* @param {string} elementName
* @returns {boolean}
*/
function checkIsNotMixinByItself( query, columnMap, elementName ) {
if (query?.SELECT?.mixin) {
const col = columnMap[elementName];
if (!col.ref) // No ref -> new association, but not a mixin.
return true;
// Use getLastRefStepString - with hdbcds.hdbcds and malicious CSN input we might have .id
const realName = getLastRefStepString(col.ref);
// If the element is not part of the mixin => True
return query.SELECT.mixin[realName] === undefined;
}
// the artifact does not define any mixins, the element cannot be a mixin
return true;
}
/**
* Return the string value of the last ref step - so either the .id or the last step.
*
* We cannot use implicitAs, as this causes problems for structured things with hdi-hdbcds naming
*
* @param {Array} ref
* @returns {string}
*/
function getLastRefStepString( ref ) {
const last = ref[ref.length - 1];
if (last.id)
return last.id;
return last;
}
/**
* This function is similar to csnRefs()' `columnName()`, but does not split the
* last `col.ref` segment on `.`.
*
* TODO: The HDBCDS backend relies on this. Also the HDI backend relies
* on this for virtual elements somehow. That can probably be fixed
* by using csnRefs()'s `getElement()`.
* TODO: Remove this function; update HDBCDS/HDI
*
* @param {CSN.Column} col
* @returns {string}
*/
function columnNameForMap( col ) {
return col.as || (!col.args && col.func) || (col.ref && getLastRefStepString( col.ref ));
}
/**
* Build a map of the resulting names (i.e. the element name of the column) and references
* to the respective columns.
* This can later be used to match from elements to columns.
*
* @param {CSN.Query} query
* @param {object} csnUtils
* @returns {object}
*/
function getColumnMap( query, csnUtils ) {
const map = Object.create(null);
if (query?.SELECT?.columns) {
query.SELECT.columns.forEach((col) => {
if (col !== '*') {
// Fallback to csnUtils for columns without any alias (internal one is created)
const as = columnNameForMap(col) || csnUtils.getColumnName( col );
if (as && !map[as])
map[as] = col;
}
});
}
return map;
}
/**
* Ensure that each column in the CSN has a name. A column does not have
* a name if the column is an expression and there is no explicit alias.
* In that case an internal alias (from csnRefs()) is used and made explicit
* via non-enumerable `as`.
*
* For HDBCDS, the alias is made explicit as an enumerable property, because
* HDBCDS does not support expressions as columns without aliases.
*
* Notes:
* - The alias is removed after A2J: we rely on the compiler ignoring non-enumerable CSN properties.
* - We can't use e.g. `$as`, as csnRefs() does not use that property, and it must not
* invent another name for the column (could happen after flattening).
*
* @param {CSN.Model} csn
* @param {CSN.Options} options
* @param {object} csnUtils
*/
function ensureColumnNames( csn, options, csnUtils ) {
forEachDefinition(csn, (def) => {
csnUtils.initDefinition(def);
for (const query of csnUtils.$getQueries(def) || []) {
for (const col of query._select.columns || []) {
if (col !== '*' && !columnAlias(col)) {
if (options.transformation === 'hdbcds')
col.as = csnUtils.getColumnName(col);
else
setProp(col, 'as', csnUtils.getColumnName(col));
}
}
}
});
}
module.exports = {
getViewTransformer,
getColumnMap,
ensureColumnNames,
};