@cap-js/db-service
Version:
CDS base database service
1,106 lines (1,044 loc) • 51.9 kB
JavaScript
'use strict'
const cds = require('@sap/cds')
const JoinTree = require('./join-tree')
const { pseudos } = require('./pseudos')
const { isCalculatedOnRead, getImplicitAlias } = require('../utils')
const cdsTypes = cds.linked({
definitions: {
Timestamp: { type: 'cds.Timestamp' },
DateTime: { type: 'cds.DateTime' },
Date: { type: 'cds.Date' },
Time: { type: 'cds.Time' },
String: { type: 'cds.String' },
Decimal: { type: 'cds.Decimal' },
Integer: { type: 'cds.Integer' },
Boolean: { type: 'cds.Boolean' },
},
}).definitions
for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
/**
* @param {import('@sap/cds/apis/cqn').Query|string} originalQuery
* @param {import('@sap/cds/apis/csn').CSN} [model]
* @returns {import('./cqn').Query} = q with .target and .elements
*/
function infer(originalQuery, model) {
if (!model) throw new Error('Please specify a model')
const inferred = originalQuery
// REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
// e.g. there's a lot of overhead for infer( SELECT.from(Books) )
if (originalQuery.SET) throw new Error('”UNION” based queries are not supported')
const _ =
inferred.SELECT ||
inferred.INSERT ||
inferred.UPSERT ||
inferred.UPDATE ||
inferred.DELETE ||
inferred.CREATE ||
inferred.DROP
// cache for already processed calculated elements
const alreadySeenCalcElements = new Set()
let $combinedElements
const sources = inferTarget(_.from || _.into || _.entity, {})
const joinTree = new JoinTree(sources)
const aliases = Object.keys(sources)
const target = aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery
Object.defineProperties(inferred, {
// REVISIT: public, or for local reuse, or in cqn4sql only?
sources: { value: sources, writable: true },
_target: { value: target, writable: true, configurable: true }, // REVISIT: legacy?
})
// also enrich original query -> writable because it may be inferred again
Object.defineProperties(originalQuery, {
sources: { value: sources, writable: true },
_target: { value: target, writable: true, configurable: true },
})
if (originalQuery.SELECT || originalQuery.DELETE || originalQuery.UPDATE) {
$combinedElements = inferCombinedElements()
/**
* TODO: this function is currently only called on DELETE's
* because it correctly set's up the $refLink's in the
* where clause: This functionality should be pulled out
* of ´inferQueryElement()` as this is a subtle side effect
*/
const elements = inferQueryElements()
Object.defineProperties(inferred, {
$combinedElements: { value: $combinedElements, writable: true, configurable: true },
elements: { value: elements, writable: true, configurable: true },
joinTree: { value: joinTree, writable: true, configurable: true }, // REVISIT: eliminate
})
// also enrich original query -> writable because it may be inferred again
Object.defineProperty(originalQuery, 'elements', { value: elements, writable: true, configurable: true })
}
return inferred
/**
* Infers all query sources from a given SQL-like query's `from` clause.
* It drills down into join arguments of the `from` clause.
*
* This function helps identify each source, target, and association within the `from` clause.
* It processes the `from` clause in the query and maps each source to a respective target and alias.
* In case of any errors like missing definitions or associations, this function will throw an error.
*
* @function inferTarget
* @param {object|string} from - The `from` clause of the query to infer the target from.
* It could be an object or a string.
* @param {object} querySources - An object to map the query sources.
* Each key is a query source alias, and its value is the corresponding CSN Definition.
* @returns {object} The updated `querySources` object with inferred sources from the `from` clause.
*/
function inferTarget(from, querySources, useTechnicalAlias = true) {
const { ref } = from
if (ref) {
const { id, args } = ref[0]
const first = id || ref[0]
let target = getDefinition(first) || cds.error`"${first}" not found in the definitions of your model`
if (!target) throw new Error(`"${first}" not found in the definitions of your model`)
if (ref.length > 1) {
target = from.ref.slice(1).reduce((d, r) => {
const next = getDefinition(d.elements[r.id || r]?.target) || d.elements[r.id || r]
if (!next) throw new Error(`No association “${r.id || r}” in ${d.kind} “${d.name}”`)
return next
}, target)
}
if (target.kind !== 'entity' && !target.isAssociation)
throw new Error('Query source must be a an entity or an association')
inferArg(from, null, null, { inFrom: true })
const alias =
from.uniqueSubqueryAlias ||
from.as ||
(ref.length === 1
? getImplicitAlias(first, useTechnicalAlias)
: getImplicitAlias(ref.at(-1).id || ref.at(-1), useTechnicalAlias));
if (alias in querySources) throw new Error(`Duplicate alias "${alias}"`)
querySources[alias] = { definition: target, args }
const last = from.$refLinks.at(-1)
last.alias = alias
} else if (from.args) {
from.args.forEach(a => inferTarget(a, querySources, false))
} else if (from.SELECT) {
const subqueryInFrom = infer(from, model) // we need the .elements in the sources
// if no explicit alias is provided, we make up one
const subqueryAlias =
from.as || subqueryInFrom.joinTree.addNextAvailableTableAlias('__select__', subqueryInFrom.outerQueries)
querySources[subqueryAlias] = { definition: from }
} else if (typeof from === 'string') {
// TODO: Create unique alias, what about duplicates?
const definition = getDefinition(from) || cds.error`"${from}" not found in the definitions of your model`
querySources[getImplicitAlias(from, useTechnicalAlias)] = { definition }
} else if (from.SET) {
infer(from, model)
}
return querySources
}
/**
* Calculates the `$combinedElements` based on the provided queries `sources`.
* The `$combinedElements` of a query consist of all accessible elements across all
* the table aliases found in the from clause.
*
* The `$combinedElements` are attached to the query as a non-enumerable property.
* Each entry in the `$combinedElements` dictionary maps from the element name
* to an array of objects containing the index and table alias where the element can be found.
*
* @returns {object} The `$combinedElements` dictionary, which maps element names to an array of objects
* containing the index and table alias where the element can be found.
*/
function inferCombinedElements() {
const combinedElements = {}
for (const index in sources) {
const tableAlias = getDefinitionFromSources(sources, index)
for (const key in tableAlias.elements) {
if (key in combinedElements) combinedElements[key].push({ index, tableAlias })
else combinedElements[key] = [{ index, tableAlias }]
}
}
return combinedElements
}
/**
* Assigns the given `element` as non-enumerable property 'element' onto `col`.
*
* @param {object} col
* @param {csn.Element} element
*/
function setElementOnColumns(col, element) {
Object.defineProperty(col, 'element', {
value: element,
writable: true,
})
}
/**
* Walks over all columns of a query's `SELECT` and infers each `ref`, `xpr`, or `val` as a query element
* based on the query's `$combinedElements` and `sources`.
*
* The inferred `elements` are attached to the query as a non-enumerable property.
*
* Also walks over other `ref`s in the query, validates them, and attaches `$refLinks`.
* This includes handling `where`, infix filters within column `refs`, or other `csn` paths.
*
* @param {object} $combinedElements The `$combinedElements` dictionary of the query, which maps element names
* to an array of objects containing the index and table alias where the element can be found.
* @returns {object} The inferred `elements` dictionary of the query, which maps element names to their corresponding definitions.
*/
function inferQueryElements() {
let queryElements = {}
const { columns, where, groupBy, having, orderBy } = _
if (!columns) {
inferElementsFromWildCard(queryElements)
} else {
let wildcardSelect = false
const dollarSelfRefs = []
columns.forEach(col => {
if (col === '*') {
wildcardSelect = true
} else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
const as = col.as || col.func || col.val
if (as === undefined) cds.error`Expecting expression to have an alias name`
if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
if (col.xpr || col.SELECT) {
queryElements[as] = getElementForXprOrSubquery(col, queryElements, dollarSelfRefs)
}
if (col.func) {
if (col.args) {
// {func}.args are optional
applyToFunctionArgs(col.args, inferArg, [false, null, {dollarSelfRefs}])
}
queryElements[as] = getElementForCast(col)
}
if (!queryElements[as]) {
// either binding parameter (col.param) or value
queryElements[as] = col.cast ? getElementForCast(col) : getCdsTypeForVal(col.val)
}
setElementOnColumns(col, queryElements[as])
} else if (col.ref) {
const firstStepIsTableAlias =
(col.ref.length > 1 && col.ref[0] in sources) ||
// nested projection on table alias
(col.ref.length === 1 && col.ref[0] in sources && col.inline)
const firstStepIsSelf =
!firstStepIsTableAlias && col.ref.length > 1 && ['$self', '$projection'].includes(col.ref[0])
// we must handle $self references after the query elements have been calculated
if (firstStepIsSelf) dollarSelfRefs.push(col)
else handleRef(col)
} else if (col.expand) {
inferArg(col, queryElements, null)
} else {
cds.error`Not supported: ${JSON.stringify(col)}`
}
})
if (dollarSelfRefs.length) inferDollarSelfRefs(dollarSelfRefs)
if (wildcardSelect) inferElementsFromWildCard(queryElements)
}
if (orderBy) {
// link $refLinks -> special name resolution rules for orderBy
orderBy.forEach(token => {
let $baseLink
let rejectJoinRelevantPath
// first check if token ref is resolvable in query elements
if (columns) {
const firstStep = token.ref?.[0].id || token.ref?.[0]
const tokenPointsToQueryElements = columns.some(c => {
const columnName = c.as || c.flatName || c.ref?.at(-1).id || c.ref?.at(-1) || c.func
return columnName === firstStep
})
const needsElementsOfQueryAsBase =
tokenPointsToQueryElements &&
queryElements[token.ref?.[0]] &&
/* expand on structure can be addressed */ !queryElements[token.ref?.[0]].$assocExpand
// if the ref points into the query itself and follows an exposed association
// to a non-fk column, we must reject the ref, as we can't join with the queries own results
rejectJoinRelevantPath = needsElementsOfQueryAsBase
if (needsElementsOfQueryAsBase) $baseLink = { definition: { elements: queryElements }, target: inferred }
} else {
// fallback to elements of query source
$baseLink = null
}
inferArg(token, queryElements, $baseLink, { inQueryModifier: true })
if (token.isJoinRelevant && rejectJoinRelevantPath) {
// reverse the array, find the last association and calculate the index of the association in non-reversed order
const assocIndex =
token.$refLinks.length - 1 - token.$refLinks.reverse().findIndex(link => link.definition.isAssociation)
throw new Error(
`Can follow managed association “${token.ref[assocIndex].id || token.ref[assocIndex]}” only to the keys of its target, not to “${token.ref[assocIndex + 1].id || token.ref[assocIndex + 1]}”`,
)
}
})
}
// walk over all paths in other query properties
if (where) walkTokenStream(where, true)
if (groupBy) walkTokenStream(groupBy)
if (having) walkTokenStream(having)
if (_.with)
// consider UPDATE.with
Object.values(_.with).forEach(val => inferArg(val, queryElements, null, { inXpr: true }))
return queryElements
/**
* Recursively drill down into a tokenStream (`where` or `having`) and pass
* on the information whether the next token is resolved within an `exists` predicates.
* If such a token has an infix filter, it is not join relevant, because the filter
* condition is applied to the generated `exists <subquery>` condition.
*
* @param {array} tokenStream
*/
function walkTokenStream(tokenStream, inXpr = false) {
let skipJoins
const processToken = t => {
if (t === 'exists') {
// no joins for infix filters along `exists <path>`
skipJoins = true
} else if (t.xpr) {
// don't miss an exists within an expression
t.xpr.forEach(processToken)
} else {
inferArg(t, queryElements, null, { inExists: skipJoins, inXpr, inQueryModifier: true })
skipJoins = false
}
}
tokenStream.forEach(processToken)
}
/**
* Processes references starting with `$self`, which are intended to target other query elements.
* These `$self` paths must be handled after processing the "regular" columns since they are dependent on other query elements.
*
* This function checks for `$self` references that may target other `$self` columns, and delays their processing.
* `$self` references not targeting other `$self` references are handled by the generic `handleRef` function immediately.
*
* @param {array} dollarSelfColumns - An array of column objects containing `$self` references.
*/
function inferDollarSelfRefs(dollarSelfColumns) {
do {
const unprocessedColumns = []
for (const currentDollarSelfColumn of dollarSelfColumns) {
const { ref, inXpr } = currentDollarSelfColumn
const stepToFind = ref[1]
const referencesOtherDollarSelfColumn = dollarSelfColumns.find(
otherDollarSelfCol =>
!(stepToFind in queryElements) &&
otherDollarSelfCol !== currentDollarSelfColumn &&
(otherDollarSelfCol.as
? stepToFind === otherDollarSelfCol.as
: stepToFind === otherDollarSelfCol.ref?.[otherDollarSelfCol.ref.length - 1]),
)
if (referencesOtherDollarSelfColumn) {
unprocessedColumns.push(currentDollarSelfColumn)
} else {
handleRef(currentDollarSelfColumn, inXpr)
}
}
dollarSelfColumns = unprocessedColumns
} while (dollarSelfColumns.length > 0)
}
function handleRef(col, inXpr) {
inferArg(col, queryElements, null, { inXpr })
const { definition } = col.$refLinks[col.$refLinks.length - 1]
if (col.cast)
// final type overwritten -> element not visible anymore
setElementOnColumns(col, getElementForCast(col))
else if ((col.ref.length === 1) & (col.ref[0] === '$user'))
// shortcut to $user.id
setElementOnColumns(col, queryElements[col.as || '$user'])
else setElementOnColumns(col, definition)
}
}
/**
* This function is responsible for inferring a query element based on a provided column.
* It initializes and attaches a non-enumerable `$refLinks` property to the column,
* which stores an array of objects that represent the corresponding artifact of the ref step.
* Each object in the `$refLinks` array corresponds to the same index position in the `column.ref` array.
* Based on the leaf artifact (last object in the `$refLinks` array), the query element is inferred.
*
* @param {object} arg - The column object that contains the properties to infer a query element.
* @param {boolean} [queryElements=true] - Determines whether the inferred element should be inserted into the queries elements.
* For instance, it's set to false when walking over the where clause.
* @param {object} [$baseLink=null] - A base reference link, usually it's an object with a definition and a target.
* Used for infix filters, exists <assoc> and nested projections.
* @param {object} [context={}] - Contextual information for element inference.
* @param {boolean} [context.inExists=false] - Flag to control the creation of joins for non-association path traversals.
* for `exists <assoc>` paths we do not need to create joins for path expressions as they are part of the semi-joined subquery.
* @param {boolean} [context.inXpr=false] - Flag to signal whether the element is part of an expression.
* Used to ignore non-persisted elements.
* @param {boolean} [context.inNestedProjection=false] - Flag to signal whether the element is part of a nested projection.
*
* Note:
* - `inExists` is used to specify cases where no joins should be created for non-association path traversals.
* It is primarily used for infix filters in `exists assoc[parent.foo='bar']`, where it becomes part of a semi-join.
* - Columns with a `param` property are parameter references resolved into values only at execution time.
* - Columns with an `args` property are function calls in expressions.
* - Columns with a `list` property represent a list of values (e.g., for the IN operator).
* - Columns with a `SELECT` property represent subqueries.
*
* @throws {Error} If an unmanaged association is found in an infix filter path, an error is thrown.
* @throws {Error} If a non-foreign key traversal is found in an infix filter, an error is thrown.
* @throws {Error} If a first step is not found in the combined elements, an error is thrown.
* @throws {Error} If a filter is provided while navigating along non-associations, an error is thrown.
* @throws {Error} If the same element name is inferred more than once, an error is thrown.
*
* @returns {void}
*/
function inferArg(arg, queryElements = null, $baseLink = null, context = {}) {
const { inExists, inXpr, inCalcElement, baseColumn, inInfixFilter, inQueryModifier, inFrom, dollarSelfRefs } = context
if (arg.param || arg.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ?
if (arg.args) applyToFunctionArgs(arg.args, inferArg, [null, $baseLink, context])
if (arg.list) arg.list.forEach(arg => inferArg(arg, null, $baseLink, context))
if (arg.xpr) arg.xpr.forEach(token => inferArg(token, queryElements, $baseLink, { ...context, inXpr: true })) // e.g. function in expression
if (!arg.ref) {
if (arg.expand && queryElements) queryElements[arg.as] = resolveExpand(arg)
return
}
// initialize $refLinks
Object.defineProperty(arg, '$refLinks', {
value: [],
writable: true,
})
// if any path step points to an artifact with `@cds.persistence.skip`
// we must ignore the element from the queries elements
let isPersisted = true
let firstStepIsTableAlias, firstStepIsSelf, expandOnTableAlias
if (!inFrom) {
firstStepIsTableAlias = arg.ref.length > 1 && arg.ref[0] in sources
firstStepIsSelf = !firstStepIsTableAlias && arg.ref.length > 1 && ['$self', '$projection'].includes(arg.ref[0])
expandOnTableAlias = arg.ref.length === 1 && arg.ref[0] in sources && (arg.expand || arg.inline)
}
if(dollarSelfRefs && firstStepIsSelf) {
Object.defineProperty(arg, 'inXpr', { value: true, writable: true })
dollarSelfRefs.push(arg)
return
}
const nameSegments = []
// if a (segment) of a (structured) foreign key is renamed, we must not include
// the aliased ref segments into the name of the final foreign key which is e.g. used in
// on conditions of joins
const skipAliasedFkSegmentsOfNameStack = []
let pseudoPath = false
arg.ref.forEach((step, i) => {
const id = step.id || step
if (i === 0) {
if (id in pseudos.elements) {
// pseudo path
arg.$refLinks.push({ definition: pseudos.elements[id], target: pseudos })
pseudoPath = true // only first path step must be well defined
nameSegments.push(id)
} else if ($baseLink) {
const { definition, target } = $baseLink
const elements = getDefinition(definition.target)?.elements || definition.elements
if (elements && id in elements) {
const element = elements[id]
if (inInfixFilter) {
const nextStep = arg.ref[1]?.id || arg.ref[1]
if (isNonForeignKeyNavigation(element, nextStep)) {
if (inExists) {
Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
} else {
rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
}
}
}
const resolvableIn = getDefinition(definition.target) || target
const $refLink = { definition: elements[id], target: resolvableIn }
arg.$refLinks.push($refLink)
} else {
stepNotFoundInPredecessor(id, definition.name)
}
nameSegments.push(id)
} else if (inFrom) {
const definition = getDefinition(id) || cds.error`"${id}" not found in the definitions of your model`
arg.$refLinks.push({ definition, target: definition })
} else if (firstStepIsTableAlias) {
arg.$refLinks.push({
definition: getDefinitionFromSources(sources, id),
target: getDefinitionFromSources(sources, id),
})
} else if (firstStepIsSelf) {
arg.$refLinks.push({ definition: { elements: queryElements }, target: { elements: queryElements } })
} else if (arg.ref.length > 1 && inferred.outerQueries?.find(outer => id in outer.sources)) {
// outer query accessed via alias
const outerAlias = inferred.outerQueries.find(outer => id in outer.sources)
arg.$refLinks.push({
definition: getDefinitionFromSources(outerAlias.sources, id),
target: getDefinitionFromSources(outerAlias.sources, id),
})
} else if (id in $combinedElements) {
if ($combinedElements[id].length > 1) stepIsAmbiguous(id) // exit
const definition = $combinedElements[id][0].tableAlias.elements[id]
const $refLink = { definition, target: $combinedElements[id][0].tableAlias }
arg.$refLinks.push($refLink)
nameSegments.push(id)
} else if (expandOnTableAlias) {
// expand on table alias
arg.$refLinks.push({
definition: getDefinitionFromSources(sources, id),
target: getDefinitionFromSources(sources, id),
})
} else {
stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements)
}
} else {
const { definition } = arg.$refLinks[i - 1]
const elements = getDefinition(definition.target)?.elements || definition.elements //> go for assoc._target first, instead of assoc as struct
const element = elements?.[id]
if (firstStepIsSelf && element?.isAssociation) {
throw new Error(
`Paths starting with “$self” must not contain steps of type “cds.Association”: ref: [ ${arg.ref
.map(idOnly)
.join(', ')} ]`,
)
}
const target = getDefinition(definition.target) || arg.$refLinks[i - 1].target
if (element) {
if ($baseLink && inInfixFilter) {
const nextStep = arg.ref[i + 1]?.id || arg.ref[i + 1]
if (isNonForeignKeyNavigation(element, nextStep)) {
if (inExists) {
Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
} else {
rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
}
}
}
const $refLink = { definition: elements[id], target }
arg.$refLinks.push($refLink)
} else if (firstStepIsSelf) {
stepNotFoundInColumnList(id)
} else if (arg.ref[0] === '$user' && pseudoPath) {
// `$user.some.unknown.element` -> no error
arg.$refLinks.push({ definition: {}, target })
} else if (id === '$dummy') {
// `some.known.element.$dummy` -> no error; used by cds.ql to simulate joins
arg.$refLinks.push({ definition: { name: '$dummy', parent: arg.$refLinks[i - 1].target } })
Object.defineProperty(arg, 'isJoinRelevant', { value: true })
} else {
const notFoundIn = pseudoPath ? arg.ref[i - 1] : getFullPathForLinkedArg(arg)
stepNotFoundInPredecessor(id, notFoundIn)
}
const foreignKeyAlias = Array.isArray(definition.keys)
? definition.keys.find(k => {
if (k.ref.every((step, j) => arg.ref[i + j] === step)) {
skipAliasedFkSegmentsOfNameStack.push(...k.ref.slice(1))
return true
}
return false
})?.as
: null
if (foreignKeyAlias) nameSegments.push(foreignKeyAlias)
else if (skipAliasedFkSegmentsOfNameStack[0] === id) skipAliasedFkSegmentsOfNameStack.shift()
else {
nameSegments.push(firstStepIsSelf && i === 1 ? element.__proto__.name : id)
}
}
if (step.where) {
const danglingFilter = !(arg.ref[i + 1] || arg.expand || arg.inline || inExists)
const definition = arg.$refLinks[i].definition
if ((!definition.target && definition.kind !== 'entity') || (!inFrom && danglingFilter))
throw new Error('A filter can only be provided when navigating along associations')
if (!inFrom && !arg.expand) Object.defineProperty(arg, 'isJoinRelevant', { value: true })
let skipJoinsForFilter = false
step.where.forEach(token => {
if (token === 'exists') {
// books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
skipJoinsForFilter = true
} else if (token.ref || token.xpr || token.list) {
inferArg(token, false, arg.$refLinks[i], {
inExists: skipJoinsForFilter || inExists,
inXpr: !!token.xpr,
inInfixFilter: true,
inFrom,
})
} else if (token.func) {
if (token.args) {
applyToFunctionArgs(token.args, inferArg, [
false,
arg.$refLinks[i],
{ inExists: skipJoinsForFilter || inExists, inXpr: true, inInfixFilter: true, inFrom },
])
}
}
})
}
arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
if (getDefinition(arg.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true) isPersisted = false
if (!arg.ref[i + 1]) {
const flatName = nameSegments.join('_')
Object.defineProperty(arg, 'flatName', { value: flatName, writable: true })
// if column is casted, we overwrite it's origin with the new type
if (arg.cast) {
const base = getElementForCast(arg)
if (insertIntoQueryElements()) queryElements[arg.as || flatName] = getCopyWithAnnos(arg, base)
} else if (arg.expand) {
const elements = resolveExpand(arg)
let elementName
// expand on table alias
if (arg.$refLinks.length === 1 && arg.$refLinks[0].definition.kind === 'entity')
elementName = arg.$refLinks[0].alias
else elementName = arg.as || flatName
if (queryElements) queryElements[elementName] = elements
} else if (arg.inline && queryElements) {
const elements = resolveInline(arg)
Object.assign(queryElements, elements)
} else {
// shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']`
const leafArt =
i === 0 && id === '$user' ? arg.$refLinks[i].definition.elements.id : arg.$refLinks[i].definition
// infer element based on leaf artifact of path
if (insertIntoQueryElements()) {
let elementName
if (arg.as) {
elementName = arg.as
} else {
// if the navigation the user has written differs from the final flat ref - e.g. for renamed foreign keys -
// the inferred name of the element equals the flat version of the user-written ref.
const refNavigation = arg.ref
.slice(firstStepIsSelf || firstStepIsTableAlias ? 1 : 0)
.map(idOnly)
.join('_')
if (refNavigation !== flatName) elementName = refNavigation
else elementName = flatName
}
if (queryElements[elementName] !== undefined)
throw new Error(`Duplicate definition of element “${elementName}”`)
const element = getCopyWithAnnos(arg, leafArt)
queryElements[elementName] = element
}
}
}
})
// we need inner joins for the path expressions inside filter expressions after exists predicate
if ($baseLink?.pathExpressionInsideFilter) Object.defineProperty(arg, 'join', { value: 'inner' })
// ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
if (arg.expand) {
const { $refLinks } = arg
const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)
if (skip) {
$refLinks[$refLinks.length - 1].skipExpand = true
return
}
}
const leafArt = arg.$refLinks[arg.$refLinks.length - 1].definition
const virtual = (leafArt.virtual || !isPersisted) && !inXpr
// check if we need to merge the column `ref` into the join tree of the query
if (!inFrom && !inExists && !virtual && !inCalcElement) {
// for a ref inside an `inline` we need to consider the column `ref` which has the `inline` prop
const colWithBase = baseColumn
? { ref: [...baseColumn.ref, ...arg.ref], $refLinks: [...baseColumn.$refLinks, ...arg.$refLinks] }
: arg
if (isColumnJoinRelevant(colWithBase)) {
Object.defineProperty(arg, 'isJoinRelevant', { value: true })
joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
}
}
if (isCalculatedOnRead(leafArt)) {
linkCalculatedElement(arg, $baseLink, baseColumn, context)
}
function insertIntoQueryElements() {
return queryElements && !inXpr && !inInfixFilter && !inQueryModifier
}
/**
* Resolves and processes the inline attribute of a column in a database query.
*
* @param {object} col - The column object with properties: `inline` and `$refLinks`.
* @param {string} [namePrefix=col.as || col.flatName] - Prefix for naming new columns. Defaults to `col.as` or `col.flatName`.
* @returns {object} - An object with resolved and processed inline column definitions.
*
* Procedure:
* 1. Iterate through `inline` array. For each `inlineCol`:
* a. If `inlineCol` equals '*', wildcard elements are processed and added to the `elements` object.
* b. If `inlineCol` has inline or expand attributes, corresponding functions are called recursively and the resulting elements are added to the `elements` object.
* c. If `inlineCol` has val or func attributes, new elements are created and added to the `elements` object.
* d. Otherwise, the corresponding `$refLinks` definition is added to the `elements` object.
* 2. Returns the `elements` object.
*/
function resolveInline(col, namePrefix = col.as || col.flatName) {
const { inline, $refLinks } = col
const $leafLink = $refLinks[$refLinks.length - 1]
if (!$leafLink.definition.target && !$leafLink.definition.elements) {
throw new Error(
`Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
)
}
let elements = {}
inline.forEach(inlineCol => {
inferArg(inlineCol, null, $leafLink, { inXpr: true, baseColumn: col })
if (inlineCol === '*') {
const wildCardElements = {}
// either the `.elements´ of the struct or the `.elements` of the assoc target
const leafLinkElements = getDefinition($leafLink.definition.target)?.elements || $leafLink.definition.elements
Object.entries(leafLinkElements).forEach(([k, v]) => {
const name = namePrefix ? `${namePrefix}_${k}` : k
// if overwritten/excluded omit from wildcard elements
// in elements the names are already flat so consider the prefix
// in excluding, the elements are addressed without the prefix
if (!(name in elements || col.excluding?.includes(k))) wildCardElements[name] = v
})
elements = { ...elements, ...wildCardElements }
} else {
const nameParts = namePrefix ? [namePrefix] : []
if (inlineCol.as) nameParts.push(inlineCol.as)
else nameParts.push(...inlineCol.ref.map(idOnly))
const name = nameParts.join('_')
if (inlineCol.inline) {
const inlineElements = resolveInline(inlineCol, name)
elements = { ...elements, ...inlineElements }
} else if (inlineCol.expand) {
const expandElements = resolveExpand(inlineCol)
elements = { ...elements, [name]: expandElements }
} else if (inlineCol.val) {
elements[name] = { ...getCdsTypeForVal(inlineCol.val) }
} else if (inlineCol.func) {
elements[name] = {}
} else {
elements[name] = inlineCol.$refLinks[inlineCol.$refLinks.length - 1].definition
}
}
})
return elements
}
/**
* Resolves a query column which has an `expand` property.
*
* @param {object} col - The column object with properties: `expand` and `$refLinks`.
* @returns {object} - A `cds.struct` object with expanded column definitions.
*
* Procedure:
* - if `$leafLink` is an association, constructs an `expandSubquery` and infers a new query structure.
* Returns a new `cds.struct` if the association has a target cardinality === 1 or a `cds.array` for to many relations.
* - else constructs an `elements` object based on the refs `expand` found in the expand and returns a new `cds.struct` with these `elements`.
*/
function resolveExpand(col) {
const { expand, $refLinks } = col
const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
if (!$leafLink.definition.target && !$leafLink.definition.elements) {
throw new Error(
`Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
)
}
const target = getDefinition($leafLink.definition.target)
if (target) {
const expandSubquery = {
SELECT: {
from: target.name,
columns: expand.filter(c => !c.inline),
},
}
if (col.excluding) expandSubquery.SELECT.excluding = col.excluding
if (col.as) expandSubquery.SELECT.as = col.as
const inferredExpandSubquery = infer(expandSubquery, model)
const res = $leafLink.definition.is2one
? new cds.struct({ elements: inferredExpandSubquery.elements })
: new cds.array({ items: new cds.struct({ elements: inferredExpandSubquery.elements }) })
return Object.defineProperty(res, '$assocExpand', { value: true })
} else if ($leafLink.definition.elements) {
let elements = {}
expand.forEach(e => {
if (e === '*') {
elements = { ...elements, ...$leafLink.definition.elements }
} else {
inferArg(e, false, $leafLink, { inXpr: true })
if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
if (e.inline) elements = { ...elements, ...resolveInline(e) }
else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
}
})
return new cds.struct({ elements })
}
}
function stepNotFoundInPredecessor(step, def) {
throw new Error(`"${step}" not found in "${def}"`)
}
function stepIsAmbiguous(step) {
throw new Error(
`ambiguous reference to "${step}", write ${Object.values($combinedElements[step])
.map(ta => `"${ta.index}.${step}"`)
.join(', ')} instead`,
)
}
function stepNotFoundInCombinedElements(step) {
throw new Error(
`"${step}" not found in the elements of ${Object.values(sources)
.map(s => s.definition)
.map(def => `"${def.name || /* subquery */ def.as}"`)
.join(', ')}`,
)
}
function stepNotFoundInColumnList(step) {
const err = [`"${step}" not found in the columns list of query`]
// if the `elt` from a `$self.elt` path is found in the `$combinedElements` -> hint to remove `$self`
if (step in $combinedElements)
err.push(` did you mean ${$combinedElements[step].map(ta => `"${ta.index || ta.as}.${step}"`).join(',')}?`)
throw new Error(err.join(','))
}
}
function linkCalculatedElement(column, baseLink, baseColumn, context = {}) {
const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
if (alreadySeenCalcElements.has(calcElement)) return
else alreadySeenCalcElements.add(calcElement)
const { ref, xpr } = calcElement.value
if (ref || xpr) {
baseLink = { definition: calcElement.parent, target: calcElement.parent }
inferArg(calcElement.value, null, baseLink, { inCalcElement: true, ...context })
const basePath =
column.$refLinks?.length > 1
? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
: { $refLinks: [], ref: [] }
if (baseColumn) {
basePath.$refLinks.push(...baseColumn.$refLinks)
basePath.ref.push(...baseColumn.ref)
}
mergePathsIntoJoinTree(calcElement.value, basePath)
}
if (calcElement.value.args) {
const processArgument = (arg, calcElement, column) => {
inferArg(arg, null, { definition: calcElement.parent, target: calcElement.parent }, { inCalcElement: true })
const basePath =
column.$refLinks?.length > 1
? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
: { $refLinks: [], ref: [] }
mergePathsIntoJoinTree(arg, basePath)
}
if (calcElement.value.args) {
applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column])
}
}
/**
* Calculates all paths from a given ref and merges them into the join tree.
* Recursively walks into refs of calculated elements.
*
* @param {object} arg with a ref and sibling $refLinks
* @param {object} basePath with a ref and sibling $refLinks, used for recursion
*/
function mergePathsIntoJoinTree(arg, basePath = null) {
basePath = basePath || { $refLinks: [], ref: [] }
if (arg.ref) {
arg.$refLinks.forEach((link, i) => {
const { definition } = link
if (!definition.value) {
basePath.$refLinks.push(link)
basePath.ref.push(arg.ref[i])
}
})
const leafOfCalculatedElementRef = arg.$refLinks[arg.$refLinks.length - 1].definition
if (leafOfCalculatedElementRef.value) mergePathsIntoJoinTree(leafOfCalculatedElementRef.value, basePath)
mergePathIfNecessary(basePath, arg)
} else if (arg.xpr || arg.args) {
const prop = arg.xpr ? 'xpr' : 'args'
arg[prop].forEach(step => {
let subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
if (step.ref) {
step.$refLinks.forEach((link, i) => {
const { definition } = link
if (definition.value) {
mergePathsIntoJoinTree(definition.value, subPath)
} else {
subPath.$refLinks.push(link)
subPath.ref.push(step.ref[i])
}
})
mergePathIfNecessary(subPath, step)
} else if (step.args || step.xpr) {
const nestedProp = step.xpr ? 'xpr' : 'args'
step[nestedProp].forEach(a => {
// reset sub path for each nested argument
// e.g. case when <path> then <otherPath> else <anotherPath> end
if(!a.ref)
subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
mergePathsIntoJoinTree(a, subPath)
})
}
})
}
function mergePathIfNecessary(p, step) {
const calcElementIsJoinRelevant = isColumnJoinRelevant(p)
if (calcElementIsJoinRelevant) {
if (!calcElement.value.isJoinRelevant)
Object.defineProperty(step, 'isJoinRelevant', { value: true, writable: true, })
joinTree.mergeColumn(p, originalQuery.outerQueries)
} else {
// we need to explicitly set the value to false in this case,
// e.g. `SELECT from booksCalc.Books { ID, author.{name }, author {name } }`
// --> for the inline column, the name is join relevant, while for the expand, it is not
Object.defineProperty(step, 'isJoinRelevant', { value: false, writable: true })
}
}
}
}
/**
* Checks whether or not the `ref` of the given column is join relevant.
* A `ref` is considered join relevant if it includes an association traversal and:
* - the association is unmanaged
* - a non-foreign key access is performed
* - an infix filter is applied at the association
*
* @param {object} column the column with the `ref` to check for join relevance
* @returns {boolean} true if the column ref needs to be merged into a join tree
*/
function isColumnJoinRelevant(column) {
let fkAccess = false
let assoc = null
for (let i = 0; i < column.ref.length; i++) {
const ref = column.ref[i]
const link = column.$refLinks[i]
if (link.definition.on && link.definition.isAssociation) {
if (!column.ref[i + 1]) {
if (column.expand && assoc) return true
// if unmanaged assoc is exposed, ignore it
return false
}
return true
}
if (assoc) {
// foreign key access without filters never join relevant
if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
// <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
return true
}
if (link.definition.target && link.definition.keys) {
if (column.ref[i + 1] || assoc) fkAccess = false
else fkAccess = true
assoc = link.definition
if (ref.where) {
// always join relevant except for expand assoc
if (column.expand && !column.ref[i + 1]) return false
return true
}
}
}
if (!assoc) return false
if (fkAccess) return false
return true
}
/**
* Iterates over all `$combinedElements` of the `query` and puts them into the `query`s `elements`,
* if there is not already an element with the same name present.
*/
function inferElementsFromWildCard(queryElements) {
const exclude = _.excluding ? x => _.excluding.includes(x) : () => false
if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
const { elements } = getDefinitionFromSources(sources, aliases[0])
// only one query source and no overwritten columns
for (const k of Object.keys(elements)) {
if (!exclude(k)) {
const element = elements[k]
if (element.type !== 'cds.LargeBinary') {
queryElements[k] = element
}
// only relevant if we actually select the calculated element
if (originalQuery.SELECT && isCalculatedOnRead(element)) {
linkCalculatedElement(element)
}
}
}
return
}
const ambiguousElements = {}
Object.entries($combinedElements).forEach(([name, tableAliases]) => {
if (Object.keys(tableAliases).length > 1) {
ambiguousElements[name] = tableAliases
return ambiguousElements[name]
}
if (exclude(name) || name in queryElements) return true
const element = tableAliases[0].tableAlias.elements[name]
if (element.type !== 'cds.LargeBinary') queryElements[name] = element
if (isCalculatedOnRead(element)) {
linkCalculatedElement(element)
}
})
if (Object.keys(ambiguousElements).length > 0) throwAmbiguousWildcardError()
function throwAmbiguousWildcardError() {
const err = []
err.push('Ambiguous wildcard elements:')
Object.keys(ambiguousElements).forEach(name => {
const tableAliasNames = Object.values(ambiguousElements[name]).map(v => v.index)
err.push(
` select "${name}" explicitly with ${tableAliasNames.map(taName => `"${taName}.${name}"`).join(', ')}`,
)
})
throw new Error(err.join('\n'))
}
}
/**
* Returns a new object which is the inferred element for the given `col`.
* A cast type (via cast function) on the column gets preserved.
*
* @param {object} col
* @returns object
*/
function getElementForXprOrSubquery(col, queryElements, dollarSelfRefs) {
const { xpr } = col
let skipJoins = false
xpr?.forEach(token => {
if (token === 'exists') {
// no joins for infix filters along `exists <path>`
skipJoins = true
} else {
inferArg(token, queryElements, null, { inExists: skipJoins, inXpr: true, dollarSelfRefs })
skipJoins = false
}
})
const base = getElementForCast(col.cast ? col : xpr?.[0] || col)
if (col.key) base.key = col.key // > preserve key on column
return getCopyWithAnnos(col, base)
}
/**
* Returns an object with the cast-type defined in the cast of the `thing`.
* If no cast property is present, it just returns an empty object.
* The type of the cast is mapped to the `cds` type if possible.
*
* @param {object} thing with the cast property
* @returns {object}
*/
function getElementForCast(thing) {
const { cast, $refLinks } = thing
if (!cast) return {}
if ($refLinks?.[$refLinks.length - 1].definition.elements)
// no cast on structure
cds.error`Structured elements can't be cast to a different type`
thing.cast = cdsTypes[cast.type] || cast
return thing.cast
}
/**
* return a new object based on @param base
* with all annotations found in @param from
*
* @param {object} from
* @param {object} base
* @returns {object} a copy of @param base with all annotations of @param from
* @TODO prototype based
*/
// REVISIT: TODO: inferred.elements should be linked
function getCopyWithAnnos(from, base) {
const result = { ...base }
// REVISIT: we don't need to and hence should not handle annotations at runtime
for (const prop in from) {
if (prop.startsWith('@')) result[prop] = from[prop]
}
if (from.as && base.name !== from.as) Object.defineProperty(result, 'name', { value: from.as }) // TODO double check if this is needed
// in subqueries we need the linked element if an outer query accesses it
return Object.setPrototypeOf(result, base)
}
function getCdsTypeForVal(val) {
// REVISIT: JS null should have a type for proper DB layer conversion logic
// if(val === null) return {type:'cds.String'}
switch (typeof val) {
case 'string':
return cdsTypes.String
case 'boolean':
return cdsTypes.Boolean
case 'number':
return Number.isSafeInteger(val) ? cdsTypes.Integer : cdsTypes.Decimal
default:
return {}
}
}
/** returns the CSN definition for the given name from the model */
function getDefinition(name) {
if (!name) return null
return model.definitions[name]
}
function getDefinitionFromSources(sources, id) {
return sources[id].definition
}
/**
* Returns the csn path as string for a given column ref with sibling $refLinks
*
* @param {object} arg
* @returns {string}
*/
function getFullPathForLinkedArg(arg) {
let firstStepIsEntity = false
return arg.$refLinks.reduce((res, cur, i) => {
if (cur.definition.kind === 'entity') {