@cap-js/db-service 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@
4
4
  - The format is based on [Keep a Changelog](http://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## [2.8.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.7.0...db-service-v2.8.0) (2025-12-15)
8
+
9
+
10
+ ### Added
11
+
12
+ * Added support to use `UPSERT` from `SELECT` ([#1435](https://github.com/cap-js/cds-dbs/issues/1435)) ([68f3db8](https://github.com/cap-js/cds-dbs/commit/68f3db8d79aa120768fe81324cd164782b9eec1b))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * propagte target to subquery ([#1438](https://github.com/cap-js/cds-dbs/issues/1438)) ([5460e43](https://github.com/cap-js/cds-dbs/commit/5460e4398079750ec3afec9a1747007618d23ecd))
18
+
19
+ ## [2.7.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.6.0...db-service-v2.7.0) (2025-11-26)
20
+
21
+
22
+ ### Added
23
+
24
+ * `error` standard function ([#1421](https://github.com/cap-js/cds-dbs/issues/1421)) ([b1b0fca](https://github.com/cap-js/cds-dbs/commit/b1b0fca00387c45ed91280b2df4282be90ea0a6e))
25
+
26
+
27
+ ### Fixed
28
+
29
+ * LimitedRank with compositions ([#1391](https://github.com/cap-js/cds-dbs/issues/1391)) ([31766cd](https://github.com/cap-js/cds-dbs/commit/31766cd8f9b626d090129b174ac9a04b4d578c21))
30
+ * reject nested projection if duplicated ([#1411](https://github.com/cap-js/cds-dbs/issues/1411)) ([6e924c9](https://github.com/cap-js/cds-dbs/commit/6e924c9942de6e6a4abf7b2c168d4378efcaefa9))
31
+
7
32
  ## [2.6.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.1...db-service-v2.6.0) (2025-10-23)
8
33
 
9
34
 
@@ -4,6 +4,48 @@ const cds = require('@sap/cds')
4
4
 
5
5
  // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
6
6
  const StandardFunctions = {
7
+ /**
8
+ * Generates SQL statement that produces a runtime compatible error object
9
+ * @param {string|object} message - The i18n key or message of the error object
10
+ * @param {Array<xpr>} args - The arguments to apply to the i18n string
11
+ * @param {Array<xpr>} targets - The name of the element that the error is related to
12
+ * @return {string} - SQL statement
13
+ */
14
+ error: function (message, args, targets) {
15
+ targets = targets && (targets.list || (targets.val || targets.ref) && [targets])
16
+ if (Array.isArray(targets)) targets = targets.map(e => e.ref && { val: e.ref.at(-1) } || e)
17
+ args = args && (args.list || (args.val || args.ref) && [args])
18
+
19
+ return `(${this.SELECT({
20
+ SELECT: {
21
+ expand: 'root',
22
+ columns: [
23
+ {
24
+ __proto__: (message || { val: null }),
25
+ as: 'message',
26
+ },
27
+ args ? {
28
+ func: 'json_array',
29
+ args: args,
30
+ as: 'args',
31
+ element: cds.builtin.types.Map,
32
+ } : { val: null, as: 'args' },
33
+ targets ? {
34
+ func: 'json_array',
35
+ args: targets,
36
+ as: 'targets',
37
+ element: cds.builtin.types.Map,
38
+ } : { val: null, as: 'targets' },
39
+ ]
40
+ },
41
+ elements: {
42
+ message: cds.builtin.types.String,
43
+ args: cds.builtin.types.Map,
44
+ targets: cds.builtin.types.Map,
45
+ }
46
+ })})`
47
+ },
48
+
7
49
  /**
8
50
  * Generates SQL statement that produces a boolean value indicating whether the search term is contained in the given columns
9
51
  * @param {string} ref - The reference object containing column information
package/lib/cqn2sql.js CHANGED
@@ -325,7 +325,7 @@ class CQN2SQLRenderer {
325
325
  DistanceFromRoot: { xpr: [{ ref: ['HIERARCHY_LEVEL'] }, '-', { val: 1, param: false }], as: 'DistanceFromRoot' },
326
326
  DrillState: false,
327
327
  LimitedDescendantCount: { xpr: [{ ref: ['HIERARCHY_TREE_SIZE'] }, '-', { val: 1, param: false }], as: 'LimitedDescendantCount' },
328
- LimitedRank: { xpr: [{ func: 'row_number', args: [] }, 'OVER', { xpr: [] }, '-', { val: 1, param: false }], as: 'LimitedRank' }
328
+ LimitedRank: { xpr: [{ func: 'row_number', args: [] }, 'OVER', { xpr: ['ORDER', 'BY', { ref: ['HIERARCHY_RANK'] }, 'ASC'] }, '-', { val: 1, param: false }], as: 'LimitedRank' }
329
329
  }
330
330
 
331
331
  const columnsFiltered = columns
@@ -393,11 +393,11 @@ class CQN2SQLRenderer {
393
393
  const alias = stableFrom.as
394
394
  const source = () => {
395
395
  return ({
396
- func: 'HIERARCHY',
397
- args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }],
398
- as: alias
399
- })
400
- }
396
+ func: 'HIERARCHY',
397
+ args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }],
398
+ as: alias
399
+ })
400
+ }
401
401
 
402
402
  const expandedByNr = { list: [] } // DistanceTo(...,null)
403
403
  const expandedByOne = { list: [] } // DistanceTo(...,1)
@@ -1019,12 +1019,17 @@ class CQN2SQLRenderer {
1019
1019
  const entity = this.name(q._target.name, q)
1020
1020
  const alias = INSERT.into.as
1021
1021
  const elements = q.elements || q._target?.elements || {}
1022
- const columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1022
+ let columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1023
1023
  c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
1024
1024
  ))
1025
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${this.SELECT(
1026
- this.cqn4sql(INSERT.from || INSERT.as),
1027
- )}`
1025
+
1026
+ const src = this.cqn4sql(INSERT.from)
1027
+ const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements)
1028
+ const sql = extractions.length > columns.length
1029
+ ? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW`
1030
+ : this.SELECT(src)
1031
+ if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
1032
+ this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${sql}`
1028
1033
  this.entries = [this.values]
1029
1034
  return this.sql
1030
1035
  }
@@ -1077,18 +1082,32 @@ class CQN2SQLRenderer {
1077
1082
  .map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
1078
1083
  .join(' AND ')
1079
1084
 
1080
- const columns = this.columns // this.columns is computed as part of this.INSERT
1081
- const managed = this._managed.slice(0, columns.length)
1085
+ let columns = this.columns // this.columns is computed as part of this.INSERT
1086
+ const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1087
+ if (UPSERT.entries || UPSERT.rows || UPSERT.values) {
1088
+ const managed = this._managed.slice(0, columns.length)
1082
1089
 
1083
- const extractkeys = managed
1084
- .filter(c => keys.includes(c.name))
1085
- .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
1090
+ const extractkeys = managed
1091
+ .filter(c => keys.includes(c.name))
1092
+ .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
1086
1093
 
1087
- const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1088
- sql = `SELECT ${managed.map(c => c.upsert
1089
- .replace(/value->/g, '"$$$$value$$$$"->')
1090
- .replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
1091
- } FROM (SELECT value as "$$value$$", ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
1094
+ sql = `SELECT ${managed.map(c => c.upsert
1095
+ .replace(/value->/g, '"$$$$value$$$$"->')
1096
+ .replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
1097
+ } FROM (SELECT value as "$$value$$", ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
1098
+ } else {
1099
+ const extractions = this._managed
1100
+ if (this.values) this.values = [] // Clear previously computed values
1101
+ const src = this.cqn4sql(UPSERT.from || UPSERT.as)
1102
+ const aliasedQuery = cds.ql.SELECT
1103
+ .columns(src.SELECT.columns
1104
+ .map((c, i) => ({ ref: [this.column_name(c)], as: this.columns[i] }))
1105
+ )
1106
+ .from(src)
1107
+ sql = `SELECT ${extractions.map(c => `${c.upsert}`)} FROM (${this.SELECT(aliasedQuery)}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
1108
+ if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
1109
+ this.entries = [this.values]
1110
+ }
1092
1111
 
1093
1112
  const updateColumns = columns.filter(c => {
1094
1113
  if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
@@ -1326,7 +1345,7 @@ class CQN2SQLRenderer {
1326
1345
  } else {
1327
1346
  cds.error`Invalid arguments provided for function '${func}' (${args})`
1328
1347
  }
1329
- const fn = this.class.Functions[func]?.apply(this, args) || `${func}(${args})`
1348
+ const fn = this.class.Functions[func]?.apply(this, Array.isArray(args) ? args : [args]) || `${func}(${args})`
1330
1349
  if (xpr) return `${fn} ${this.xpr({ xpr })}`
1331
1350
  return fn
1332
1351
  }
@@ -1430,7 +1449,7 @@ class CQN2SQLRenderer {
1430
1449
 
1431
1450
  let onInsert = this.managed_session_context(element[cdsOnInsert]?.['='])
1432
1451
  || this.managed_session_context(element.default?.ref?.[0])
1433
- || (element.default && { __proto__: element.default, param: false })
1452
+ || (element.default && { __proto__: element.default, param: false })
1434
1453
  let onUpdate = this.managed_session_context(element[cdsOnUpdate]?.['='])
1435
1454
 
1436
1455
  if (onInsert) onInsert = this.expr(onInsert)
package/lib/cqn4sql.js CHANGED
@@ -74,6 +74,12 @@ function cqn4sql(originalQuery, model) {
74
74
  if (inferred.SELECT?.from.ref?.at(-1).id) {
75
75
  assignQueryModifiers(inferred.SELECT, inferred.SELECT.from.ref.at(-1))
76
76
  }
77
+ if (inferred.DELETE?.from.ref?.at(-1).id) {
78
+ assignQueryModifiers(inferred.DELETE, inferred.DELETE.from.ref.at(-1))
79
+ }
80
+ if (inferred.UPDATE?.entity.ref?.at(-1).id) {
81
+ assignQueryModifiers(inferred.UPDATE, inferred.UPDATE.entity.ref.at(-1))
82
+ }
77
83
  inferred = infer(inferred, model)
78
84
  const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
79
85
  // if the query has custom joins we don't want to transform it
@@ -275,7 +281,11 @@ function cqn4sql(originalQuery, model) {
275
281
  const alreadySeen = new Map()
276
282
  inferred.joinTree._roots.forEach(r => {
277
283
  const args = []
278
- if (r.queryArtifact.SELECT) args.push({ SELECT: transformSubquery(r.queryArtifact).SELECT, as: r.alias })
284
+ if (r.queryArtifact.SELECT) {
285
+ const transformedSubquery = transformSubquery(r.queryArtifact)
286
+ transformedSubquery.as = r.alias
287
+ args.push(transformedSubquery)
288
+ }
279
289
  else {
280
290
  const id = getLocalizedName(r.queryArtifact)
281
291
  args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
@@ -1460,24 +1470,12 @@ function cqn4sql(originalQuery, model) {
1460
1470
  else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1461
1471
  }
1462
1472
  } else if (tokenStream.length === 1 && token.val && $baseLink) {
1463
- // infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
1473
+ // infix filter - OData variant w/o mentioning key
1464
1474
  const def = getDefinition($baseLink.definition.target) || $baseLink.definition
1465
- const keys = def.keys // use key aspect on entity
1466
- const keyValComparisons = []
1467
- const flatKeys = []
1468
- for (const v of Object.values(keys)) {
1469
- if (v !== backlinkFor($baseLink.definition)?.[0]) {
1470
- // up__ID already part of inner where exists, no need to add it explicitly here
1471
- flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1472
- }
1473
- }
1474
- // TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
1475
- if (flatKeys.length > 1)
1476
- throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1477
- flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
1478
- keyValComparisons.forEach((kv, j) =>
1479
- transformedTokenStream.push(...kv) && keyValComparisons[j + 1] ? transformedTokenStream.push('and') : null,
1480
- )
1475
+ const flatKeys = getPrimaryKey(def, $baseLink.alias)
1476
+ if (flatKeys.length > 1) // TODO: what about keyless?
1477
+ throw new Error(`Shortcut notation “[${token.val}]” not available for composite primary key of “${def.name}”, write “<key> = ${token.val}” explicitly`)
1478
+ transformedTokenStream.push(...[flatKeys[0], '=', token]);
1481
1479
  } else if (token.ref && token.param) {
1482
1480
  transformedTokenStream.push({ ...token })
1483
1481
  } else if (pseudos.elements[token.ref?.[0]]) {
@@ -1785,9 +1783,10 @@ function cqn4sql(originalQuery, model) {
1785
1783
  }
1786
1784
  }
1787
1785
 
1788
- // only append infix filter to outer where if it is the leaf of the from ref
1789
- if (refReverse[0].where)
1786
+ // OData variant w/o mentioning key
1787
+ if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1790
1788
  filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
1789
+ }
1791
1790
 
1792
1791
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
1793
1792
  if (whereExistsSubSelects.length > 0) {
@@ -2283,6 +2282,12 @@ function cqn4sql(originalQuery, model) {
2283
2282
  if (!node || !node.$refLinks || !node.ref) {
2284
2283
  throw new Error('Invalid node')
2285
2284
  }
2285
+ if(node.$refLinks[0].$main) {
2286
+ if (node.isJoinRelevant) {
2287
+ return getJoinRelevantAlias(node)
2288
+ }
2289
+ return node.$refLinks[0].alias
2290
+ }
2286
2291
  if ($baseLink) {
2287
2292
  return getBaseLinkAlias($baseLink)
2288
2293
  }
@@ -2418,6 +2423,12 @@ function assignQueryModifiers(SELECT, modifiers) {
2418
2423
  } else if (key === 'having') {
2419
2424
  if (!SELECT.having) SELECT.having = val
2420
2425
  else SELECT.having.push('and', ...val)
2426
+ } else if (key === 'where') {
2427
+ // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2428
+ if(!val || val.length === 1 && val[0].val) continue
2429
+ if (!SELECT.where) SELECT.where = val
2430
+ // infix filter comes first in resulting where
2431
+ else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]
2421
2432
  }
2422
2433
  }
2423
2434
  }
@@ -47,7 +47,6 @@ function infer(originalQuery, model) {
47
47
  let $combinedElements
48
48
 
49
49
  const sources = inferTarget(_.into || _.from || _.entity, {}) // IMPORTANT: _.into has to go before _.from for INSERT.into().from(SELECT)
50
- const joinTree = new JoinTree(sources)
51
50
  const aliases = Object.keys(sources)
52
51
  const target = aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery
53
52
  Object.defineProperties(inferred, {
@@ -72,7 +71,6 @@ function infer(originalQuery, model) {
72
71
  Object.defineProperties(inferred, {
73
72
  $combinedElements: { value: $combinedElements, writable: true, configurable: true },
74
73
  elements: { value: elements, writable: true, configurable: true },
75
- joinTree: { value: joinTree, writable: true, configurable: true }, // REVISIT: eliminate
76
74
  })
77
75
  // also enrich original query -> writable because it may be inferred again
78
76
  defineProperty(originalQuery, 'elements', elements)
@@ -96,6 +94,10 @@ function infer(originalQuery, model) {
96
94
  */
97
95
  function inferTarget(from, querySources, useTechnicalAlias = true) {
98
96
  const { ref } = from
97
+ // Given a from clause `Root:parent[$main.name = name].parent as Foo`
98
+ // we need to first resolve until to the last step of the from.ref
99
+ // before we can replace $main with `Foo`
100
+ const $mainLazyResolve = [] // TODO: remove and replace with real alias breakout
99
101
  if (ref) {
100
102
  const { id, args } = ref[0]
101
103
  const first = id || ref[0]
@@ -111,7 +113,7 @@ function infer(originalQuery, model) {
111
113
  if (target.kind !== 'entity' && !target.isAssociation)
112
114
  throw new Error('Query source must be a an entity or an association')
113
115
 
114
- inferArg(from, null, null, { inFrom: true })
116
+ inferArg(from, null, null, { inFrom: true, $mainLazyResolve })
115
117
  const alias =
116
118
  from.uniqueSubqueryAlias ||
117
119
  from.as ||
@@ -137,6 +139,10 @@ function infer(originalQuery, model) {
137
139
  } else if (from.SET) {
138
140
  infer(from, model)
139
141
  }
142
+
143
+ const joinTree = new JoinTree(querySources)
144
+ Object.defineProperty( inferred, 'joinTree', { value: joinTree, writable: true, configurable: true } )
145
+ for(const lazyRef of $mainLazyResolve) inferArg(lazyRef)
140
146
  return querySources
141
147
  }
142
148
 
@@ -201,7 +207,7 @@ function infer(originalQuery, model) {
201
207
  } else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
202
208
  const as = col.as || col.func || col.val
203
209
  if (as === undefined) cds.error`Expecting expression to have an alias name`
204
- if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
210
+ if (queryElements[as]) rejectDuplicatedElement(as)
205
211
  if (col.xpr || col.SELECT) {
206
212
  queryElements[as] = getElementForXprOrSubquery(col, queryElements, dollarSelfRefs)
207
213
  }
@@ -422,11 +428,13 @@ function infer(originalQuery, model) {
422
428
  // we must ignore the element from the queries elements
423
429
  let isPersisted = true
424
430
  let firstStepIsTableAlias, firstStepIsSelf, expandOnTableAlias
425
- if (!inFrom) {
431
+ const firstStepIsDollarMain = arg.ref.length > 1 && arg.ref[0] === '$main'
432
+ if (!inFrom && !firstStepIsDollarMain) {
426
433
  firstStepIsTableAlias = arg.ref.length > 1 && arg.ref[0] in sources
427
434
  firstStepIsSelf = !firstStepIsTableAlias && arg.ref.length > 1 && ['$self', '$projection'].includes(arg.ref[0])
428
435
  expandOnTableAlias = arg.ref.length === 1 && arg.ref[0] in sources && (arg.expand || arg.inline)
429
436
  }
437
+
430
438
  if (dollarSelfRefs && firstStepIsSelf) {
431
439
  defineProperty(arg, 'inXpr', true)
432
440
  dollarSelfRefs.push(arg)
@@ -438,10 +446,20 @@ function infer(originalQuery, model) {
438
446
  // on conditions of joins
439
447
  const skipAliasedFkSegmentsOfNameStack = []
440
448
  let pseudoPath = false
441
- arg.ref.forEach((step, i) => {
449
+ for(let i = 0; i < arg.ref.length; i++) {
450
+ const step = arg.ref[i]
442
451
  const id = step.id || step
443
452
  if (i === 0) {
444
- if (id in pseudos.elements) {
453
+ if(firstStepIsDollarMain) {
454
+ if(inFrom) { // we need to resolve the full from clause first
455
+ context.$mainLazyResolve.push(arg)
456
+ return; // this will be done once the from clause is fully resolved
457
+ } else {
458
+ // replace $main with the alias of the outermost query
459
+ const mainAlias = getMainAlias(inferred)
460
+ arg.$refLinks.push(Object.assign(mainAlias, {$main: true}))
461
+ }
462
+ } else if (id in pseudos.elements) {
445
463
  // pseudo path
446
464
  arg.$refLinks.push({ definition: pseudos.elements[id], target: pseudos })
447
465
  pseudoPath = true // only first path step must be well defined
@@ -569,6 +587,7 @@ function infer(originalQuery, model) {
569
587
  skipJoinsForFilter = true
570
588
  } else if (token.ref || token.xpr || token.list) {
571
589
  inferArg(token, false, arg.$refLinks[i], {
590
+ ...context,
572
591
  inExists: skipJoinsForFilter || inExists,
573
592
  inXpr: !!token.xpr,
574
593
  inInfixFilter: true,
@@ -586,7 +605,8 @@ function infer(originalQuery, model) {
586
605
  })
587
606
  }
588
607
 
589
- arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
608
+ if(!arg.$refLinks[i].$main)
609
+ arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
590
610
  if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
591
611
  if (!arg.ref[i + 1]) {
592
612
  const flatName = nameSegments.join('_')
@@ -602,9 +622,17 @@ function infer(originalQuery, model) {
602
622
  if (arg.$refLinks.length === 1 && arg.$refLinks[0].definition.kind === 'entity')
603
623
  elementName = arg.$refLinks[0].alias
604
624
  else elementName = arg.as || flatName
605
- if (queryElements) queryElements[elementName] = elements
625
+
626
+ if (queryElements) {
627
+ if (queryElements[elementName] !== undefined)
628
+ rejectDuplicatedElement(elementName)
629
+ queryElements[elementName] = elements
630
+ }
606
631
  } else if (arg.inline && queryElements) {
607
632
  const elements = resolveInline(arg)
633
+ for (const elName in elements) {
634
+ if (queryElements[elName] !== undefined) rejectDuplicatedElement(elName)
635
+ }
608
636
  Object.assign(queryElements, elements)
609
637
  } else {
610
638
  // shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']`
@@ -626,14 +654,13 @@ function infer(originalQuery, model) {
626
654
  else elementName = flatName
627
655
  }
628
656
  if (queryElements[elementName] !== undefined)
629
- throw new Error(`Duplicate definition of element “${elementName}”`)
657
+ rejectDuplicatedElement(elementName)
630
658
  const element = getCopyWithAnnos(arg, leafArt)
631
659
  queryElements[elementName] = element
632
660
  }
633
661
  }
634
662
  }
635
- })
636
-
663
+ }
637
664
  // we need inner joins for the path expressions inside filter expressions after exists predicate
638
665
  if ($baseLink?.pathExpressionInsideFilter) defineProperty(arg, 'join', 'inner')
639
666
 
@@ -656,7 +683,9 @@ function infer(originalQuery, model) {
656
683
  : arg
657
684
  if (isColumnJoinRelevant(colWithBase)) {
658
685
  defineProperty(arg, 'isJoinRelevant', true)
659
- joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
686
+ // join resolved in outer query
687
+ if(!(arg.$refLinks[0].$main && originalQuery.outerQueries))
688
+ inferred.joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
660
689
  }
661
690
  }
662
691
  if (isCalculatedOnRead(leafArt)) {
@@ -807,6 +836,10 @@ function infer(originalQuery, model) {
807
836
  throw new Error(err.join(','))
808
837
  }
809
838
  }
839
+ function rejectDuplicatedElement(elementName) {
840
+ throw new Error(`Duplicate definition of element “${elementName}”`)
841
+ }
842
+
810
843
  function linkCalculatedElement(column, baseLink, baseColumn, context = {}) {
811
844
  const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
812
845
  if (alreadySeenCalcElements.has(calcElement)) return
@@ -901,7 +934,7 @@ function infer(originalQuery, model) {
901
934
  if (calcElementIsJoinRelevant) {
902
935
  if (!calcElement.value.isJoinRelevant)
903
936
  defineProperty(step, 'isJoinRelevant',true)
904
- joinTree.mergeColumn(p, originalQuery.outerQueries)
937
+ inferred.joinTree.mergeColumn(p, originalQuery.outerQueries)
905
938
  } else {
906
939
  // we need to explicitly set the value to false in this case,
907
940
  // e.g. `SELECT from booksCalc.Books { ID, author.{name }, author {name } }`
@@ -1160,4 +1193,12 @@ function applyToFunctionArgs(funcArgs, cb, cbArgs) {
1160
1193
  else if (typeof funcArgs === 'object') Object.keys(funcArgs).forEach(prop => cb(funcArgs[prop], ...cbArgs))
1161
1194
  }
1162
1195
 
1196
+ function getMainAlias (query) {
1197
+ let mainAlias
1198
+ if (query.outerQueries) mainAlias = query.outerQueries[0].SELECT?.from.$refLinks.at(-1)
1199
+ else mainAlias = query.SELECT?.from.$refLinks.at(-1)
1200
+ if(!mainAlias) throw new Error('Cannot determine main query source for $main, please report this')
1201
+ return mainAlias
1202
+ }
1203
+
1163
1204
  module.exports = infer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "CDS base database service",
5
5
  "homepage": "https://github.com/cap-js/cds-dbs/tree/main/db-service#cds-base-database-service",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  "generic-pool": "^3.9.0"
28
28
  },
29
29
  "peerDependencies": {
30
- "@sap/cds": ">=9"
30
+ "@sap/cds": ">=9.4.5"
31
31
  },
32
32
  "license": "Apache-2.0"
33
33
  }