diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 075e93294..69cb6a14e 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -1664,7 +1664,7 @@ function cqn4sql(originalQuery, model) { transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1) let args = from.ref.at(-1).args - const subquerySource = getDefinition(transformedFrom.$refLinks[0].definition.target) || transformedFrom.$refLinks[0].target + const subquerySource = transformedFrom.$refLinks[0].target if (subquerySource.params && !args) args = {} const id = localized(subquerySource) transformedFrom.ref = [args ? { id, args } : id] diff --git a/db-service/lib/infer/index.js b/db-service/lib/infer/index.js index b2807f859..124e99ff3 100644 --- a/db-service/lib/infer/index.js +++ b/db-service/lib/infer/index.js @@ -40,7 +40,6 @@ function infer(originalQuery, model) { // cache for already processed calculated elements const alreadySeenCalcElements = new Set() - let $combinedElements const sources = inferTarget(_.from || _.into || _.entity, {}) const joinTree = new JoinTree(sources) @@ -51,7 +50,6 @@ function infer(originalQuery, model) { target: { value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery, writable: true, - configurable: true, }, // REVISIT: legacy? }) // also enrich original query -> writable because it may be inferred again @@ -60,25 +58,24 @@ function infer(originalQuery, model) { target: { value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery, writable: true, - configurable: true, }, }) if (originalQuery.SELECT || originalQuery.DELETE || originalQuery.UPDATE) { - $combinedElements = inferCombinedElements() + const $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() + const elements = inferQueryElements($combinedElements) 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 + $combinedElements: { value: $combinedElements, writable: true }, + elements: { value: elements, writable: true }, + joinTree: { value: joinTree, writable: true }, // REVISIT: eliminate }) // also enrich original query -> writable because it may be inferred again - Object.defineProperty(originalQuery, 'elements', { value: elements, writable: true, configurable: true }) + Object.defineProperty(originalQuery, 'elements', { value: elements, writable: true }) } return inferred @@ -114,7 +111,7 @@ function infer(originalQuery, model) { if (target.kind !== 'entity' && !target.isAssociation) throw new Error('Query source must be a an entity or an association') - inferArgument(from, null, null, { inFrom: true }) + attachRefLinksToArg(from) // REVISIT: remove const alias = from.uniqueSubqueryAlias || from.as || @@ -132,6 +129,7 @@ function infer(originalQuery, model) { 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[/([^.]*)$/.exec(from)[0]] = { definition } } else if (from.SET) { @@ -140,6 +138,107 @@ function infer(originalQuery, model) { return querySources } + // REVISIT: this helper is doing by far too much, with too many side effects + + /** + * This function recursively traverses through all 'ref' steps of the 'arg' object and enriches it by attaching + * additional information. For each 'ref' step, it adds the corresponding definition and the target in which the + * next 'ref' step should be looked up. + * + * + * @param {object} arg - The argument object that will be augmented with additional properties. + * It must contain a 'ref' property, which is an array representing the steps to be processed. + * Optionally, it can also contain an 'xpr' property, which is also processed recursively. + * + * @param {object} $baseLink - Optional parameter. It represents the environment in which the first 'ref' step should be + * resolved. It's needed for infix filter / expand columns. It must contain a 'definition' + * property, which is an object representing the base environment. + * + * @param {boolean} expandOrExists - Optional parameter, defaults to false. It indicates whether the 'arg' is part of a + * 'column.expand' or preceded by an 'exists'. When true, unmanaged association paths + * are allowed -> $baseLink is an `expand` or `assoc` preceded by `exists`. + * + * @throws Will throw an error if a 'ref' step cannot be found in the current environment or if a 'ref' step + * represents an unmanaged association in the case of infix filters and 'expandOrExists' is false. + * + * @returns {void} This function does not return a value; it mutates the 'arg' object directly. + */ + function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) { + const { ref, xpr, args, list } = arg + if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists)) + if (args) applyToFunctionArgs(args, attachRefLinksToArg, [$baseLink, expandOrExists]) + if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists)) + if (!ref) return + init$refLinks(arg) + ref.forEach((step, i) => { + const id = step.id || step + if (i === 0) { + // infix filter never have table alias + // we need to search for first step in ´model.definitions[infixAlias]` + if ($baseLink) { + const { definition } = $baseLink + const elements = getDefinition(definition.target)?.elements || definition.elements + const e = elements?.[id] || cds.error`"${id}" not found in the elements of "${definition.name}"` + if (e.target) { + // only fk access in infix filter + const nextStep = ref[1]?.id || ref[1] + // no unmanaged assoc in infix filter path + if (!expandOrExists && e.on) { + const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”` + throw new Error(err) + } + // no non-fk traversal in infix filter + if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e)) + throw new Error( + `Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`, + ) + } + arg.$refLinks.push({ definition: e, target: definition }) + // filter paths are flattened + // REVISIT: too much augmentation -> better remove flatName.. + Object.defineProperty(arg, 'flatName', { value: ref.join('_'), writable: true }) + } else { + // must be in model.definitions + const definition = getDefinition(id) || cds.error`"${id}" not found in the definitions of your model` + arg.$refLinks[0] = { definition, target: definition } + } + } else { + const recent = arg.$refLinks[i - 1] + const { elements } = getDefinition(recent.definition.target) || recent.definition + const e = elements[id] + if (!e) throw new Error(`"${id}" not found in the elements of "${arg.$refLinks[i - 1].definition.name}"`) + arg.$refLinks.push({ definition: e, target: getDefinition(e.target) || e }) + } + arg.$refLinks[i].alias = !ref[i + 1] && arg.as ? arg.as : id.split('.').pop() + + // link refs in where + if (step.where) { + // REVISIT: why do we need to walk through these so early? + if (arg.$refLinks[i].definition.kind === 'entity' || getDefinition(arg.$refLinks[i].definition.target)) { + let existsPredicate = false + const walkTokenStream = token => { + if (token === 'exists') { + // no joins for infix filters along `exists ` + existsPredicate = true + } else if (token.xpr) { + // don't miss an exists within an expression + token.xpr.forEach(walkTokenStream) + } else { + attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate) + existsPredicate = false + } + } + step.where.forEach(walkTokenStream) + } else throw new Error('A filter can only be provided when navigating along associations') + } + }) + const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1] + if (definition.value) { + // nested calculated element + attachRefLinksToArg(definition.value, { definition: definition.parent, target }, true) + } + } + /** * Calculates the `$combinedElements` based on the provided queries `sources`. * The `$combinedElements` of a query consist of all accessible elements across all @@ -190,11 +289,11 @@ function infer(originalQuery, model) { * 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() { + function inferQueryElements($combinedElements) { let queryElements = {} const { columns, where, groupBy, having, orderBy } = _ if (!columns) { - inferElementsFromWildCard(queryElements) + inferElementsFromWildCard(aliases) } else { let wildcardSelect = false const dollarSelfRefs = [] @@ -206,9 +305,13 @@ function infer(originalQuery, model) { 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) - } else if (col.func) { - applyToFunctionArgs(col.args, inferArgument, [queryElements, null, { inExpr: true }]) + queryElements[as] = getElementForXprOrSubquery(col) + } + if (col.func) { + if (col.args) { + // {func}.args are optional + applyToFunctionArgs(col.args, inferQueryElement, [false]) + } queryElements[as] = getElementForCast(col) } if (!queryElements[as]) { @@ -225,17 +328,17 @@ function infer(originalQuery, model) { !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, queryElements) + else handleRef(col) } else if (col.expand) { - inferArgument(col, queryElements, null, { queryElements }) + inferQueryElement(col) } else { cds.error`Not supported: ${JSON.stringify(col)}` } }) - if (dollarSelfRefs.length) inferDollarSelfRefs(dollarSelfRefs, queryElements) + if (dollarSelfRefs.length) inferDollarSelfRefs(dollarSelfRefs) - if (wildcardSelect) inferElementsFromWildCard(queryElements) + if (wildcardSelect) inferElementsFromWildCard(aliases) } if (orderBy) { // link $refLinks -> special name resolution rules for orderBy @@ -263,7 +366,7 @@ function infer(originalQuery, model) { $baseLink = null } - inferArgument(token, queryElements, $baseLink, { inQueryModifier: true }) + inferQueryElement(token, false, $baseLink) if (token.isJoinRelevant && rejectJoinRelevantPath) { // reverse the array, find the last association and calculate the index of the association in non-reversed order const assocIndex = @@ -277,12 +380,12 @@ function infer(originalQuery, model) { } // walk over all paths in other query properties - if (where) walkTokenStream(where, true) - if (groupBy) walkTokenStream(groupBy) + if (where) walkTokenStream(where) + if (groupBy) groupBy.forEach(token => inferQueryElement(token, false)) if (having) walkTokenStream(having) if (_.with) // consider UPDATE.with - Object.values(_.with).forEach(val => inferArgument(val, queryElements, null, { inExpr: true })) + Object.values(_.with).forEach(val => inferQueryElement(val, false)) return queryElements @@ -294,7 +397,7 @@ function infer(originalQuery, model) { * * @param {array} tokenStream */ - function walkTokenStream(tokenStream, inExpr) { + function walkTokenStream(tokenStream) { let skipJoins const processToken = t => { if (t === 'exists') { @@ -304,777 +407,727 @@ function infer(originalQuery, model) { // don't miss an exists within an expression t.xpr.forEach(processToken) } else { - inferArgument(t, queryElements, null, { inExists: skipJoins, inQueryModifier: true, inExpr }) + inferQueryElement(t, false, null, { inExists: skipJoins, inExpr: 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, queryElements) { - do { - const unprocessedColumns = [] - - for (const currentDollarSelfColumn of dollarSelfColumns) { - const { ref } = currentDollarSelfColumn - const stepToFind = ref[1] - - const referencesOtherDollarSelfColumn = dollarSelfColumns.find( - otherDollarSelfCol => - otherDollarSelfCol !== currentDollarSelfColumn && - (otherDollarSelfCol.as - ? stepToFind === otherDollarSelfCol.as - : stepToFind === otherDollarSelfCol.ref?.[otherDollarSelfCol.ref.length - 1]), - ) + /** + * 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 } = currentDollarSelfColumn + const stepToFind = ref[1] + + const referencesOtherDollarSelfColumn = dollarSelfColumns.find( + otherDollarSelfCol => + otherDollarSelfCol !== currentDollarSelfColumn && + (otherDollarSelfCol.as + ? stepToFind === otherDollarSelfCol.as + : stepToFind === otherDollarSelfCol.ref?.[otherDollarSelfCol.ref.length - 1]), + ) - if (referencesOtherDollarSelfColumn) { - unprocessedColumns.push(currentDollarSelfColumn) - } else { - handleRef(currentDollarSelfColumn, queryElements) + if (referencesOtherDollarSelfColumn) { + unprocessedColumns.push(currentDollarSelfColumn) + } else { + handleRef(currentDollarSelfColumn) + } } - } - dollarSelfColumns = unprocessedColumns - } while (dollarSelfColumns.length > 0) - } - - function handleRef(col, queryElements) { - inferArgument(col, queryElements) - 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) - } - - /** - * Recursively infers and resolves references within the given argument. - * This function handles complex structures such as nested functions, list processing, - * and nested projections (expand, inline) based on the context provided. It links column references to their - * definitions in the model, handles special cases like pseudo paths and unresolvable steps, - * and inserts elements corresponding to the columns into the query elements object when required. - * - * @param {object} argument - The object that is being processed. This object may include - * various properties like `ref`, `args`, `list`, `xpr`, and others. - * @param {object|null} queryElements - The current state of query elements being constructed - * or modified. Null if no modifications are needed, e.g. for infix filters. - * @param {object|null} $baseLink - The base link context for resolving references, may be null. - * @param {object} context - Additional context for processing, including flags and settings like - * `inExists`, `inExpr`, `inCalcElement`, etc., which influence how - * references are resolved and processed. - * - * The function processes different aspects of the argument object: - * - Handles parameter references which are resolved at execution time. - * - Recursively processes `args`, `list`, and `xpr` properties to handle nested structures. - * - Manages the inclusion or exclusion of elements based on the persistence settings - * and the context in which they are found (e.g., inside filters or modifiers). - * - Constructs links for references (`$refLinks`) to their definitions in the model, handling - * various complexities such as pseudo paths, unresolvable steps, and ambiguous references. - * - Integrates with the overall query processing by modifying the `queryElements` object - * based on the inference results. - * - * @throws {Error} If an element cannot be resolved or if an invalid operation is attempted - * on a path, such as filtering on a non-association type. - */ - function inferArgument(argument, queryElements = null, $baseLink = null, context = {}) { - const { - inExists, - inExpr, - inCalcElement, - inInfixFilter, - inFrom, - inQueryModifier, // orderBy, groupBy, having - baseColumn, // inline, expand, calculated elements - } = context - if (argument.param || argument.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ? - if (argument.args) applyToFunctionArgs(argument.args, inferArgument, [null, $baseLink, context]) // e.g. function in expression - if (argument.list) argument.list.forEach(arg => inferArgument(arg, null, $baseLink, context)) - if (argument.xpr) - argument.xpr.forEach(token => inferArgument(token, queryElements, $baseLink, { ...context, inExpr: true })) // e.g. function in expression - - if (!argument.ref) { - if (argument.expand && queryElements) queryElements[argument.as] = resolveExpand(argument) - return + dollarSelfColumns = unprocessedColumns + } while (dollarSelfColumns.length > 0) } - // initialize $refLinks - Object.defineProperty(argument, '$refLinks', { - value: [], - writable: true, - }) - let isPersisted = true - let firstStepIsTableAlias, firstStepIsSelf, expandOnTableAlias - if (!inFrom) { - firstStepIsTableAlias = argument.ref.length > 1 && sources && argument.ref[0] in sources - firstStepIsSelf = - !firstStepIsTableAlias && argument.ref.length > 1 && ['$self', '$projection'].includes(argument.ref[0]) - expandOnTableAlias = - argument.ref.length === 1 && sources && argument.ref[0] in sources && (argument.expand || argument.inline) + function handleRef(col) { + inferQueryElement(col) + 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) } - // if any path step points to an artifact with `@cds.persistence.skip` - // we must ignore the element from the queries elements - 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 - argument.ref.forEach((step, i) => { - const id = step.id || step - if (i === 0) { - if (id in pseudos.elements) { - // pseudo path - argument.$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] - rejectNonFkAccess(element) - const resolvableIn = getDefinition(definition.target) || target - argument.$refLinks.push({ definition: elements[id], target: resolvableIn }) + + /** + * 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} column - The column object that contains the properties to infer a query element. + * @param {boolean} [insertIntoQueryElements=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 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 ` paths we do not need to create joins for path expressions as they are part of the semi-joined subquery. + * @param {boolean} [context.inExpr=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 inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, context) { + const { inExists, inExpr, inCalcElement, baseColumn, inInfixFilter } = context || {} + if (column.param || column.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ? + if (column.args) { + applyToFunctionArgs(column.args, inferQueryElement, [false, $baseLink, context]) + } + if (column.list) column.list.forEach(arg => inferQueryElement(arg, false, $baseLink, context)) + if (column.xpr) + column.xpr.forEach(token => inferQueryElement(token, false, $baseLink, { ...context, inExpr: true })) // e.g. function in expression + + if (!column.ref) { + if (column.expand) queryElements[column.as] = resolveExpand(column) + return + } + + init$refLinks(column) + // if any path step points to an artifact with `@cds.persistence.skip` + // we must ignore the element from the queries elements + let isPersisted = true + const firstStepIsTableAlias = column.ref.length > 1 && column.ref[0] in sources + const firstStepIsSelf = + !firstStepIsTableAlias && column.ref.length > 1 && ['$self', '$projection'].includes(column.ref[0]) + const expandOnTableAlias = column.ref.length === 1 && column.ref[0] in sources && (column.expand || column.inline) + 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 + column.ref.forEach((step, i) => { + const id = step.id || step + if (i === 0) { + if (id in pseudos.elements) { + // pseudo path + column.$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] + rejectNonFkAccess(element) + const resolvableIn = getDefinition(definition.target) || target + column.$refLinks.push({ definition: elements[id], target: resolvableIn }) + } else { + stepNotFoundInPredecessor(id, definition.name) + } + nameSegments.push(id) + } else if (firstStepIsTableAlias) { + column.$refLinks.push({ + definition: getDefinitionFromSources(sources, id), + target: getDefinitionFromSources(sources, id), + }) + } else if (firstStepIsSelf) { + column.$refLinks.push({ definition: { elements: queryElements }, target: { elements: queryElements } }) + } else if (column.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) + column.$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 } + column.$refLinks.push($refLink) + nameSegments.push(id) + } else if (expandOnTableAlias) { + // expand on table alias + column.$refLinks.push({ + definition: getDefinitionFromSources(sources, id), + target: getDefinitionFromSources(sources, id), + }) } else { - stepNotFoundInPredecessor(id, definition.name) + stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements) } - nameSegments.push(id) - } else if (inFrom) { - const definition = getDefinition(id) || cds.error`"${id}" not found in the definitions of your model` - argument.$refLinks.push({ definition, target: definition }) - } else if (firstStepIsTableAlias) { - argument.$refLinks.push({ - definition: getDefinitionFromSources(sources, id), - target: getDefinitionFromSources(sources, id), - }) - } else if (firstStepIsSelf) { - argument.$refLinks.push({ definition: { elements: queryElements }, target: { elements: queryElements } }) - } else if (argument.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) - argument.$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 } - argument.$refLinks.push($refLink) - nameSegments.push(id) - } else if (expandOnTableAlias) { - // expand on table alias - argument.$refLinks.push({ - definition: getDefinitionFromSources(sources, id), - target: getDefinitionFromSources(sources, id), - }) } else { - stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements) - } - } else { - const { definition } = argument.$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] + const { definition } = column.$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: [ ${argument.ref - .map(idOnly) - .join(', ')} ]`, - ) - } + if (firstStepIsSelf && element?.isAssociation) { + throw new Error( + `Paths starting with “$self” must not contain steps of type “cds.Association”: ref: [ ${column.ref + .map(idOnly) + .join(', ')} ]`, + ) + } - const target = getDefinition(definition.target) || argument.$refLinks[i - 1].target - if (element) { - if ($baseLink) rejectNonFkAccess(element) - const e = elements[id] - const $refLink = { definition: e, target } - argument.$refLinks.push($refLink) - } else if (firstStepIsSelf) { - stepNotFoundInColumnList(id) - } else if (argument.ref[0] === '$user' && pseudoPath) { - // `$user.some.unknown.element` -> no error - argument.$refLinks.push({ definition: {}, target }) - } else if (id === '$dummy') { - // `some.known.element.$dummy` -> no error; used by cds.ql to simulate joins - argument.$refLinks.push({ definition: { name: '$dummy', parent: argument.$refLinks[i - 1].target } }) - Object.defineProperty(argument, 'isJoinRelevant', { value: true }) - } else { - const notFoundIn = pseudoPath ? argument.ref[i - 1] : getFullPathForLinkedArg(argument) - stepNotFoundInPredecessor(id, notFoundIn) + const target = getDefinition(definition.target) || column.$refLinks[i - 1].target + if (element) { + if ($baseLink) rejectNonFkAccess(element) + const $refLink = { definition: elements[id], target } + column.$refLinks.push($refLink) + } else if (firstStepIsSelf) { + stepNotFoundInColumnList(id) + } else if (column.ref[0] === '$user' && pseudoPath) { + // `$user.some.unknown.element` -> no error + column.$refLinks.push({ definition: {}, target }) + } else if (id === '$dummy') { + // `some.known.element.$dummy` -> no error; used by cds.ql to simulate joins + column.$refLinks.push({ definition: { name: '$dummy', parent: column.$refLinks[i - 1].target } }) + Object.defineProperty(column, 'isJoinRelevant', { value: true }) + } else { + const notFoundIn = pseudoPath ? column.ref[i - 1] : getFullPathForLinkedArg(column) + stepNotFoundInPredecessor(id, notFoundIn) + } + const foreignKeyAlias = Array.isArray(definition.keys) + ? definition.keys.find(k => { + if (k.ref.every((step, j) => column.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) + } } - const foreignKeyAlias = Array.isArray(definition.keys) - ? definition.keys.find(k => { - if (k.ref.every((step, j) => argument.ref[i + j] === step)) { - skipAliasedFkSegmentsOfNameStack.push(...k.ref.slice(1)) - return true + + if (step.where) { + const danglingFilter = !(column.ref[i + 1] || column.expand || column.inline || inExists) + if (!column.$refLinks[i].definition.target || danglingFilter) + throw new Error('A filter can only be provided when navigating along associations') + if (!column.expand) Object.defineProperty(column, '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) { + inferQueryElement(token, false, column.$refLinks[i], { + inExists: skipJoinsForFilter, + inExpr: !!token.xpr, + inInfixFilter: true, + }) + } else if (token.func) { + if (token.args) { + applyToFunctionArgs(token.args, inferQueryElement, [ + false, + column.$refLinks[i], + { inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: 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) { - // Checks if there is a dangling filter at the end of a path. - // A dangling filter is not followed by another reference, expansion, or inline and is not within an EXISTS clause. - const hasDanglingFilter = !(argument.ref[i + 1] || argument.expand || argument.inline || inExists) - - const definition = argument.$refLinks[i].definition - // Ensures that a filter is valid only under specific conditions: - // 1. The filter must be on associations, except when it is directly on an entity specified in the from.ref clause, like `SELECT from Books[42]`. - // 2. A dangling filter is invalid unless it is used in the from.ref, like `SELECT from Books:author[name = 'foo']` - if ((!definition.target && definition.kind !== 'entity') || (!inFrom && hasDanglingFilter)) { - throw new Error('A filter can only be provided when navigating along associations') + column.$refLinks[i].alias = !column.ref[i + 1] && column.as ? column.as : id.split('.').pop() + if (getDefinition(column.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true) + isPersisted = false + if (!column.ref[i + 1]) { + const flatName = nameSegments.join('_') + Object.defineProperty(column, 'flatName', { value: flatName, writable: true }) + // if column is casted, we overwrite it's origin with the new type + if (column.cast) { + const base = getElementForCast(column) + if (insertIntoQueryElements) queryElements[column.as || flatName] = getCopyWithAnnos(column, base) + } else if (column.expand) { + const elements = resolveExpand(column) + let elementName + // expand on table alias + if (column.$refLinks.length === 1 && column.$refLinks[0].definition.kind === 'entity') + elementName = column.$refLinks[0].alias + else elementName = column.as || flatName + if (insertIntoQueryElements) queryElements[elementName] = elements + } else if (column.inline && insertIntoQueryElements) { + const elements = resolveInline(column) + queryElements = { ...queryElements, ...elements } + } else { + // shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']` + const leafArt = + i === 0 && id === '$user' ? column.$refLinks[i].definition.elements.id : column.$refLinks[i].definition + // infer element based on leaf artifact of path + if (insertIntoQueryElements) { + let elementName + if (column.as) { + elementName = column.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 = column.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(column, leafArt) + queryElements[elementName] = element + } + } } - if (!argument.expand && !inFrom) Object.defineProperty(argument, '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.func) { - applyToFunctionArgs(token.args, inferArgument, [ - false, - argument.$refLinks[i], - { - inExists: skipJoinsForFilter, - inInfixFilter: true, - inFrom, - }, - ]) - } else if (typeof token !== 'string') { - // xpr, ref, val - inferArgument(token, false, argument.$refLinks[i], { - inExists: skipJoinsForFilter, - inInfixFilter: true, - inFrom, - }) + /** + * Check if the next step in the ref is foreign key of `assoc` + * if not, an error is thrown. + * + * @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element. + */ + function rejectNonFkAccess(assoc) { + if (inInfixFilter && assoc.target) { + // only fk access in infix filter + const nextStep = column.ref[i + 1]?.id || column.ref[i + 1] + // no unmanaged assoc in infix filter path + if (!inExists && assoc.on) { + const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”` + throw new Error(err) + } + // no non-fk traversal in infix filter in non-exists path + if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc)) + throw new Error( + `Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`, + ) } - }) + } + }) + + // ignore whole expand if target of assoc along path has ”@cds.persistence.skip” + if (column.expand) { + const { $refLinks } = column + const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true) + if (skip) { + $refLinks[$refLinks.length - 1].skipExpand = true + return + } + } + const leafArt = column.$refLinks[column.$refLinks.length - 1].definition + const virtual = (leafArt.virtual || !isPersisted) && !inExpr + // check if we need to merge the column `ref` into the join tree of the query + if (!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, ...column.ref], $refLinks: [...baseColumn.$refLinks, ...column.$refLinks] } + : column + if (isColumnJoinRelevant(colWithBase)) { + Object.defineProperty(column, 'isJoinRelevant', { value: true }) + joinTree.mergeColumn(colWithBase, originalQuery.outerQueries) + } + } + if (leafArt.value && !leafArt.value.stored) { + linkCalculatedElement(column, $baseLink, baseColumn) } - argument.$refLinks[i].alias = !argument.ref[i + 1] && argument.as ? argument.as : id.split('.').pop() - if (getDefinition(argument.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true) - isPersisted = false - if (!argument.ref[i + 1]) { - const flatName = nameSegments.join('_') - Object.defineProperty(argument, 'flatName', { value: flatName, writable: true }) - // if column is casted, we overwrite it's origin with the new type - if (argument.cast) { - const base = getElementForCast(argument) - if (insertIntoQueryElements()) queryElements[argument.as || flatName] = getCopyWithAnnos(argument, base) - } else if (argument.expand) { - const elements = resolveExpand(argument) - let elementName - // expand on table alias - if (argument.$refLinks.length === 1 && argument.$refLinks[0].definition.kind === 'entity') - elementName = argument.$refLinks[0].alias - else elementName = argument.as || flatName - if (queryElements) queryElements[elementName] = elements - } else if (argument.inline && queryElements) { - const elements = resolveInline(argument) - Object.assign(queryElements, elements) - } else { - // shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']` - const leafArt = - i === 0 && id === '$user' ? argument.$refLinks[i].definition.elements.id : argument.$refLinks[i].definition - // infer element based on leaf artifact of path - if (insertIntoQueryElements()) { - let elementName - if (argument.as) { - elementName = argument.as + /** + * 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 => { + inferQueryElement(inlineCol, false, $leafLink, { inExpr: 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 { - // 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 = argument.ref - .slice(firstStepIsSelf || firstStepIsTableAlias ? 1 : 0) - .map(idOnly) - .join('_') - if (refNavigation !== flatName) elementName = refNavigation - else elementName = flatName + elements[name] = inlineCol.$refLinks[inlineCol.$refLinks.length - 1].definition } - if (queryElements[elementName] !== undefined) - throw new Error(`Duplicate definition of element “${elementName}”`) - const element = getCopyWithAnnos(argument, leafArt) - queryElements[elementName] = element } - } - } - - function insertIntoQueryElements() { - return queryElements && !inExpr && !inInfixFilter && !inQueryModifier + }) + return elements } /** - * Check if the next step in the ref is foreign key of `assoc` - * if not, an error is thrown. + * Resolves a query column which has an `expand` property. * - * @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element. + * @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 rejectNonFkAccess(assoc) { - if (inInfixFilter && assoc.target) { - // only fk access in infix filter - const nextStep = argument.ref[i + 1]?.id || argument.ref[i + 1] - // no unmanaged assoc in infix filter path - if (!inExists && assoc.on) { - const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”` - throw new Error(err) + 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), + }, } - // no non-fk traversal in infix filter in non-exists path - if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc)) - throw new Error( - `Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`, - ) + 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 { + inferQueryElement(e, false, $leafLink, { inExpr: 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 }) } } - }) - // ignore whole expand if target of assoc along path has ”@cds.persistence.skip” - if (argument.expand) { - const { $refLinks } = argument - const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true) - if (skip) { - $refLinks[$refLinks.length - 1].skipExpand = true - return + function stepNotFoundInPredecessor(step, def) { + throw new Error(`"${step}" not found in "${def}"`) } - } - const leafArt = argument.$refLinks[argument.$refLinks.length - 1].definition - const virtual = (leafArt.virtual || !isPersisted) && !inExpr - // 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, ...argument.ref], $refLinks: [...baseColumn.$refLinks, ...argument.$refLinks] } - : argument - if (isColumnJoinRelevant(colWithBase)) { - Object.defineProperty(argument, 'isJoinRelevant', { value: true }) - joinTree.mergeColumn(colWithBase, originalQuery.outerQueries) - } - } - if (leafArt.value && !leafArt.value.stored) { - linkCalculatedElement(argument, $baseLink, baseColumn, context) - } - /** - * 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) { + function stepIsAmbiguous(step) { throw new Error( - `Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`, + `ambiguous reference to "${step}", write ${Object.values($combinedElements[step]) + .map(ta => `"${ta.index}.${step}"`) + .join(', ')} instead`, ) } - let elements = {} - inline.forEach(inlineCol => { - inferArgument(inlineCol, false, $leafLink, { inExpr: 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) { + function stepNotFoundInCombinedElements(step) { throw new Error( - `Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`, + `"${step}" not found in the elements of ${Object.values(sources) + .map(s => s.definition) + .map(def => `"${def.name || /* subquery */ def.as}"`) + .join(', ')}`, ) } - 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 { - inferArgument(e, false, $leafLink, { inExpr: true, ...context }) - 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 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) { + 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 = baseLink || { definition: calcElement.parent, target: calcElement.parent } + attachRefLinksToArg(calcElement.value, baseLink, true) + 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) + } - 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(', ')}`, - ) - } + if (calcElement.value.args) { + const processArgument = (arg, calcElement, column) => { + inferQueryElement( + arg, + false, + { 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) + } - 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(',')) - } - } - /** - * Processes and links a calculated element. This function recursively identifies - * calculated elements within a column, ensures they are not processed multiple times, and handles - * their integration into the query's join tree if necessary. It deals with calculated elements that - * are derived from both expressions and function calls, recursively processing any nested elements. - * - * @param {object} column - The column that contains the calculated element to be linked. - * @param {object} baseLink - The base link context used for resolving the calculated element's references. - * This may be adjusted during processing to reflect the parent of the calculated element. - * @param {object} baseColumn - Base column context that may provide additional reference paths to be merged - * into the join tree alongside the calculated element's path. - * @param {object} [context={}] - Additional processing context that may influence how calculated elements - * are processed, such as flags indicating if the current processing is within - * a calculated element. - * - * This function identifies the calculated element within the column based on its `$refLinks` or defaults - * to the column itself if no `$refLinks` are found. It then ensures this element has not been processed before, - * registers it in a cache, and proceeds to handle different scenarios: - * - If the element is an expression or reference, it recursively infers arguments within this context, - * merging any necessary paths into the queries join tree. - * - If the element involves a function call, it processes each argument of the function in a similar manner. - * - * Throughout its operation, it adjusts the base link context and merges paths as necessary, ensuring - * that calculated elements are correctly integrated into the query's join tree, affecting how joins are - * constructed during SQL generation. - * - * @throws {Error} If calculated elements contain errors in their path definitions or if recursive processing - * of nested elements identifies issues that cannot be resolved within the current query context. - */ - 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, func } = calcElement.value - if (ref || xpr) { - baseLink = { definition: calcElement.parent, target: calcElement.parent } - inferArgument(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) + if (calcElement.value.args) { + applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column]) + } } - mergePathsIntoJoinTree(calcElement.value, basePath) - } - function processArgument(arg, calcElement, column) { - inferArgument(arg, false, { 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) { - if (Array.isArray(calcElement.value.args)) { - calcElement.value.args.forEach(arg => processArgument(arg, calcElement, column)) - } else if (typeof calcElement.value.args === 'object') { - Object.values(calcElement.value.args).forEach((v) => processArgument(v, 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 => { + const 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 => { + mergePathsIntoJoinTree(a, subPath) + }) + } + }) + } + + function mergePathIfNecessary(p, step) { + const calcElementIsJoinRelevant = isColumnJoinRelevant(p) + if (calcElementIsJoinRelevant) { + if (!calcElement.value.isColumnJoinRelevant) + 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 }) + } + } } } /** - * Calculates all paths from a given ref and merges them into the join tree. - * Recursively walks into refs of calculated elements. + * 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} arg with a ref and sibling $refLinks - * @param {object} basePath with a ref and sibling $refLinks, used for recursion + * @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 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 => { - const 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 => { - mergePathsIntoJoinTree(a, subPath) - }) + 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 } - }) - } - - 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 }) + return 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 + if (assoc && assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) { + // foreign key access without filters never join relevant return false } - return true - } - if (assoc && assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) { - // foreign key access without filters never join relevant - return false - } - 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 (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 - Object.keys(elements) - .filter(k => !exclude(k)) - .forEach(k => { - const element = elements[k] - if (element.type !== 'cds.LargeBinary') queryElements[k] = element - if (element.value) { - linkCalculatedElement(element) - } - }) - return + if (!assoc) return false + if (fkAccess) return false + return true } - 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 (element.value) { - linkCalculatedElement(element) + /** + * 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() { + 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 + Object.keys(elements) + .filter(k => !exclude(k)) + .forEach(k => { + const element = elements[k] + if (element.type !== 'cds.LargeBinary') queryElements[k] = element + if (element.value) { + linkCalculatedElement(element) + } + }) + return } - }) - 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(', ')}`, - ) + 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 (element.value) { + linkCalculatedElement(element) + } }) - 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) { - const { xpr } = col - let skipJoins = false - xpr?.forEach(token => { - if (token === 'exists') { - // no joins for infix filters along `exists ` - skipJoins = true - } else { - inferArgument(token, queryElements, null, { inExists: skipJoins, inExpr: true }) - skipJoins = false + 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')) } - }) - 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 + /** + * 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) { + const { xpr } = col + let skipJoins = false + xpr?.forEach(token => { + if (token === 'exists') { + // no joins for infix filters along `exists ` + skipJoins = true + } else { + inferQueryElement(token, false, null, { inExists: skipJoins, inExpr: true }) + 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 + } } /** @@ -1099,6 +1152,14 @@ function infer(originalQuery, model) { return Object.setPrototypeOf(result, base) } + // REVISIT: functions without return are by nature side-effect functions -> bad + function init$refLinks(arg) { + Object.defineProperty(arg, '$refLinks', { + value: [], + writable: true, + }) + } + function getCdsTypeForVal(val) { // REVISIT: JS null should have a type for proper DB layer conversion logic // if(val === null) return {type:'cds.String'} diff --git a/db-service/test/cqn4sql/path-in-from.test.js b/db-service/test/cqn4sql/path-in-from.test.js index ad7de4b63..14cffb0c5 100644 --- a/db-service/test/cqn4sql/path-in-from.test.js +++ b/db-service/test/cqn4sql/path-in-from.test.js @@ -29,7 +29,7 @@ describe('infix filter on entities', () => { it('fails when using table alias in infix filter at entity', () => { expect(() => cqn4sql(CQL`SELECT from bookshop.Books[Books.price < 12.13] {ID}`, model)).to.throw( - /"Books" not found in "bookshop.Books"/, + /"Books" not found in the elements of "bookshop.Books"/, ) })