@cap-js/db-service 2.5.1 → 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,33 @@
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
+
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)
21
+
22
+
23
+ ### Added
24
+
25
+ * **`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))
26
+ * **`flattening`:** allow flattening of structures with exactly one leaf ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
27
+
28
+
29
+ ### Fixed
30
+
31
+ * **`@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))
32
+ * tree table with expand ([#1363](https://github.com/cap-js/cds-dbs/issues/1363)) ([bdad412](https://github.com/cap-js/cds-dbs/commit/bdad412f0362165b532ce35261773e5ecc7c696a))
33
+
7
34
  ## [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
35
 
9
36
 
@@ -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
@@ -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
  }
@@ -1320,7 +1326,7 @@ class CQN2SQLRenderer {
1320
1326
  } else {
1321
1327
  cds.error`Invalid arguments provided for function '${func}' (${args})`
1322
1328
  }
1323
- 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})`
1324
1330
  if (xpr) return `${fn} ${this.xpr({ xpr })}`
1325
1331
  return fn
1326
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
@@ -96,7 +102,7 @@ function cqn4sql(originalQuery, model) {
96
102
 
97
103
  // Transform the existing where, prepend table aliases, and so on...
98
104
  if (where) {
99
- transformedProp.where = getTransformedTokenStream(where)
105
+ transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
100
106
  }
101
107
 
102
108
  // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
@@ -191,7 +197,7 @@ function cqn4sql(originalQuery, model) {
191
197
 
192
198
  // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
193
199
  if (having) {
194
- transformedQuery.SELECT.having = getTransformedTokenStream(having)
200
+ transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
195
201
  }
196
202
 
197
203
  if (groupBy) {
@@ -314,7 +320,7 @@ function cqn4sql(originalQuery, model) {
314
320
  lhs.args.push(arg)
315
321
  alreadySeen.set(nextAssoc.$refLink.alias, true)
316
322
  if (nextAssoc.where) {
317
- const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
323
+ const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
318
324
  lhs.on = [
319
325
  ...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
320
326
  'and',
@@ -521,14 +527,14 @@ function cqn4sql(originalQuery, model) {
521
527
  }
522
528
  }
523
529
 
524
- function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
530
+ function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
525
531
  let value
526
532
 
527
533
  if (column.$refLinks) {
528
534
  const { $refLinks } = column
529
535
  value = $refLinks[$refLinks.length - 1].definition.value
530
536
  if (column.$refLinks.length > 1) {
531
- baseLink =
537
+ $baseLink =
532
538
  [...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
533
539
  // if there is no association in the path, the table alias is the base link
534
540
  // TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
@@ -541,13 +547,13 @@ function cqn4sql(originalQuery, model) {
541
547
 
542
548
  let res
543
549
  if (ref) {
544
- res = getTransformedTokenStream([value], baseLink)[0]
550
+ res = getTransformedTokenStream([value], { $baseLink })[0]
545
551
  } else if (xpr) {
546
- res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
552
+ res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
547
553
  } else if (val !== undefined) {
548
554
  res = { val }
549
555
  } else if (func) {
550
- res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
556
+ res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
551
557
  }
552
558
  if (!omitAlias) res.as = column.as || column.name || column.flatName
553
559
  return res
@@ -1018,7 +1024,7 @@ function cqn4sql(originalQuery, model) {
1018
1024
  * the result.
1019
1025
  */
1020
1026
  if (inOrderBy && flatColumns.length > 1)
1021
- throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
1027
+ throw new Error(`Structured element “${getFullName(leaf)} expands to multiple fields and can't be used in order by`)
1022
1028
  flatColumns.forEach(fc => {
1023
1029
  if (col.nulls) fc.nulls = col.nulls
1024
1030
  if (col.sort) fc.sort = col.sort
@@ -1182,7 +1188,7 @@ function cqn4sql(originalQuery, model) {
1182
1188
  if (column.val || column.func || column.SELECT) return [column]
1183
1189
 
1184
1190
  const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1185
- let { baseName, columnAlias = column.as, tableAlias } = names
1191
+ let { baseName, columnAlias = column.as, tableAlias } = names || {}
1186
1192
  const { exclude, replace } = excludeAndReplace || {}
1187
1193
  const { $refLinks, flatName, isJoinRelevant } = column
1188
1194
  let firstNonJoinRelevantAssoc, stepAfterAssoc
@@ -1360,14 +1366,18 @@ function cqn4sql(originalQuery, model) {
1360
1366
  * @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
1361
1367
  * object representing a CQN construct such as a column, an operator,
1362
1368
  * 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`.
1369
+ * @param {object} [context] - Optional context object.
1370
+ * @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
1371
+ * It serves as the reference point for resolving associations in
1372
+ * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1373
+ * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1374
+ * @param {string} [context.prop] - The query property which holds the token stream which shall be
1375
+ * transformed by this function, e.g. "where".
1367
1376
  * @returns {object[]} - The transformed token stream.
1368
1377
  */
1369
- function getTransformedTokenStream(tokenStream, $baseLink = null) {
1378
+ function getTransformedTokenStream(tokenStream, context = {}) {
1370
1379
  const transformedTokenStream = []
1380
+ const { $baseLink, /* prop */ } = context
1371
1381
  for (let i = 0; i < tokenStream.length; i++) {
1372
1382
  const token = tokenStream[i]
1373
1383
  if (token === 'exists') {
@@ -1453,27 +1463,15 @@ function cqn4sql(originalQuery, model) {
1453
1463
  if (list.every(e => e.val))
1454
1464
  // no need for transformation
1455
1465
  transformedTokenStream.push({ list })
1456
- else transformedTokenStream.push({ list: getTransformedTokenStream(list, $baseLink) })
1466
+ else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1457
1467
  }
1458
1468
  } else if (tokenStream.length === 1 && token.val && $baseLink) {
1459
- // 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
1460
1470
  const def = getDefinition($baseLink.definition.target) || $baseLink.definition
1461
- const keys = def.keys // use key aspect on entity
1462
- const keyValComparisons = []
1463
- const flatKeys = []
1464
- for (const v of Object.values(keys)) {
1465
- if (v !== backlinkFor($baseLink.definition)?.[0]) {
1466
- // up__ID already part of inner where exists, no need to add it explicitly here
1467
- flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1468
- }
1469
- }
1470
- // TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
1471
- if (flatKeys.length > 1)
1472
- throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1473
- flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
1474
- keyValComparisons.forEach((kv, j) =>
1475
- transformedTokenStream.push(...kv) && keyValComparisons[j + 1] ? transformedTokenStream.push('and') : null,
1476
- )
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]);
1477
1475
  } else if (token.ref && token.param) {
1478
1476
  transformedTokenStream.push({ ...token })
1479
1477
  } else if (pseudos.elements[token.ref?.[0]]) {
@@ -1509,13 +1507,13 @@ function cqn4sql(originalQuery, model) {
1509
1507
  i = indexRhs // jump to next relevant index
1510
1508
  } else {
1511
1509
  // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1512
- assertNoStructInXpr(token, $baseLink)
1510
+ assertNoStructInXpr(token, context)
1513
1511
  // reject virtual elements in expressions as they will lead to a sql error down the line
1514
1512
  if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1515
1513
 
1516
1514
  let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1517
1515
  if (token.ref) {
1518
- const { definition } = token.$refLinks[token.$refLinks.length - 1]
1516
+ const { definition } = token.$refLinks.at(-1)
1519
1517
  // Add definition to result
1520
1518
  setElementOnColumns(result, definition)
1521
1519
  if (isCalculatedOnRead(definition)) {
@@ -1536,7 +1534,15 @@ function cqn4sql(originalQuery, model) {
1536
1534
  const lastAssoc =
1537
1535
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1538
1536
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1539
- if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1537
+ if(isAssocOrStruct(definition)) {
1538
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1539
+ if(flat.length === 0)
1540
+ throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1541
+ else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1542
+ throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
1543
+ transformedTokenStream.push(...flat)
1544
+ continue
1545
+ } else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1540
1546
  let name = calculateElementName(token)
1541
1547
  result.ref = [tableAlias, name]
1542
1548
  } else if (tableAlias) {
@@ -1549,7 +1555,7 @@ function cqn4sql(originalQuery, model) {
1549
1555
  result = transformSubquery(token)
1550
1556
  } else {
1551
1557
  if (token.xpr) {
1552
- result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1558
+ result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
1553
1559
  }
1554
1560
  if (token.func && token.args) {
1555
1561
  result.args = getTransformedFunctionArgs(token.args, $baseLink)
@@ -1661,11 +1667,15 @@ function cqn4sql(originalQuery, model) {
1661
1667
  }
1662
1668
  }
1663
1669
 
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
1670
+ function assertNoStructInXpr(token, context) {
1671
+ const definition = token.$refLinks?.at(-1).definition
1672
+ if(!definition) return
1673
+ const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1674
+ // unmanaged is always forbidden
1675
+ // expanding a ref in a `where`/`having` context
1676
+ if ((rejectStructs && definition?.target) || definition?.on)
1667
1677
  rejectAssocInExpression()
1668
- if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1678
+ if (rejectStructs && isStructured(definition))
1669
1679
  // REVISIT: let this through if not requested otherwise
1670
1680
  rejectStructInExpression()
1671
1681
 
@@ -1769,9 +1779,10 @@ function cqn4sql(originalQuery, model) {
1769
1779
  }
1770
1780
  }
1771
1781
 
1772
- // only append infix filter to outer where if it is the leaf of the from ref
1773
- if (refReverse[0].where)
1774
- filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
1782
+ // OData variant w/o mentioning key
1783
+ if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1784
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
1785
+ }
1775
1786
 
1776
1787
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
1777
1788
  if (whereExistsSubSelects.length > 0) {
@@ -2209,7 +2220,7 @@ function cqn4sql(originalQuery, model) {
2209
2220
  }
2210
2221
 
2211
2222
  if (customWhere) {
2212
- const filter = getTransformedTokenStream(customWhere, next)
2223
+ const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
2213
2224
  const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2214
2225
  on.push('and', ...wrappedFilter)
2215
2226
  }
@@ -2267,6 +2278,12 @@ function cqn4sql(originalQuery, model) {
2267
2278
  if (!node || !node.$refLinks || !node.ref) {
2268
2279
  throw new Error('Invalid node')
2269
2280
  }
2281
+ if(node.$refLinks[0].$main) {
2282
+ if (node.isJoinRelevant) {
2283
+ return getJoinRelevantAlias(node)
2284
+ }
2285
+ return node.$refLinks[0].alias
2286
+ }
2270
2287
  if ($baseLink) {
2271
2288
  return getBaseLinkAlias($baseLink)
2272
2289
  }
@@ -2315,10 +2332,10 @@ function cqn4sql(originalQuery, model) {
2315
2332
  function getTransformedFunctionArgs(args, $baseLink = null) {
2316
2333
  let result = null
2317
2334
  if (Array.isArray(args)) {
2318
- result = args.map(t => {
2335
+ result = args.flatMap(t => {
2319
2336
  if (!t.val)
2320
2337
  // this must not be touched
2321
- return getTransformedTokenStream([t], $baseLink)[0]
2338
+ return getTransformedTokenStream([t], { $baseLink })
2322
2339
  return t
2323
2340
  })
2324
2341
  } else if (typeof args === 'object') {
@@ -2327,7 +2344,7 @@ function cqn4sql(originalQuery, model) {
2327
2344
  const t = args[prop]
2328
2345
  if (!t.val)
2329
2346
  // this must not be touched
2330
- result[prop] = getTransformedTokenStream([t], $baseLink)[0]
2347
+ result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
2331
2348
  else result[prop] = t
2332
2349
  }
2333
2350
  }
@@ -2402,6 +2419,12 @@ function assignQueryModifiers(SELECT, modifiers) {
2402
2419
  } else if (key === 'having') {
2403
2420
  if (!SELECT.having) SELECT.having = val
2404
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)]
2405
2428
  }
2406
2429
  }
2407
2430
  }
@@ -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
 
@@ -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/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.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
  }