@cap-js/db-service 2.6.0 → 2.7.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,19 @@
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.7.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.6.0...db-service-v2.7.0) (2025-11-26)
8
+
9
+
10
+ ### Added
11
+
12
+ * `error` standard function ([#1421](https://github.com/cap-js/cds-dbs/issues/1421)) ([b1b0fca](https://github.com/cap-js/cds-dbs/commit/b1b0fca00387c45ed91280b2df4282be90ea0a6e))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * LimitedRank with compositions ([#1391](https://github.com/cap-js/cds-dbs/issues/1391)) ([31766cd](https://github.com/cap-js/cds-dbs/commit/31766cd8f9b626d090129b174ac9a04b4d578c21))
18
+ * 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))
19
+
7
20
  ## [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
21
 
9
22
 
@@ -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
@@ -1326,7 +1326,7 @@ class CQN2SQLRenderer {
1326
1326
  } else {
1327
1327
  cds.error`Invalid arguments provided for function '${func}' (${args})`
1328
1328
  }
1329
- const fn = this.class.Functions[func]?.apply(this, args) || `${func}(${args})`
1329
+ const fn = this.class.Functions[func]?.apply(this, Array.isArray(args) ? args: [args]) || `${func}(${args})`
1330
1330
  if (xpr) return `${fn} ${this.xpr({ xpr })}`
1331
1331
  return fn
1332
1332
  }
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
@@ -1460,24 +1466,12 @@ function cqn4sql(originalQuery, model) {
1460
1466
  else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1461
1467
  }
1462
1468
  } 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
1469
+ // infix filter - OData variant w/o mentioning key
1464
1470
  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
- )
1471
+ const flatKeys = getPrimaryKey(def, $baseLink.alias)
1472
+ if (flatKeys.length > 1) // TODO: what about keyless?
1473
+ throw new Error(`Shortcut notation “[${token.val}]” not available for composite primary key of “${def.name}”, write “<key> = ${token.val}” explicitly`)
1474
+ transformedTokenStream.push(...[flatKeys[0], '=', token]);
1481
1475
  } else if (token.ref && token.param) {
1482
1476
  transformedTokenStream.push({ ...token })
1483
1477
  } else if (pseudos.elements[token.ref?.[0]]) {
@@ -1785,9 +1779,10 @@ function cqn4sql(originalQuery, model) {
1785
1779
  }
1786
1780
  }
1787
1781
 
1788
- // only append infix filter to outer where if it is the leaf of the from ref
1789
- if (refReverse[0].where)
1782
+ // OData variant w/o mentioning key
1783
+ if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1790
1784
  filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
1785
+ }
1791
1786
 
1792
1787
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
1793
1788
  if (whereExistsSubSelects.length > 0) {
@@ -2283,6 +2278,12 @@ function cqn4sql(originalQuery, model) {
2283
2278
  if (!node || !node.$refLinks || !node.ref) {
2284
2279
  throw new Error('Invalid node')
2285
2280
  }
2281
+ if(node.$refLinks[0].$main) {
2282
+ if (node.isJoinRelevant) {
2283
+ return getJoinRelevantAlias(node)
2284
+ }
2285
+ return node.$refLinks[0].alias
2286
+ }
2286
2287
  if ($baseLink) {
2287
2288
  return getBaseLinkAlias($baseLink)
2288
2289
  }
@@ -2418,6 +2419,12 @@ function assignQueryModifiers(SELECT, modifiers) {
2418
2419
  } else if (key === 'having') {
2419
2420
  if (!SELECT.having) SELECT.having = val
2420
2421
  else SELECT.having.push('and', ...val)
2422
+ } else if (key === 'where') {
2423
+ // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2424
+ if(!val || val.length === 1 && val[0].val) continue
2425
+ if (!SELECT.where) SELECT.where = val
2426
+ // infix filter comes first in resulting where
2427
+ else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]
2421
2428
  }
2422
2429
  }
2423
2430
  }
@@ -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.7.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
  }