@cap-js/db-service 2.5.1 → 2.6.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,20 @@
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.6.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.1...db-service-v2.6.0) (2025-10-23)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`flattening`:** allow flattening of `n`-ary structures in `list` ([#1337](https://github.com/cap-js/cds-dbs/issues/1337)) ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
13
+ * **`flattening`:** allow flattening of structures with exactly one leaf ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`@cds.search`:** properly exclude an association from being searched ([#1385](https://github.com/cap-js/cds-dbs/issues/1385)) ([9ed4245](https://github.com/cap-js/cds-dbs/commit/9ed42458417c15dc33409befd0ef40889f04a69f))
19
+ * tree table with expand ([#1363](https://github.com/cap-js/cds-dbs/issues/1363)) ([bdad412](https://github.com/cap-js/cds-dbs/commit/bdad412f0362165b532ce35261773e5ecc7c696a))
20
+
7
21
  ## [2.5.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.0...db-service-v2.5.1) (2025-09-30)
8
22
 
9
23
 
package/lib/cqn2sql.js CHANGED
@@ -350,7 +350,13 @@ class CQN2SQLRenderer {
350
350
  if (element['@Core.Computed'] && name in availableComputedColumns) continue
351
351
  if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
352
352
  columnsIn.push(ref)
353
- if (from.args || columnsFiltered.find(c => this.column_name(c) === name)) {
353
+ const foreignkey4 = element._foreignKey4
354
+ if (
355
+ from.args ||
356
+ columnsFiltered.find(c => this.column_name(c) === name) ||
357
+ // foreignkey needs to be included when the association is expanded
358
+ (foreignkey4 && q.SELECT.columns.some(c => c.element?.isAssociation && c.element.name === foreignkey4))
359
+ ) {
354
360
  columnsOut.push(ref.as ? { ref: [ref.as], as: name } : ref)
355
361
  }
356
362
  }
package/lib/cqn4sql.js CHANGED
@@ -96,7 +96,7 @@ function cqn4sql(originalQuery, model) {
96
96
 
97
97
  // Transform the existing where, prepend table aliases, and so on...
98
98
  if (where) {
99
- transformedProp.where = getTransformedTokenStream(where)
99
+ transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
100
100
  }
101
101
 
102
102
  // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
@@ -191,7 +191,7 @@ function cqn4sql(originalQuery, model) {
191
191
 
192
192
  // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
193
193
  if (having) {
194
- transformedQuery.SELECT.having = getTransformedTokenStream(having)
194
+ transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
195
195
  }
196
196
 
197
197
  if (groupBy) {
@@ -314,7 +314,7 @@ function cqn4sql(originalQuery, model) {
314
314
  lhs.args.push(arg)
315
315
  alreadySeen.set(nextAssoc.$refLink.alias, true)
316
316
  if (nextAssoc.where) {
317
- const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
317
+ const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
318
318
  lhs.on = [
319
319
  ...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
320
320
  'and',
@@ -521,14 +521,14 @@ function cqn4sql(originalQuery, model) {
521
521
  }
522
522
  }
523
523
 
524
- function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
524
+ function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
525
525
  let value
526
526
 
527
527
  if (column.$refLinks) {
528
528
  const { $refLinks } = column
529
529
  value = $refLinks[$refLinks.length - 1].definition.value
530
530
  if (column.$refLinks.length > 1) {
531
- baseLink =
531
+ $baseLink =
532
532
  [...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
533
533
  // if there is no association in the path, the table alias is the base link
534
534
  // TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
@@ -541,13 +541,13 @@ function cqn4sql(originalQuery, model) {
541
541
 
542
542
  let res
543
543
  if (ref) {
544
- res = getTransformedTokenStream([value], baseLink)[0]
544
+ res = getTransformedTokenStream([value], { $baseLink })[0]
545
545
  } else if (xpr) {
546
- res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
546
+ res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
547
547
  } else if (val !== undefined) {
548
548
  res = { val }
549
549
  } else if (func) {
550
- res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
550
+ res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
551
551
  }
552
552
  if (!omitAlias) res.as = column.as || column.name || column.flatName
553
553
  return res
@@ -1018,7 +1018,7 @@ function cqn4sql(originalQuery, model) {
1018
1018
  * the result.
1019
1019
  */
1020
1020
  if (inOrderBy && flatColumns.length > 1)
1021
- throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
1021
+ throw new Error(`Structured element “${getFullName(leaf)} expands to multiple fields and can't be used in order by`)
1022
1022
  flatColumns.forEach(fc => {
1023
1023
  if (col.nulls) fc.nulls = col.nulls
1024
1024
  if (col.sort) fc.sort = col.sort
@@ -1182,7 +1182,7 @@ function cqn4sql(originalQuery, model) {
1182
1182
  if (column.val || column.func || column.SELECT) return [column]
1183
1183
 
1184
1184
  const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1185
- let { baseName, columnAlias = column.as, tableAlias } = names
1185
+ let { baseName, columnAlias = column.as, tableAlias } = names || {}
1186
1186
  const { exclude, replace } = excludeAndReplace || {}
1187
1187
  const { $refLinks, flatName, isJoinRelevant } = column
1188
1188
  let firstNonJoinRelevantAssoc, stepAfterAssoc
@@ -1360,14 +1360,18 @@ function cqn4sql(originalQuery, model) {
1360
1360
  * @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
1361
1361
  * object representing a CQN construct such as a column, an operator,
1362
1362
  * or a subquery.
1363
- * @param {object} [$baseLink=null] - The context in which the `ref`s in the token stream are resolvable.
1364
- * It serves as the reference point for resolving associations in
1365
- * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1366
- * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1363
+ * @param {object} [context] - Optional context object.
1364
+ * @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
1365
+ * It serves as the reference point for resolving associations in
1366
+ * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1367
+ * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1368
+ * @param {string} [context.prop] - The query property which holds the token stream which shall be
1369
+ * transformed by this function, e.g. "where".
1367
1370
  * @returns {object[]} - The transformed token stream.
1368
1371
  */
1369
- function getTransformedTokenStream(tokenStream, $baseLink = null) {
1372
+ function getTransformedTokenStream(tokenStream, context = {}) {
1370
1373
  const transformedTokenStream = []
1374
+ const { $baseLink, /* prop */ } = context
1371
1375
  for (let i = 0; i < tokenStream.length; i++) {
1372
1376
  const token = tokenStream[i]
1373
1377
  if (token === 'exists') {
@@ -1453,7 +1457,7 @@ function cqn4sql(originalQuery, model) {
1453
1457
  if (list.every(e => e.val))
1454
1458
  // no need for transformation
1455
1459
  transformedTokenStream.push({ list })
1456
- else transformedTokenStream.push({ list: getTransformedTokenStream(list, $baseLink) })
1460
+ else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1457
1461
  }
1458
1462
  } else if (tokenStream.length === 1 && token.val && $baseLink) {
1459
1463
  // infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
@@ -1509,13 +1513,13 @@ function cqn4sql(originalQuery, model) {
1509
1513
  i = indexRhs // jump to next relevant index
1510
1514
  } else {
1511
1515
  // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1512
- assertNoStructInXpr(token, $baseLink)
1516
+ assertNoStructInXpr(token, context)
1513
1517
  // reject virtual elements in expressions as they will lead to a sql error down the line
1514
1518
  if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1515
1519
 
1516
1520
  let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1517
1521
  if (token.ref) {
1518
- const { definition } = token.$refLinks[token.$refLinks.length - 1]
1522
+ const { definition } = token.$refLinks.at(-1)
1519
1523
  // Add definition to result
1520
1524
  setElementOnColumns(result, definition)
1521
1525
  if (isCalculatedOnRead(definition)) {
@@ -1536,7 +1540,15 @@ function cqn4sql(originalQuery, model) {
1536
1540
  const lastAssoc =
1537
1541
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1538
1542
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1539
- if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1543
+ if(isAssocOrStruct(definition)) {
1544
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1545
+ if(flat.length === 0)
1546
+ throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1547
+ else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1548
+ throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
1549
+ transformedTokenStream.push(...flat)
1550
+ continue
1551
+ } else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1540
1552
  let name = calculateElementName(token)
1541
1553
  result.ref = [tableAlias, name]
1542
1554
  } else if (tableAlias) {
@@ -1549,7 +1561,7 @@ function cqn4sql(originalQuery, model) {
1549
1561
  result = transformSubquery(token)
1550
1562
  } else {
1551
1563
  if (token.xpr) {
1552
- result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1564
+ result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
1553
1565
  }
1554
1566
  if (token.func && token.args) {
1555
1567
  result.args = getTransformedFunctionArgs(token.args, $baseLink)
@@ -1661,11 +1673,15 @@ function cqn4sql(originalQuery, model) {
1661
1673
  }
1662
1674
  }
1663
1675
 
1664
- function assertNoStructInXpr(token, inInfixFilter = false) {
1665
- if (!inInfixFilter && token.$refLinks?.[token.$refLinks.length - 1].definition.target)
1666
- // REVISIT: let this through if not requested otherwise
1676
+ function assertNoStructInXpr(token, context) {
1677
+ const definition = token.$refLinks?.at(-1).definition
1678
+ if(!definition) return
1679
+ const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1680
+ // unmanaged is always forbidden
1681
+ // expanding a ref in a `where`/`having` context
1682
+ if ((rejectStructs && definition?.target) || definition?.on)
1667
1683
  rejectAssocInExpression()
1668
- if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1684
+ if (rejectStructs && isStructured(definition))
1669
1685
  // REVISIT: let this through if not requested otherwise
1670
1686
  rejectStructInExpression()
1671
1687
 
@@ -1771,7 +1787,7 @@ function cqn4sql(originalQuery, model) {
1771
1787
 
1772
1788
  // only append infix filter to outer where if it is the leaf of the from ref
1773
1789
  if (refReverse[0].where)
1774
- filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
1790
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
1775
1791
 
1776
1792
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
1777
1793
  if (whereExistsSubSelects.length > 0) {
@@ -2209,7 +2225,7 @@ function cqn4sql(originalQuery, model) {
2209
2225
  }
2210
2226
 
2211
2227
  if (customWhere) {
2212
- const filter = getTransformedTokenStream(customWhere, next)
2228
+ const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
2213
2229
  const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2214
2230
  on.push('and', ...wrappedFilter)
2215
2231
  }
@@ -2315,10 +2331,10 @@ function cqn4sql(originalQuery, model) {
2315
2331
  function getTransformedFunctionArgs(args, $baseLink = null) {
2316
2332
  let result = null
2317
2333
  if (Array.isArray(args)) {
2318
- result = args.map(t => {
2334
+ result = args.flatMap(t => {
2319
2335
  if (!t.val)
2320
2336
  // this must not be touched
2321
- return getTransformedTokenStream([t], $baseLink)[0]
2337
+ return getTransformedTokenStream([t], { $baseLink })
2322
2338
  return t
2323
2339
  })
2324
2340
  } else if (typeof args === 'object') {
@@ -2327,7 +2343,7 @@ function cqn4sql(originalQuery, model) {
2327
2343
  const t = args[prop]
2328
2344
  if (!t.val)
2329
2345
  // this must not be touched
2330
- result[prop] = getTransformedTokenStream([t], $baseLink)[0]
2346
+ result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
2331
2347
  else result[prop] = t
2332
2348
  }
2333
2349
  }
@@ -3,20 +3,7 @@ const { _target_name4 } = require('./SQLService')
3
3
 
4
4
  const ROOT = Symbol('root')
5
5
 
6
- // REVISIT: remove old path with cds^8
7
- let _compareJson
8
- const compareJson = (...args) => {
9
- if (!_compareJson) {
10
- try {
11
- // new path
12
- _compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
13
- } catch {
14
- // old path
15
- _compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
16
- }
17
- }
18
- return _compareJson(...args)
19
- }
6
+ const compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
20
7
 
21
8
  const handledDeep = Symbol('handledDeep')
22
9
 
package/lib/search.js CHANGED
@@ -79,6 +79,9 @@ const _getSearchableColumns = entity => {
79
79
  // always ignore virtual elements from search
80
80
  if(column?.virtual) continue
81
81
  if (column?.isAssociation || columnName.includes('.')) {
82
+ if(!annotationValue)
83
+ continue
84
+
82
85
  const ref = columnName.split('.')
83
86
  if(ref.length > 1) skipDefaultSearchableElements = true
84
87
  deepSearchCandidates.push({ ref })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.5.1",
3
+ "version": "2.6.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": {