UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

963 lines (799 loc) 32.8 kB
const cds = require('../../../lib') const { keysOf, addRefToWhereIfNecessary } = require('../utils') const { where2obj, resolveFromSelect, targetFromPath } = require('../../_runtime/common/utils/cqn') const { findCsnTargetFor } = require('../../_runtime/common/utils/csn') const normalizeTimestamp = require('../../_runtime/common/utils/normalizeTimestamp') const { rewriteExpandAsterisk } = require('../../_runtime/common/utils/rewriteAsterisks') const resolveStructured = require('../../_runtime/common/utils/resolveStructured') // Same regex as peggy parser const RELAXED_UUID_REGEX = /^[0-9a-z]{8}-?[0-9a-z]{4}-?[0-9a-z]{4}-?[0-9a-z]{4}-?[0-9a-z]{12}$/i function _getDefinition(definition, name, namespace) { return ( definition?.definitions?.[name] || definition?.elements?.[name] || (definition.actions && (definition.actions[name] || definition.actions[name.replace(namespace + '.', '')])) ) } function _resolveAliasesInRef(ref, target) { if (ref.length === 1) { if (target.keys?.[ref[0]]) return ref // resolve multi-part refs for innermost ref in url if (target._flattenedKeys === undefined) { const flattenedKeys = [] for (const key in target.keys) { if (!target.keys[key].elements) continue flattenedKeys.push(...resolveStructured({ element: target.keys[key], structProperties: [] }, false, true)) } target._flattenedKeys = flattenedKeys.length ? flattenedKeys : null } const fk = target._flattenedKeys?.find(fk => fk.key === ref[0]) if (fk) return [...fk.resolved] } for (const seg of ref) { target = target.elements[seg.id || seg] if (!target) return ref if (target.isAssociation) { target = target._target if (seg.where) _resolveAliasesInXpr(seg.where, target) } } return ref } function _resolveAliasesInXpr(xpr, target) { if (!target || !xpr) return for (const el of xpr) { if (el.xpr) _resolveAliasesInXpr(el.xpr, target) if (el.args) _resolveAliasesInXpr(el.args, target) if (el.ref) el.ref = _resolveAliasesInRef(el.ref, target) } } function _resolveAliasesInNavigation(cqn, target) { if (!target || !cqn) return if (cqn.SELECT.from.SELECT) _resolveAliasesInNavigation(cqn.SELECT.from, target) if (cqn.SELECT.where) _resolveAliasesInXpr(cqn.SELECT.where, target) if (cqn.SELECT.having) _resolveAliasesInXpr(cqn.SELECT.having, target) } function _addDefaultParams(ref, view) { const params = view.params const defaults = params && Object.values(params).filter(p => p.default) if (defaults && defaults.length > 0) { if (!ref.where) ref.where = [] for (const def of defaults) { if (ref.where.find(e => e.ref && e.ref[0] === def.name)) continue if (ref.where.length > 0) ref.where.push('and') ref.where.push({ ref: [def.name] }, '=', { val: def.default.val }) } } } function getResolvedElement(entity, { ref }) { const element = entity.elements[ref[0]] if (element && element.isAssociation && ref.length > 1) { return getResolvedElement(element._target, { ref: ref.slice(1) }) } if (element && element._isStructured) { return getResolvedElement(element, { ref: ref.slice(1) }) } return element } const forbidden = { '(': 1, and: 1, or: 1, not: 1, ')': 1 } function _processWhere(where, entity) { for (let i = 0; i < where.length; i++) { const ref = where[i] const operator = where[i + 1] const val = where[i + 2] if (ref in forbidden || val in forbidden || ref.func) continue if (ref.xpr) { _processWhere(ref.xpr, entity) continue } // xpr check needs to be done first, else it could happen, that we ignore xpr OR xpr if (operator in forbidden) continue let valIndex = -1 let refIndex = -1 if (typeof val === 'object') { if (val.val !== undefined) valIndex = i + 2 if (val.ref != undefined) refIndex = i + 2 } if (typeof ref === 'object') { if (ref.val !== undefined) valIndex = i if (ref.ref != undefined) refIndex = i } // no need to check ref = ref or val = val, if no ref or no val exists we can't do anything if (valIndex === refIndex || valIndex === -1 || refIndex == -1) continue const realRef = where[refIndex] const element = getResolvedElement(entity, realRef) if (element) { i += 2 where[valIndex].val = _convertVal(where[valIndex].val, element) } } } function _convertVal(value, element) { if (value === null) return value switch (element._type) { // numbers case 'cds.UInt8': case 'cds.Integer': case 'cds.Int16': case 'cds.Int32': if (!/^-?\+?\d+$/.test(value)) { const msg = `Element "${element.name}" does not contain a valid Integer` throw Object.assign(new Error(msg), { statusCode: 400 }) } // eslint-disable-next-line no-case-declarations const n = Number(value) if (!Number.isSafeInteger(n)) { const msg = `Element "${element.name}" does not contain a valid Integer` throw Object.assign(new Error(msg), { statusCode: 400 }) } if (element._type === 'cds.UInt8' && n < 0) { const msg = `Element "${element.name}" does not contain a valid positive Integer` throw Object.assign(new Error(msg), { statusCode: 400 }) } return n case 'cds.Double': return parseFloat(value) case 'cds.Decimal': case 'cds.DecimalFloat': case 'cds.Int64': case 'cds.Integer64': if (typeof value === 'string') return value return String(value) // others case 'cds.String': case 'cds.LargeString': return String(value) case 'cds.Boolean': return typeof value === 'string' ? value === 'true' : value case 'cds.Timestamp': return normalizeTimestamp(value) case 'cds.UUID': if (!RELAXED_UUID_REGEX.test(value)) { const msg = `Element "${element.name}" does not contain a valid UUID` throw Object.assign(new Error(msg), { statusCode: 400 }) } return value default: return value } } const getStructRef = (element, ref = []) => { if (element.kind === 'element') { if (element.parent.kind === 'element') { getStructRef(element.parent, ref) ref.push(element.name) } if (element.parent.kind === 'entity') { ref.push(element.name) } } return ref } const getStructTargetName = element => { if (element.kind === 'element') { if (element.parent.kind === 'element') { return getStructTargetName(element.parent) } if (element.elements && element.parent.kind === 'entity') { return element.parent.name } } } const _getDataFromParams = (params, operation) => { try { return Object.keys(params).reduce((acc, cur) => { acc[cur] = typeof params[cur] === 'string' && (operation.params[cur]?.elements || operation.params[cur]?.items) ? JSON.parse(params[cur]) : params[cur] return acc }, {}) } catch (e) { throw Object.assign(e, { statusCode: 400, internal: e.message, message: 'Malformed parameters' }) } } function _handleCollectionBoundActions(current, ref, i, namespace, one) { let action if (current.actions) { const nextRef = (typeof ref[i + 1] === 'string' && ref[i + 1]) || ref[i + 1]?.id const shortName = nextRef && nextRef.replace(namespace + '.', '') action = shortName && current.actions[shortName] } let incompleteKeys = !(!!ref[i].where || i === ref.length - 1 || one) if (!action) return incompleteKeys const onCollection = !!( action['@cds.odata.bindingparameter.collection'] || (action?.params && [...action.params].some(p => p?.items?.type === '$self')) ) if (onCollection && one) { const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${ action.name }" must be called on a collection of ${current.name}` throw Object.assign(new Error(msg), { statusCode: 400 }) } if (incompleteKeys) { if (!onCollection) { const msg = `${action.kind.at(0).toUpperCase() + action.kind.slice(1)} "${ action.name }" must be called on a single instance of ${current.name}` throw Object.assign(new Error(msg), { statusCode: 400 }) } incompleteKeys = false } return incompleteKeys } function _resolveImplicitFunctionParameters(args) { Object.entries(args).forEach(([key, value]) => { if (typeof value !== 'boolean' && !!Number(value)) { args[key] = Number(value) } else if (typeof value === 'string') { let result result = value.match(/^'(\w*)'$/)?.[1] if (result) { args[key] = result } else { result = value.match(/^binary'([^']+)'$/)?.[1] if (result) args[key] = Buffer.from(result, 'base64') } } }) } function _processSegments(from, model, namespace, cqn, protocol) { const { ref } = from let current = model, path, keys = null, keyCount = 0, incompleteKeys = false, one, target for (let i = 0; i < ref.length; i++) { const seg = ref[i].id || ref[i] const whereRef = ref[i].where let params = whereRef && where2obj(whereRef) if (incompleteKeys) { // > key // in case of odata, values for keys that are backlinks are expected to be omitted keys = keys || keysOf(current, !!protocol?.match(/odata/i)) if (!keys.length) throw new cds.error(404, `Invalid resource path "${path}"`) let key = keys[keyCount++] one = true const element = current.elements[key] let base = ref[i - keyCount] if (!base.id) base = { id: base, where: [] } if (base.where.length) base.where.push('and') if (ref[i].id) { // > fix case key value parsed to collection with filter const val = `${ref[i].id}(${Object.keys(params) .map(k => `${k}='${params[k]}'`) .join(',')})` base.where.push({ ref: [key] }, '=', { val }) } else { const val = _convertVal(seg, element) base.where.push({ ref: [key] }, '=', { val }) } ref[i] = null ref[i - keyCount] = base incompleteKeys = keyCount < keys.length } else { // > entity or property (incl. nested) or navigation or action or function keys = null keyCount = 0 one = false path = path ? path + `${path.match(/:/) ? '.' : ':'}${seg}` : seg // REVISIT: replace use case: <namespace>.<entity>_history is at <namespace>.<entity>.history current = _getDefinition(current, seg, namespace) || _getDefinition(current, seg.replace(/_/g, '.'), namespace) // REVISIT: 404 or 400? if (!current) throw new cds.error(404, `Invalid resource path "${path}"`) if (current.params && current.kind === 'entity') { // > View with params target = current if (whereRef) { keyCount += addRefToWhereIfNecessary(ref[i].where, current) _resolveAliasesInXpr(ref[i].where, current) _processWhere(ref[i].where, current) } else { // parentheses are missing const msg = `Invalid call to "${current.name}". Parentheses are missing` throw cds.error(msg, { code: '400', statusCode: 400 }) } _addDefaultParams(ref[i], current) if ((!params || !Object.keys(params).length) && ref[i].where) params = where2obj(ref[i].where) _checkAllKeysProvided(params, current) ref[i].args = {} const where = ref[i].where for (let j = 0; j < where.length; j++) { const whereElement = where[j] if (whereElement === 'and' || !whereElement.ref) continue ref[i].args[whereElement.ref[0]] = where[j + 2] j += 2 } ref[i].where = undefined if (ref[i + 1] !== 'Set') { // /Set is missing const msg = `Invalid call to "${current.name}". You need to navigate to Set` throw cds.error(msg, { code: '400', statusCode: 400 }) } ref[++i] = null } else if (current.kind === 'entity') { // > entity target = current one = !!(ref[i].where || current._isSingleton) incompleteKeys = _handleCollectionBoundActions(current, ref, i, namespace, one) if (whereRef) { keyCount += addRefToWhereIfNecessary(whereRef, current) _resolveAliasesInXpr(whereRef, current) // in case of Foo(1), params will be {} (before addRefToWhereIfNecessary was called) if (!Object.keys(params).length) params = where2obj(ref[i].where) _processWhere(ref[i].where, current) _checkAllKeysProvided(params, current) if (keyCount === 0 && !Object.keys(params).length && whereRef.length === 1) { const msg = `Entity "${current.name}" can not be accessed by key.` throw Object.assign(new Error(msg), { statusCode: 400 }) } } } else if ({ action: 1, function: 1 }[current.kind]) { // > action or function if (current.kind === 'action' && ref && ref.at(-1)?.where?.length === 0) { const msg = `Parentheses are not allowed for action calls.` throw Object.assign(new Error(msg), { statusCode: 400 }) } if (i !== ref.length - 1) { const msg = `${i ? 'Bound' : 'Unbound'} ${current.kind}s are only supported as the last path segment` throw Object.assign(new Error(msg), { statusCode: 400 }) } ref[i] = { operation: current.name } if (current.kind === 'function') { if (params) ref[i].args = _getDataFromParams(params, current) // REVISIT: SELECT.from._params is a temporary hack else if (from._params) { // only take known params to allow additional instructions like sap-language, etc. ref[i].args = current['@open'] ? Object.assign({}, from._params) : Object.keys(from._params).reduce((acc, cur) => { const param = cur.startsWith('@') ? cur.slice(1) : cur if (current.params && param in current.params) acc[param] = from._params[cur] return acc }, {}) ref[i].args = _getDataFromParams(ref[i].args, current) //resolve parameter if Object or Array _resolveImplicitFunctionParameters(ref[i].args) } } if (current.returns && current.returns._type) one = true if (current.returns) { if (current.returns._type) { one = true } target = current.returns.items ?? current.returns } } else if (current.isAssociation) { if (!current._target._service) { // not exposed target cds.error(`Property '${current.name}' does not exist in type '${target.name.replace(namespace + '.', '')}'`, { statusCode: 404 }) } // > navigation one = !!(current.is2one || ref[i].where) incompleteKeys = one || i === ref.length - 1 ? false : true current = model.definitions[current.target] target = current incompleteKeys = _handleCollectionBoundActions(current, ref, i, namespace, one) if (ref[i].where) { keyCount += addRefToWhereIfNecessary(ref[i].where, current, true) _resolveAliasesInXpr(ref[i].where, current) _processWhere(ref[i].where, current) } } else if (current.kind === 'element' && current.type !== 'cds.Map' && current.elements && i < ref.length - 1) { // > structured continue } else { // > property // we do not support navigations from properties yet one = true // if the last segment is a property, it must be removed and pushed to columns target = target || _getDefinition(model, ref[0].id, namespace) if (getStructTargetName(current) === target.name) { // TODO add simple isStructured check before if (!cqn.SELECT.columns) cqn.SELECT.columns = [] const ref = getStructRef(current) cqn.SELECT.columns.push({ ref }) // store struct as ref // we need the keys to generate the correct @odata.context for (const key in target.keys || {}) { if (key !== 'IsActiveEntity' && !cqn.SELECT.columns.some(c => c.ref?.[0] === key)) cqn.SELECT.columns.push({ ref: [key] }) } Object.defineProperty(cqn, '_propertyAccess', { value: current.name, enumerable: false }) // if we end up with structured, keep path as is, if we end up with property in structured, cut off property if (!current.elements || current.type === 'cds.Map') from.ref.splice(-1) break } else if (Object.keys(target.elements).includes(current.name)) { if (!cqn.SELECT.columns) cqn.SELECT.columns = [] const propRef = ref.slice(i) if (propRef[0].where?.length === 0) { const msg = 'Parentheses are not allowed when addressing properties.' throw Object.assign(new Error(msg), { statusCode: 400 }) } cqn.SELECT.columns.push({ ref: propRef }) // we need the keys to generate the correct @odata.context for (const key in target.keys || {}) { if (key !== 'IsActiveEntity' && !cqn.SELECT.columns.some(c => c.ref?.[0] === key)) cqn.SELECT.columns.push({ ref: [key] }) } // REVISIT: remove hacky _propertyAccess Object.defineProperty(cqn, '_propertyAccess', { value: current.name, enumerable: false }) from.ref.splice(i) break } } } } if (incompleteKeys) { // > last segment not fully qualified const msg = `Entity "${current.name}" has ${keysOf(current).length} keys. Only ${keyCount} ${ keyCount === 1 ? 'was' : 'were' } provided.` throw Object.assign(new Error(msg), { statusCode: 400 }) } // remove all nulled refs from.ref = ref.filter(r => r) return { one, current, target } } const AGGR_DFLT = '@Aggregation.default' const CSTM_AGGR = '@Aggregation.CustomAggregate' const SMTCS_CC = '@Semantics.currencyCode' const SMTCS_UOM = '@Semantics.unitOfMeasure' const SMTCS_AMT_CC = '@Semantics.amount.currencyCode' const SMTCS_AMT_UOM = '@Semantics.amount.unitOfMeasure' function _addKeys(columns, target) { let hasAggregatedColumn = false, hasStarColumn = false for (const column of columns) { if (column === '*') hasStarColumn = true else if (column.func || column.func === null) hasAggregatedColumn = true // Add keys to (sub-)expands else if (column.expand && column.ref) _addKeys(column.expand, target.elements[column.ref]._target) } // Don't add keys to queries with calculated properties, especially aggregations // REVISIT Clarify if keys should be added for queries containing non-aggregating func columns if (hasAggregatedColumn) return if (hasStarColumn) return const keys = keysOf(target) for (const key of keys) { if (!columns.some(c => (typeof c === 'string' ? c === key : c.ref?.[0] === key))) columns.push({ ref: [key] }) } } /** * Recursively, for each depth, remove all other select columns if a select star is present * (including duplicates) and remove duplicate expand stars. * * @param {*} columns CQN `SELECT` columns array. */ function _removeUnneededColumnsIfHasAsterisk(columns) { // We need to know if column contains a select * before we can remove other selected columns below const hasSelectStar = columns.some(column => column === '*') let hasExpandStar = false columns.forEach((column, i) => { // Remove other select columns if we have a select star if (hasSelectStar && column.ref && !column.expand) columns.splice(i, 1) // Remove duplicate expand stars if (!column.ref && column.expand?.[0] === '*') { if (hasExpandStar) columns.splice(i, 1) hasExpandStar = true } // Recursively remove unneeded columns in expand if (column.expand) _removeUnneededColumnsIfHasAsterisk(column.expand) }) } const _structProperty = (ref, target) => { if (target.elements && target.kind === 'element') { return _structProperty(ref.slice(1), target.elements[ref[0]]) } return target } function _processColumns(cqn, target, protocol) { // Recursively process columns for nested SELECTs if (cqn.SELECT.from.SELECT) _processColumns(cqn.SELECT.from, target) let columns = cqn.SELECT.columns // Error if groupBy is present but no columns are selected if (columns && !columns.length && cqn.SELECT.groupBy) { throw cds.error('Explicit select must include at least one column available in the result set of groupby', { code: '400', statusCode: 400 }) } // If columns exist and no groupBy, handle asterisk and expand logic if (columns && !cqn.SELECT.groupBy) { let entity if (target.kind === 'entity') entity = target else if (target.kind === 'action' && target.returns?.kind === 'entity') entity = target.returns if (!entity) return _removeUnneededColumnsIfHasAsterisk(columns) rewriteExpandAsterisk(columns, entity) // For OData, add missing key fields to columns if (protocol?.match(/odata/i)) _addKeys(columns, entity) } if (!Array.isArray(columns)) return // Iterate columns to set default aggregation function if needed for (let i = 0; i < columns.length; i++) { const processedColumn = columns[i] // Skip if column is not an object or has a ref (not an aggregation) if (typeof processedColumn !== 'object' || processedColumn.ref) continue if (!processedColumn.args?.length) continue if (!processedColumn.args[0].ref?.length) continue const processedColumnRef = processedColumn.args[0].ref // Extract relevant element characteristics let aggregatedPropertyName, aggregatedElement for (let refIdx = 0; refIdx < processedColumnRef.length; refIdx++) { aggregatedPropertyName = processedColumnRef[refIdx] if (aggregatedPropertyName.id) aggregatedPropertyName = aggregatedPropertyName.id if (aggregatedElement && aggregatedElement.isAssociation) target = aggregatedElement._target aggregatedElement = target.elements[aggregatedPropertyName] } const prefixRef = processedColumnRef.slice(0, processedColumnRef.length - 1) const aggregatedElementRef = [...prefixRef, aggregatedPropertyName] const isCurrencyCodeOrUnitOfMeasure = !!(aggregatedElement[SMTCS_CC] || aggregatedElement[SMTCS_UOM]) processedColumn.as = processedColumn.as || aggregatedPropertyName // Specifically handle aggregating semantic amounts if (isCurrencyCodeOrUnitOfMeasure) { columns[i] = { xpr: [ 'case', 'when', { xpr: [{ func: 'max', args: [{ ref: aggregatedElementRef }] }, '=', { val: null }] }, 'then', { val: '' }, 'else', { xpr: [ 'case', 'when', { xpr: [ { func: 'min', args: [{ ref: aggregatedElementRef }] }, '=', { func: 'max', args: [{ ref: aggregatedElementRef }] } ] }, 'then', { func: 'min', args: [{ ref: aggregatedElementRef }] }, 'else', { val: null }, 'end' ] }, 'end' ], as: processedColumn.as } continue } // Determine default aggregation function if necessary const customAggregate = target[`${CSTM_AGGR}#${aggregatedPropertyName}`] if (processedColumn.func === null) { processedColumn.func = aggregatedElement?.[AGGR_DFLT]?.['#']?.toLowerCase() if (!customAggregate) throw cds.error(`Result type for custom aggregation of property "${aggregatedPropertyName}" not found`) if (!processedColumn.func) throw cds.error(`Default aggregation for property "${aggregatedPropertyName}" not found`) if (processedColumn.func === 'count_distinct') processedColumn.func = 'countdistinct' } // Process Semantics Amount - Currency Code / Unit of Measure - if present const semanticsAmountElementName = aggregatedElement?.[SMTCS_AMT_CC] ?? aggregatedElement?.[SMTCS_AMT_UOM] if (!semanticsAmountElementName) continue if (!target.elements[semanticsAmountElementName]) throw cds.error(`Referenced semantics amount element not found: ${semanticsAmountElementName}`) const semanticsAmountElementRef = [...prefixRef, semanticsAmountElementName] columns[i] = { xpr: [ 'case', 'when', { xpr: [ { func: 'min', args: [{ ref: semanticsAmountElementRef }] }, '=', { func: 'max', args: [{ ref: semanticsAmountElementRef }] } ] }, 'then', { func: processedColumn.func, args: [{ ref: aggregatedElementRef }] }, 'else', { val: null }, 'end' ], as: processedColumn.as } } } const _checkAllKeysProvided = (params, entity) => { let keysOfEntity const isView = !!entity.params if (isView) { // view with params if (params === undefined) { throw cds.error(`Invalid call to "${entity.name}". You need to navigate to Set`, { code: '400', statusCode: 400 }) } keysOfEntity = Object.keys(entity.params) } else { keysOfEntity = keysOf(entity) } if (!keysOfEntity) return for (const keyOfEntity of keysOfEntity) { if (!(keyOfEntity in params)) { if (isView && entity.params[keyOfEntity].default) { // will be added later? continue } // prettier-ignore const msg = `${isView ? 'Parameter' : 'Key'} "${keyOfEntity}" is missing for ${isView ? 'view' : 'entity'} "${entity.name}"` throw Object.assign(new Error(msg), { statusCode: 400 }) } } } const _doesNotExistError = (isExpand, refName, targetName, targetKind) => { const msg = isExpand ? `Navigation property "${refName}" is not defined in "${targetName}"` : `Property "${refName}" does not exist in ${targetKind === 'type' ? 'type ' : ''}"${targetName}"` throw Object.assign(new Error(msg), { statusCode: 400 }) } function _validateXpr(xpr, target, isOne, model, aliases = []) { if (!xpr) return [] const ignoredColumns = Object.values(target?.elements ?? {}) .filter(element => element['@cds.api.ignore'] && !element.isAssociation) .map(element => element.name) const _aliases = [] for (const x of xpr) { if (x.as) _aliases.push(x.as) if (x.xpr) { _validateXpr(x.xpr, target, isOne, model) continue } if (x.ref) { const refName = x.ref[0].id ?? x.ref[0] if (x.ref[0].where) { const element = target.elements[refName] if (!element) { _doesNotExistError(true, refName, target.name) } _validateXpr(x.ref[0].where, element._target ?? element.items, isOne, model) } if (!target?.elements) { _doesNotExistError(false, refName, target.name, target.kind) } if (ignoredColumns.includes(refName) || (!target.elements[refName] && !aliases.includes(refName))) { _doesNotExistError(x.expand, refName, target.name) } else if (x.ref.length > 1) { const element = target.elements[refName] if (element.isAssociation) { // navigation _validateXpr([{ ref: x.ref.slice(1) }], element._target, false, model) } else if (element.kind === 'element') { // structured _validateXpr([{ ref: x.ref.slice(1) }], element, isOne, model) } else { throw new Error('not yet validated') } } if (x.expand) { let element = target.elements[refName] if (element.kind === 'element' && element.elements) { // structured _validateXpr([{ ref: x.ref.slice(1) }], element, isOne, model) element = _structProperty(x.ref.slice(1), element) } if (!element._target) { _doesNotExistError(true, refName, target.name) } _validateXpr(x.expand, element._target, false, model) if (x.where) { _validateXpr(x.where, element._target, false, model) } if (x.orderBy) { _validateXpr(x.orderBy, element._target, false, model) } } } if (x.func) { _validateXpr(x.args, target, isOne, model) continue } if (x.SELECT) { const { target } = targetFromPath(x.SELECT.from, model) _validateQuery(x.SELECT, target, x.SELECT.one, model) } } return _aliases } function _validateQuery(SELECT, target, isOne, model) { const aliases = [] if (SELECT.from.SELECT) { const { target } = targetFromPath(SELECT.from.SELECT.from, model) const subselectAliases = _validateQuery(SELECT.from.SELECT, target, SELECT.from.SELECT.one, model) aliases.push(...subselectAliases) } const columnAliases = _validateXpr(SELECT.columns, target, isOne, model) aliases.push(...columnAliases) _validateXpr(SELECT.orderBy, target, isOne, model, aliases) _validateXpr(SELECT.where, target, isOne, model, aliases) _validateXpr(SELECT.groupBy, target, isOne, model, aliases) _validateXpr(SELECT.having, target, isOne, model, aliases) return aliases } module.exports = (cqn, model, namespace, protocol) => { if (!model) return cqn const from = resolveFromSelect(cqn) const { ref } = from let edmName = ref[0].id || ref[0] // REVISIT: shouldn't be necessary if (edmName.split('.').length > 1) //required for concat query, where the root is already identified with the first query and subsequent queries already have correct root edmName = edmName.split('.')[edmName.split('.').length - 1] // Make first path segment fully qualified const root = findCsnTargetFor(edmName, model, namespace) if (!root) { //404 else we would expose knowledge to potential attackers throw new cds.error(404, `Invalid resource path "${namespace}.${ref[0].id || ref[0]}"`) } if (cds.env.effective.odata.containment && model.definitions[namespace]._containedEntities.has(root.name)) { throw new cds.error( 404, `Invalid resource path "${namespace}.${ref[0].id || ref[0]}"! It is not an entity set nor a singleton.` ) } if (ref[0].id) ref[0].id = root.name else ref[0] = root.name // key vs. path segments (/Books/1/author/books/2/...) and more const { one, current, target } = _processSegments(from, model, namespace, cqn, protocol) if (cds.env.effective.odata.proxies && cds.env.effective.odata.xrefs && target) { if (!target._service) { // proxy navigation, add keys as columns only const columns = [] for (const key in target.keys) { if (target.keys[key].isAssociation) continue columns.push({ ref: [key] }) } cqn.SELECT.columns = columns } } if (cqn.SELECT.where) { _processWhere(cqn.SELECT.where, target) } // one? if (one) cqn.SELECT.one = true // hierarchy requests, quick check to avoid unnecessary traversing // REVISIT: Should be done via annotation on backlink, would make lookup easier const _getRecurse = SELECT => { if (SELECT.recurse) return SELECT.recurse if (SELECT.from && SELECT.from.SELECT) return _getRecurse(SELECT.from.SELECT) } const _recurse = _getRecurse(cqn.SELECT) if (_recurse) { let uplinkName for (const key in target) { if (key.match(/@Aggregation\.RecursiveHierarchy\s*#.*\.ParentNavigationProperty/)) { // Qualifiers are bad for lookups uplinkName = target[key]['='] break } } if (!uplinkName || !target.elements[uplinkName]) throw new cds.error( 500, 'Cannot resolve `ParentNavigationProperty` in `@Aggregation.RecursiveHierarchy` annotation' ) _recurse.ref[0] = uplinkName } // REVISIT: better // Set target (csn definition) for later retrieval if (protocol === 'rest') cqn.__target = current.parent?.kind === 'entity' ? `${current.parent.name}:$:${current.name}` : current.name // target <=> endpoint entity, all navigation refs must be resolvable accordingly if (cds.env.effective.odata.structs) _resolveAliasesInNavigation(cqn, target) // Add default aggregation function (and alias) _processColumns(cqn, current, protocol) if (target) { // validate whether only known properties are used in query options _validateQuery(cqn.SELECT, target, one, model) } return cqn }