@cap-js/db-service
Version:
CDS base database service
1,196 lines (1,100 loc) • 103 kB
JavaScript
'use strict'
const cds = require('@sap/cds')
cds.infer.target ??= q => q._target || q.target // instanceof cds.entity ? q._target : q.target
const infer = require('./infer')
const { computeColumnsToBeSearched } = require('./search')
const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement, getImplicitAlias, getModelUtils } = require('./utils')
/**
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
* If there are at least two leaf elements and if there are tokens before or after the recognized pattern, we enclose the resulting condition in parens (...)
*/
const eqOps = [['is'], ['='] /* ['=='] */]
/**
* For operators of <notEqOps>, do the same but use or instead of and.
* This ensures that not struct == <value> is the same as struct != <value>.
*/
const notEqOps = [['is', 'not'], ['<>'], ['!=']]
/**
* not supported in comparison w/ struct because of unclear semantics
*/
const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
const { pseudos } = require('./infer/pseudos')
/**
* Transforms a CDL style query into SQL-Like CQN:
* - transform association paths in `from` to `WHERE exists` subqueries
* - transforms columns into their flat representation.
* 1. Flatten managed associations to their foreign
* 2. Flatten structures to their leafs
* 3. Replace join-relevant ref paths (i.e. non-fk accesses in association paths) with the correct join alias
* - transforms `expand` columns into special, normalized subqueries
* - transform `where` clause.
* That is the flattening of all `ref`s and the expansion of `where exists` predicates
* - rewrites `from` clause:
* Each join relevant association path traversal is translated to a join condition.
*
* `cqn4sql` is applied recursively to all queries found in `from`, `columns` and `where`
* of a query.
*
* @param {object} originalQuery
* @param {object} model
* @returns {object} transformedQuery the transformed query
*/
function cqn4sql(originalQuery, model) {
let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
const hasCustomJoins =
originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)
if (!hasCustomJoins && inferred.SELECT?.search) {
// we need an instance of query because the elements of the query are needed for the calculation of the search columns
if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
if (searchTerm) {
// Search target can be a navigation, in that case use _target to get the correct entity
const { where, having } = transformSearch(searchTerm)
if (where) inferred.SELECT.where = where
else if (having) inferred.SELECT.having = having
}
}
// query modifiers can also be defined in from ref leaf infix filter
// > SELECT from bookshop.Books[order by price] {ID}
if (inferred.SELECT?.from.ref) {
for (const [key, val] of Object.entries(inferred.SELECT.from.ref.at(-1))) {
if (key in { orderBy: 1, groupBy: 1 }) {
if (inferred.SELECT[key]) inferred.SELECT[key].push(...val)
else inferred.SELECT[key] = val
} else if (key === 'limit') {
// limit defined on the query has precedence
if (!inferred.SELECT.limit) inferred.SELECT.limit = val
} else if (key === 'having') {
if (!inferred.SELECT.having) inferred.SELECT.having = val
else inferred.SELECT.having.push('and', ...val)
}
}
}
inferred = infer(inferred, model)
const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
// if the query has custom joins we don't want to transform it
// TODO: move all the way to the top of this function once cds.infer supports joins as well
// we need to infer the query even if no transformation will happen because cds.infer can't calculate the target
if (hasCustomJoins) return originalQuery
let transformedQuery = cds.ql.clone(inferred)
const kind = inferred.kind || Object.keys(inferred)[0]
if (inferred.INSERT || inferred.UPSERT) {
transformedQuery = transformQueryForInsertUpsert(kind)
} else {
const queryProp = inferred[kind]
const { entity, where } = queryProp
const from = queryProp.from
const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
// Transform the existing where, prepend table aliases, and so on...
if (where) {
transformedProp.where = getTransformedTokenStream(where)
}
// Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
// The already transformed `where` clause is then glued together with the resulting subqueries.
const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
if (inferred.SELECT) {
transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
} else {
if (from) {
transformedProp.from = transformedFrom
} else if (!queryNeedsJoins) {
transformedProp.entity = transformedFrom
}
if (transformedWhere?.length > 0) {
transformedProp.where = transformedWhere
}
transformedQuery[kind] = transformedProp
if (inferred.UPDATE?.with) {
Object.entries(inferred.UPDATE.with).forEach(([key, val]) => {
const transformed = getTransformedTokenStream([val])
inferred.UPDATE.with[key] = transformed[0]
})
}
}
if (queryNeedsJoins) {
if (inferred.UPDATE || inferred.DELETE) {
const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
const subquery = {
SELECT: {
from: { ...transformedFrom },
columns: [], // primary keys of the query target will be added later
where: [...transformedProp.where],
},
}
// The alias of the original query is now the alias for the subquery
// so that potential references in the where clause to the alias match.
// Hence, replace the alias of the original query with the next
// available alias, so that each alias is unique.
const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
transformedFrom.as = uniqueSubqueryAlias
// calculate the primary keys of the target entity, there is always exactly
// one query source for UPDATE / DELETE
const queryTarget = Object.values(inferred.sources)[0].definition
const primaryKey = { list: [] }
for (const k of Object.keys(queryTarget.elements)) {
const e = queryTarget.elements[k]
if (e.key === true && !e.virtual && e.isAssociation !== true) {
subquery.SELECT.columns.push({ ref: [e.name] })
primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
}
}
const transformedSubquery = cqn4sql(subquery, model)
// replace where condition of original query with the transformed subquery
// correlate UPDATE / DELETE query with subquery by primary key matches
transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
else transformedQuery.DELETE.from = transformedFrom
} else {
transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
}
}
}
return transformedQuery
function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
const { columns, having, groupBy, orderBy, limit } = queryProp
// Trivial replacement -> no transformations needed
if (limit) {
transformedQuery.SELECT.limit = limit
}
transformedQuery.SELECT.from = transformedFrom
if (transformedWhere?.length > 0) {
transformedQuery.SELECT.where = transformedWhere
}
if (columns) {
transformedQuery.SELECT.columns = getTransformedColumns(columns)
} else {
transformedQuery.SELECT.columns = getColumnsForWildcard(inferred.SELECT?.excluding)
}
// Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
if (having) {
transformedQuery.SELECT.having = getTransformedTokenStream(having)
}
if (groupBy) {
const transformedGroupBy = getTransformedOrderByGroupBy(groupBy)
if (transformedGroupBy.length) {
transformedQuery.SELECT.groupBy = transformedGroupBy
}
}
// Since all the expressions in the SELECT part of the query have been computed,
// one can reference aliases of the queries columns in the orderBy clause.
if (orderBy) {
const transformedOrderBy = getTransformedOrderByGroupBy(orderBy, true)
if (transformedOrderBy.length) {
transformedQuery.SELECT.orderBy = transformedOrderBy
}
}
return transformedQuery
}
/**
* Transforms a query object for INSERT or UPSERT operations by modifying the `into` clause.
*
* @param {string} kind - The type of operation: "INSERT" or "UPSERT".
*
* @returns {object} - The transformed query with updated `into` clause.
*/
function transformQueryForInsertUpsert(kind) {
const { as } = transformedQuery[kind].into
const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
transformedQuery[kind].into = { ref: [target.name] }
if (as) transformedQuery[kind].into.as = as
return transformedQuery
}
/**
* Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
* The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
*
* @param {object} searchTerm - The search expression to be applied to the searchable columns within the query source.
* @param {object} from - The FROM clause of the CQN statement.
*
* @returns {Object} - The function returns an object representing the WHERE or HAVING clause of the query.
*
* Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
* The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
*/
function transformSearch(searchTerm) {
let prop = 'where'
// if the query is grouped and the queries columns contain an aggregate function,
// we must put the search term into the `having` clause, as the search expression
// is defined on the aggregated result, not on the individual rows
const usesAggregation =
inferred.SELECT.groupBy &&
(searchTerm.args[0].func || searchTerm.args[0].xpr || searchTerm.args[0].list?.some(c => c.func || c.xpr))
if (usesAggregation) prop = 'having'
if (inferred.SELECT[prop]) {
return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
} else {
return { [prop]: [searchTerm] }
}
}
/**
* Rewrites the from clause based on the `query.joinTree`.
*
* For each join relevant node in the join tree, the respective join is generated.
* Each join relevant node in the join tree has an unique table alias which is the query source for the respective
* path traversals. Hence, all join relevant `ref`s must be rewritten to point to the generated join aliases. However,
* this is done in the @function getFlatColumnsFor().
*
* @returns {CQN.from}
*/
function translateAssocsToJoins() {
let from
/**
* remember already seen aliases, do not create a join for them again
*/
const alreadySeen = new Map()
inferred.joinTree._roots.forEach(r => {
const args = []
if (r.queryArtifact.SELECT) args.push({ SELECT: transformSubquery(r.queryArtifact).SELECT, as: r.alias })
else {
const id = getLocalizedName(r.queryArtifact)
args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
}
from = { join: r.join || 'left', args, on: [] }
r.children.forEach(c => {
from = joinForBranch(from, c)
from = { join: c.join || 'left', args: [from], on: [] }
})
})
return from.args.length > 1 ? from : from.args[0]
function joinForBranch(lhs, node) {
const nextAssoc = inferred.joinTree.findNextAssoc(node)
if (!nextAssoc || alreadySeen.has(nextAssoc.$refLink.alias)) return lhs.args.length > 1 ? lhs : lhs.args[0]
lhs.on.push(
...onCondFor(
nextAssoc.$refLink,
node.parent.$refLink || /** tree roots do not have $refLink */ {
alias: node.parent.alias,
definition: node.parent.queryArtifact,
target: node.parent.queryArtifact,
},
/** flip source and target in on condition */ true,
),
)
const id = getDefinition(nextAssoc.$refLink.definition.target).name
const { args } = nextAssoc
const arg = {
ref: [args ? { id, args } : id],
as: nextAssoc.$refLink.alias,
}
lhs.args.push(arg)
alreadySeen.set(nextAssoc.$refLink.alias, true)
if (nextAssoc.where) {
const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
lhs.on = [
...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
'and',
...(hasLogicalOr(filter) ? [asXpr(filter)] : filter),
]
}
if (node.children) {
node.children.forEach(c => {
lhs = { join: c.join || 'left', args: [lhs], on: [] }
lhs = joinForBranch(lhs, c)
})
}
return lhs.args.length > 1 ? lhs : lhs.args[0]
}
}
/**
* Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
*
* @param {object[]} columns
* @returns {object[]} the transformed representation of the input. Expanded and flattened.
*/
function getTransformedColumns(columns) {
const transformedColumns = []
for (let i = 0; i < columns.length; i++) {
const col = columns[i]
if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
const name = getName(col)
if (!transformedColumns.some(inserted => getName(inserted) === name)) {
const calcElement = resolveCalculatedElement(col)
transformedColumns.push(calcElement)
}
} else if (col.expand) {
if (col.ref?.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
const dollarSelfReplacement = calculateDollarSelfColumn(col)
transformedColumns.push(...getTransformedColumns([dollarSelfReplacement]))
continue
}
transformedColumns.push(() => {
const expandResult = handleExpand(col)
if (expandResult.length > 1) {
return expandResult
} else {
return expandResult[0]
}
})
} else if (col.inline) {
handleInline(col)
} else if (col.ref) {
if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
const dollarSelfReplacement = calculateDollarSelfColumn(col)
transformedColumns.push(...getTransformedColumns([dollarSelfReplacement]))
continue
}
handleRef(col)
} else if (col === '*') {
handleWildcard(columns)
} else if (col.SELECT) {
handleSubquery(col)
} else {
handleDefault(col)
}
}
// subqueries are processed in the end
for (let i = 0; i < transformedColumns.length; i++) {
const c = transformedColumns[i]
if (typeof c === 'function') {
const res = c() || [] // target of expand / subquery could also be skipped -> no result
if (res.length !== undefined) {
transformedColumns.splice(i, 1, ...res)
i += res.length - 1
} else {
const replaceWith = res.as
? transformedColumns.findIndex(t => (t.as || t.ref?.[t.ref.length - 1]) === res.as)
: -1
if (replaceWith === -1) transformedColumns.splice(i, 1, res)
else {
transformedColumns.splice(replaceWith, 1, res)
transformedColumns.splice(i, 1)
// When removing an element, the next element moves to the current index
i--
}
}
}
}
if (transformedColumns.length === 0 && columns.length) {
handleEmptyColumns(columns)
}
return transformedColumns
function handleSubquery(col) {
transformedColumns.push(() => {
const res = transformSubquery(col)
if (col.as) res.as = col.as
return res
})
}
function handleExpand(col) {
const { $refLinks } = col
const res = []
const last = $refLinks?.[$refLinks.length - 1]
if (last && !last.skipExpand && last.definition.isAssociation) {
const expandedSubqueryColumn = expandColumn(col)
if (!expandedSubqueryColumn) return []
setElementOnColumns(expandedSubqueryColumn, col.element)
res.push(expandedSubqueryColumn)
} else if (!last?.skipExpand) {
const expandCols = nestedProjectionOnStructure(col, 'expand')
res.push(...expandCols)
}
return res
}
function handleInline(col) {
const inlineCols = nestedProjectionOnStructure(col)
transformedColumns.push(...inlineCols)
}
function handleRef(col) {
if (pseudos.elements[col.ref[0]] || col.param) {
transformedColumns.push({ ...col })
return
}
const tableAlias = getTableAlias(col)
// re-adjust usage of implicit alias in subquery
if (col.$refLinks[0].definition.kind === 'entity' && col.ref[0] !== tableAlias) {
col.ref[0] = tableAlias
}
const leaf = col.$refLinks[col.$refLinks.length - 1].definition
if (leaf.virtual === true) return
let baseName
if (col.ref.length >= 2) {
baseName = col.ref
.map(idOnly)
.slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1)
.join('_')
}
let columnAlias = col.as || (col.isJoinRelevant ? col.flatName : null)
const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)) return
const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
flatColumns.forEach(flatColumn => {
const name = getName(flatColumn)
if (!transformedColumns.some(inserted => getName(inserted) === name)) transformedColumns.push(flatColumn)
})
}
function handleWildcard(columns) {
const wildcardIndex = columns.indexOf('*')
const ignoreInWildcardExpansion = columns.slice(0, wildcardIndex)
const { excluding } = inferred.SELECT
if (excluding) ignoreInWildcardExpansion.push(...excluding)
const wildcardColumns = getColumnsForWildcard(ignoreInWildcardExpansion, columns.slice(wildcardIndex + 1))
transformedColumns.push(...wildcardColumns)
}
function handleDefault(col) {
let transformedColumn = getTransformedColumn(col)
if (col.as) transformedColumn.as = col.as
const replaceWith = transformedColumns.findIndex(
t => (t.as || t.ref?.[t.ref.length - 1]) === transformedColumn.as,
)
if (replaceWith === -1) transformedColumns.push(transformedColumn)
else transformedColumns.splice(replaceWith, 1, transformedColumn)
setElementOnColumns(transformedColumn, inferred.elements[col.as])
}
function getTransformedColumn(col) {
let ret
if (col.func) {
ret = {
func: col.func,
args: getTransformedFunctionArgs(col.args),
as: col.func, // may be overwritten by the explicit alias
}
}
if (col.xpr) {
ret ??= {}
ret.xpr = getTransformedTokenStream(col.xpr)
}
if (ret) {
if (col.cast) ret.cast = col.cast
return ret
}
return copy(col)
}
function handleEmptyColumns(columns) {
if (columns.some(c => c.$refLinks?.[c.$refLinks.length - 1].definition.type === 'cds.Composition')) return
throw new Error('Queries must have at least one non-virtual column')
}
}
function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
let value
if (column.$refLinks) {
const { $refLinks } = column
value = $refLinks[$refLinks.length - 1].definition.value
if (column.$refLinks.length > 1) {
baseLink =
[...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
// if there is no association in the path, the table alias is the base link
// TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
column.$refLinks[0]
}
} else {
value = column.value
}
const { ref, val, xpr, func } = value
let res
if (ref) {
res = getTransformedTokenStream([value], baseLink)[0]
} else if (xpr) {
res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
} else if (val !== undefined) {
res = { val }
} else if (func) {
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
}
if (!omitAlias) res.as = column.as || column.name || column.flatName
return res
}
/**
* This function resolves a `ref` starting with a `$self`.
* Such a path targets another element of the query by it's implicit, or explicit alias.
*
* A `$self` reference may also target another `$self` path. In this case, this function
* recursively resolves the tail of the `$self` references (`$selfPath.ref.slice(2)`) onto it's
* new base.
*
* @param {object} col with a ref like `[ '$self', <target column>, <optional further path navigation> ]`
* @param {boolean} omitAlias if we replace a $self reference in an aggregation or a token stream, we must not add an "as" to the result
*/
function calculateDollarSelfColumn(col, omitAlias = false) {
const dummyColumn = buildDummyColumnForDollarSelf({ ...col }, col.$refLinks)
return dummyColumn
function buildDummyColumnForDollarSelf(dollarSelfColumn, $refLinks) {
const { ref, as } = dollarSelfColumn
const stepToFind = ref[1]
let referencedColumn = inferred.SELECT.columns.find(
otherColumn =>
otherColumn !== dollarSelfColumn &&
(otherColumn.as
? stepToFind === otherColumn.as
: stepToFind === otherColumn.ref?.[otherColumn.ref.length - 1]),
)
if (referencedColumn.ref?.[0] === '$self') {
referencedColumn = buildDummyColumnForDollarSelf({ ...referencedColumn }, referencedColumn.$refLinks)
}
if (referencedColumn.ref) {
dollarSelfColumn.ref = [...referencedColumn.ref, ...dollarSelfColumn.ref.slice(2)]
Object.defineProperties(dollarSelfColumn, {
flatName: {
value:
referencedColumn.$refLinks[0].definition.kind === 'entity'
? dollarSelfColumn.ref.slice(1).join('_')
: dollarSelfColumn.ref.join('_'),
},
isJoinRelevant: {
value: referencedColumn.isJoinRelevant,
},
$refLinks: {
value: [...referencedColumn.$refLinks, ...$refLinks.slice(2)],
},
})
} else {
// target column is `val` or `xpr`, destructure and throw away the ref with the $self
// eslint-disable-next-line no-unused-vars
const { xpr, val, ref, as: _as, ...rest } = referencedColumn
if (xpr) rest.xpr = xpr
else rest.val = val
dollarSelfColumn = { ...rest } // reassign dummyColumn without 'ref'
if (!omitAlias) dollarSelfColumn.as = as
}
return dollarSelfColumn.ref?.[0] === '$self'
? buildDummyColumnForDollarSelf({ ...dollarSelfColumn }, $refLinks)
: dollarSelfColumn
}
}
/**
* Calculates the columns for a nested projection on a structure.
*
* @param {object} col
* @param {'inline'|'expand'} prop the property on which to operate. Default is `inline`.
* @returns a list of flat columns.
*/
function nestedProjectionOnStructure(col, prop = 'inline') {
const res = []
col[prop].forEach((nestedProjection, i) => {
let rewrittenColumns = []
if (nestedProjection === '*') {
res.push(...expandNestedProjectionWildcard(col, i, prop))
} else {
const nameParts = col.as ? [col.as] : [col.ref.map(idOnly).join('_')]
nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
const name = nameParts.join('_')
if (nestedProjection.ref) {
const augmentedInlineCol = { ...nestedProjection }
augmentedInlineCol.ref = col.ref ? [...col.ref, ...nestedProjection.ref] : nestedProjection.ref
if (
col.as ||
nestedProjection.as ||
nestedProjection.$refLinks[nestedProjection.$refLinks.length - 1].definition.value ||
nestedProjection.isJoinRelevant
) {
augmentedInlineCol.as = nameParts.join('_')
}
Object.defineProperties(augmentedInlineCol, {
$refLinks: { value: [...nestedProjection.$refLinks], writable: true },
isJoinRelevant: {
value: nestedProjection.isJoinRelevant,
writable: true,
},
})
// if the expand is not anonymous, we must prepend the expand columns path
// to make sure the full path is resolvable
if (col.ref) {
augmentedInlineCol.$refLinks.unshift(...col.$refLinks)
augmentedInlineCol.isJoinRelevant = augmentedInlineCol.isJoinRelevant || col.isJoinRelevant
}
const flatColumns = getTransformedColumns([augmentedInlineCol])
flatColumns.forEach(flatColumn => {
const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === flatColumnName)) {
const rewrittenColumn = { ...flatColumn }
if (nestedProjection.as) rewrittenColumn.as = flatColumnName
rewrittenColumns.push(rewrittenColumn)
}
})
} else {
// func, xpr, val..
// we need to check if the column was already added
// in the wildcard expansion
if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
const rewrittenColumn = { ...nestedProjection }
rewrittenColumn.as = name
rewrittenColumns.push(rewrittenColumn)
}
}
}
res.push(...rewrittenColumns)
})
return res
}
/**
* Expand the wildcard of the given column into all leaf elements.
* Respect smart wildcard rules and excluding clause.
*
* Every column before the wildcardIndex is excluded from the wildcard expansion.
* Columns after the wildcardIndex overwrite columns within the wildcard expansion in place.
*
* @TODO use this also for `expand` wildcards on structures.
*
* @param {csn.Column} col
* @param {integer} wildcardIndex
* @returns an array of columns which represents the expanded wildcard
*/
function expandNestedProjectionWildcard(col, wildcardIndex, prop = 'inline') {
const res = []
// everything before the wildcard is inserted before the wildcard
// and ignored from the wildcard expansion
const exclude = col[prop].slice(0, wildcardIndex)
// everything after the wildcard, is a potential replacement
// in the wildcard expansion
const replace = []
const baseRef = col.ref || []
const baseRefLinks = col.$refLinks || []
// column has no ref, then it is an anonymous expand:
// select from books { { * } as bar }
// only possible if there is exactly one query source
if (!baseRef.length) {
const [tableAlias, { definition }] = Object.entries(inferred.sources)[0]
baseRef.push(tableAlias)
baseRefLinks.push({ definition, source: definition })
}
// we need to make the refs absolute
col[prop].slice(wildcardIndex + 1).forEach(c => {
const fakeColumn = { ...c }
if (fakeColumn.ref) {
fakeColumn.ref = [...baseRef, ...fakeColumn.ref]
fakeColumn.$refLinks = [...baseRefLinks, ...c.$refLinks]
}
replace.push(fakeColumn)
})
// respect excluding clause
if (col.excluding) {
// fake the ref since excluding only has strings
col.excluding.forEach(c => {
const fakeColumn = {
ref: [...baseRef, c],
}
exclude.push(fakeColumn)
})
}
if (baseRefLinks.at(-1).definition.kind === 'entity') {
res.push(...getColumnsForWildcard(exclude, replace, col.as))
} else
res.push(
...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
exclude,
replace,
}),
)
return res
}
/**
* This function converts a column with an `expand` property into a subquery.
*
* It operates by using the following steps:
*
* 1. It creates an intermediate SQL query, selecting `from <effective query source>:...<column>.ref { ...<column>.expand }`.
* For example, from the query `SELECT from Authors { books { title } }`, it generates:
* - `SELECT from Authors:books as books {title}`
*
* 2. It then adds the properties `expand: true` and `one: <expand assoc>.is2one` to the intermediate SQL query.
*
* 3. It applies `cqn4sql` to the intermediate query (ensuring the aliases of the outer query are maintained).
* For example, `cqn4sql(…)` is used to create the following query:
* - `SELECT from Books as books {books.title} where exists ( SELECT 1 from Authors as Authors where Authors.ID = books.author_ID )`
*
* 4. It then replaces the `exists <subquery>` with the where condition of the `<subquery>` and correlates it with the effective query source.
* For example, this query is created:
* - `SELECT from Books as books { books.title } where Authors.ID = books.author_ID`
*
* 5. Lastly, it replaces the `expand` column of the original query with the transformed subquery.
* For example, the query becomes:
* - `SELECT from Authors { (SELECT from Books as books { books.title } where Authors.ID = books.author_ID) as books }`
*
* @param {CSN.column} column - The column with the 'expand' property to be transformed into a subquery.
*
* @returns {object} Returns a subquery correlated with the enclosing query, with added properties `expand:true` and `one:true|false`.
*/
function expandColumn(column) {
let outerAlias
let subqueryFromRef
const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
const assoc = column.$refLinks.at(-1)
ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
if (column.isJoinRelevant) {
// all n-1 steps of the expand column are already transformed into joins
// find the last join relevant association. That is the n-1 assoc in the ref path.
// slice the ref array beginning from the n-1 assoc in the ref and take that as the postfix for the subqueries from ref.
;[...column.$refLinks]
.reverse()
.slice(1)
.find((link, i) => {
if (link.definition.isAssociation) {
subqueryFromRef = [link.definition.target, ...column.ref.slice(-(i + 1), column.ref.length)]
// alias of last join relevant association is also the correlation alias for the subquery
outerAlias = link.alias
return true
}
})
} else {
outerAlias = transformedQuery.SELECT.from.as
const getInnermostTarget = q => (q._target ? getInnermostTarget(q._target) : q)
subqueryFromRef = [
...(transformedQuery.SELECT.from.ref || /* subq in from */ [getInnermostTarget(transformedQuery).name]),
...ref,
]
}
// this is the alias of the column which holds the correlated subquery
const columnAlias = column.as || ref.map(idOnly).join('_')
// if there is a group by on the main query, all
// columns of the expand must be in the groupBy
if (transformedQuery.SELECT.groupBy) {
const baseRef = column.$refLinks[0].definition.SELECT || ref
return _subqueryForGroupBy(column, baseRef, columnAlias)
}
// Alias in expand subquery is derived from but not equal to
// the alias of the column because to account for potential ambiguities
// the alias cannot be addressed anyways
const uniqueSubqueryAlias = getNextAvailableTableAlias(getImplicitAlias(columnAlias), inferred.outerQueries)
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
const subqueryBase = {}
const queryModifiers = { ...column }
for (const [key, value] of Object.entries(queryModifiers)) {
if (key in { limit: 1, orderBy: 1, groupBy: 1, excluding: 1, where: 1, having: 1, count: 1 })
subqueryBase[key] = value
}
const subquery = {
SELECT: {
...subqueryBase,
from,
columns: JSON.parse(JSON.stringify(column.expand)),
expand: true,
one: column.$refLinks.at(-1).definition.is2one,
},
}
const expanded = transformSubquery(subquery)
const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
Object.defineProperty(correlated, 'elements', {
value: expanded.elements,
writable: true,
})
return correlated
function _correlate(subq, outer) {
const subqueryFollowingExists = (a, indexOfExists) => a[indexOfExists + 1]
let {
SELECT: { where },
} = subq
let recent = where
let i = where.indexOf('exists')
while (i !== -1) {
where = subqueryFollowingExists((recent = where), i).SELECT.where
i = where.indexOf('exists')
}
const existsIndex = recent.indexOf('exists')
recent.splice(
existsIndex,
2,
...where.map(x => {
return replaceAliasWithSubqueryAlias(x)
}),
)
function replaceAliasWithSubqueryAlias(x) {
const existsSubqueryAlias = recent[existsIndex + 1].SELECT.from.as
if (existsSubqueryAlias === x.ref?.[0]) return { ref: [outer, ...x.ref.slice(1)] }
if (x.xpr) x.xpr = x.xpr.map(replaceAliasWithSubqueryAlias)
if (x.args) x.args = x.args.map(replaceAliasWithSubqueryAlias)
return x
}
return subq
}
/**
* Generates a special subquery for the `expand` of the `column`.
* All columns in the `expand` must be part of the GROUP BY clause of the main query.
* If this is the case, the subqueries columns match the corresponding references of the group by.
* Nested expands are also supported.
*
* @param {Object} column - To expand.
* @param {Array} baseRef - The base reference for the expanded column.
* @param {string} subqueryAlias - The alias of the `expand` subquery column.
* @returns {Object} - The subquery object or null if the expand has a wildcard.
* @throws {Error} - If one of the `ref`s in the `column.expand` is not part of the GROUP BY clause.
*/
function _subqueryForGroupBy(column, baseRef, subqueryAlias) {
const groupByLookup = new Map(
transformedQuery.SELECT.groupBy.map(c => [c.ref && c.ref.map(refWithConditions).join('.'), c]),
)
// to be attached to dummy query
const elements = {}
const containsWildcard = column.expand.includes('*')
if (containsWildcard) {
// expand with wildcard vanishes as expand is part of the group by (OData $apply + $expand)
return null
}
const expandedColumns = column.expand
.flatMap(expand => {
if (!expand.ref) return expand
const fullRef = [...baseRef, ...expand.ref]
if (expand.expand) {
const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
if (nested) {
setElementOnColumns(nested, expand.element)
elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
}
return nested
}
const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
if (!groupByRef) {
throw new Error(
`The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
)
}
const copy = Object.create(groupByRef)
// always alias for this special case, so that they nested element names match the expected result structure
// otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
copy.as = expand.as || expand.ref.at(-1)
const tableAlias = getTableAlias(copy)
const res = getFlatColumnsFor(copy, { tableAlias })
res.forEach(c => {
elements[c.as || c.ref.at(-1)] = c.element
})
return res
})
.filter(c => c)
if (expandedColumns.length === 0) {
return null
}
const SELECT = {
from: null,
columns: expandedColumns,
}
return Object.defineProperties(
{},
{
SELECT: {
value: Object.defineProperties(SELECT, {
expand: { value: true, writable: true }, // non-enumerable
one: { value: column.$refLinks.at(-1).definition.is2one, writable: true }, // non-enumerable
}),
enumerable: true,
},
as: { value: subqueryAlias, enumerable: true, writable: true },
elements: { value: elements }, // non-enumerable
},
)
}
}
function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
const res = []
for (let i = 0; i < columns.length; i++) {
let col = columns[i]
if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
const calcElement = resolveCalculatedElement(col, true)
res.push(calcElement)
} else if (pseudos.elements[col.ref?.[0]]) {
res.push({ ...col })
} else if (col.ref) {
if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true))
continue
if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
const dollarSelfReplacement = calculateDollarSelfColumn(col)
res.push(...getTransformedOrderByGroupBy([dollarSelfReplacement], inOrderBy))
continue
}
const { target, definition } = col.$refLinks[0]
let tableAlias = null
if (target.SELECT?.columns && inOrderBy) {
// usually TA is omitted if order by ref is a column
// if a localized sorting is requested, we add `COLLATE`s
// later on, which transforms the simple name to an expression
// --> in an expression, only source elements can be addressed, hence we must add TA
if (target.SELECT.localized && definition.type === 'cds.String') {
const referredCol = target.SELECT.columns.find(c => {
return c.as === col.ref[0] || c.ref?.at(-1) === col.ref[0]
})
if (referredCol) {
// keep sort and nulls properties
referredCol.sort = col.sort
referredCol.nulls = col.nulls
col = referredCol
if (definition.kind === 'element') {
tableAlias = getTableAlias(col)
} else {
// we must replace the reference with the underlying expression
const { val, func, args, xpr } = col
if (val) res.push({ val })
if (func) res.push({ func, args })
if (xpr) res.push({ xpr })
continue
}
}
}
} else {
tableAlias = getTableAlias(col) // do not prepend TA if orderBy column addresses element of query
}
const leaf = col.$refLinks[col.$refLinks.length - 1].definition
if (leaf.virtual === true) continue // already in getFlatColumnForElement
let baseName
if (col.ref.length >= 2) {
// leaf might be intermediate structure
baseName = col.ref
.map(idOnly)
.slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1)
.join('_')
}
const flatColumns = getFlatColumnsFor(col, { baseName, tableAlias })
/**
* We can't guarantee that the element order will NOT change in the future.
* We claim that the element order doesn't matter, hence we can't allow elements
* in the order by clause which expand to more than one column, as the order impacts
* the result.
*/
if (inOrderBy && flatColumns.length > 1)
throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
flatColumns.forEach(fc => {
if (col.nulls) fc.nulls = col.nulls
if (col.sort) fc.sort = col.sort
if (fc.as) delete fc.as
})
res.push(...flatColumns)
} else {
let transformedColumn
if (col.SELECT) transformedColumn = transformSubquery(col)
else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
else if (col.func) transformedColumn = { args: getTransformedFunctionArgs(col.args), func: col.func }
// val
else transformedColumn = copy(col)
if (col.sort) transformedColumn.sort = col.sort
if (col.nulls) transformedColumn.nulls = col.nulls
res.push(transformedColumn)
}
}
return res
}
/**
* Transforms a subquery.
*
* If the current query contains outer queries (is itself a subquery),
* it appends the current inferred query.
* Otherwise, it initializes the `outerQueries` array and adds the inferred query.
* The `outerQueries` property makes sure
* that the table aliases of the outer queries are accessible within the scope of the subquery.
* Lastly, it recursively calls cqn4sql on the subquery.
*
* @param {object} q - The query to be transformed. This should be a subquery object.
* @returns {object} - The cqn4sql transformed subquery.
*/
function transformSubquery(q) {
if (q.outerQueries) q.outerQueries.push(inferred)
else {
const outerQueries = inferred.outerQueries || []
outerQueries.push(inferred)
Object.defineProperty(q, 'outerQueries', { value: outerQueries })
}
const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
if (isLocalized(target)) q.SELECT.localized = true
if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
return cqn4sql(q, model)
function assignUniqueSubqueryAlias() {
if (q.SELECT.from.uniqueSubqueryAlias) return
const last = q.SELECT.from.ref.at(-1)
const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
getImplicitAlias(last.id || last),
inferred.outerQueries,
)
Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
}
}
/**
* This function converts a wildcard into explicit columns.
*
* Based on the query's `$combinedElements` attribute, the function computes the flat column representations
* and returns them. Additionally, it prepends the respective table alias to each column. Columns specified
* in the `excluding` clause are ignored during this transformation.
*
* Furthermore, foreign keys (FK) for OData CSN and blobs are excluded from the wildcard expansion.
*
* @param {array} exclude - An optional list of columns to be excluded during the wildcard expansion.
* @param {array} replace - An optional list of columns to replace during the wildcard expansion.
* @param {string} baseName - the explicit alias of the column.
* Only possible for anonymous expands on implicit table alias: `select from books { { * } as FOO }`
*
* @returns {Array} Returns an array of explicit columns derived from the wildcard.
*/
function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
const wildcardColumns = []
for (const k of Object.keys(inferred.$combinedElements)) {
if (!exclude.includes(k)) {
const { index, tableAlias } = inferred.$combinedElements[k][0]
const element = tableAlias.elements[k]
// ignore FK for odata csn / ignore blobs from wildcard expansion
if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
// for wildcard on subquery in from, just reference the elements
if (tableAlias.SELECT && !element.elements && !element.target) {
wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
} else if (isCalculatedOnRead(element)) {
wildcardColumns.push(resolveCalculatedElement(replace.find(r => r.as === k) || element))
} else {
const flatColumns = getFlatColumnsFor(
element,
{ tableAlias: index, baseName },
[],
{ exclude, replace },
true,
)
wildcardColumns.push(...flatColumns)
}
}
}
return wildcardColumns
/**
* foreign keys are already part of the elements in a flat model
* not excluding the associations from the wildcard columns would cause duplicate columns upon foreign key expansion
* @param {CSN.element} e
* @returns {boolean} true if the element is a managed association and the model is flat
*/
function isManagedAssocInFlatMode(e) {
return (
e.isAssociation && e.keys && (model.meta.transformation === 'odata' || model.meta.unfolded?.includes('structs'))
)
}
}
/**
* Resolve `ref` within `def` and return the element
*
* @param {string[]} ref
* @param {CSN.Artifact} def
* @returns {CSN.Element}
*/
function getElementForRef(ref, def) {
return ref.reduce((prev, res) => {
return (prev?.elements || prev?.foreignKeys)?.[res] || getDefinition(prev?.target)?.elements[res] // PLEASE REVIEW: should we add the .foreignKey check here for the non-ucsn case?
}, def)
}
/**
* Recursively expands a structured element into flat columns, representing all leaf paths.
* This function transforms complex structured elements into simple column representations.
*
* For each element, the function checks if it's a structure, an association or a scalar,
* and proceeds accordingly. If the element is a structure, it recursively fetches flat columns for all sub-elements.
* If it's an association, it fetches flat columns for it's foreign keys.
* If it's a scalar, it creates a flat column for it.
*
* Columns excluded in a wildcard expansion or replaced by other columns are also handled accordingly.
*
* @param {object} column - The structured element which needs to be expanded.
* @param {{
* columnAlias: string
* tableAlias: string
* baseName: string
* }} names - configuration object for naming parameters:
* columnAlias - The explicit alias which the user has defined for the column.
* For instance `{ struct.foo as bar}` will be transformed into
* `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
* tableAlias - The table alias to prepend to the column name. Optional.
* baseName - The prefixes of the column reference (joined with '_'). Optional.
* @param {string} columnAlias - The explicit alias which the user has defined for the column.
* For instance `{ struct.foo as bar}` will be transformed into
* `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
* @param {string} tableAlias - The table alias to prepend to the column name. Optional.
* @param {Array} csnPath - An array containing CSN paths. Optional.
* @param {Array} exclude - An array of columns to be excluded from the flat structure. Optional.
* @param {Array} replace - An array of columns to be replaced in the flat structure. Optional.
*
* @returns {object[]} Returns an array of flat column(s) for the given element.
*/
function getFlatColumnsFor(column, names, csnPath = [], excludeAndReplace, isWildcard = false) {
if (!column) return column
if (column.val || column.func || column.SELECT) return [column]
const structsAreUnfoldedAlready = model.meta.unfolded?.includes('str