@cap-js/db-service 2.3.0 → 2.4.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,24 @@
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.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.3.0...db-service-v2.4.0) (2025-08-27)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`tuple expansion`:** allow structs with exactly one element/fk in comparison ([#1291](https://github.com/cap-js/cds-dbs/issues/1291)) ([75ea826](https://github.com/cap-js/cds-dbs/commit/75ea82694faeafcaf78df9d4b0bbce37b4f65b63))
13
+ * cds.db.foreach uses real object mode streaming ([#1318](https://github.com/cap-js/cds-dbs/issues/1318)) ([cd28b53](https://github.com/cap-js/cds-dbs/commit/cd28b53966dbe28ad1d5ef3827767e78742e0fbd))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`assoc2join`:** target side access detection ([#1282](https://github.com/cap-js/cds-dbs/issues/1282)) ([6f9befa](https://github.com/cap-js/cds-dbs/commit/6f9befa24a06bcc629fe853aa66290613734c3ef))
19
+ * **`cqn4sql`:** only consider `own` property `[@cds](https://github.com/cds).persistence.skip` ([#1324](https://github.com/cap-js/cds-dbs/issues/1324)) ([bd1f52f](https://github.com/cap-js/cds-dbs/commit/bd1f52f67fb4709dce3a27fea8856cb9b875da6b))
20
+ * **`exists`:** do not loose custom where ([#1322](https://github.com/cap-js/cds-dbs/issues/1322)) ([644918c](https://github.com/cap-js/cds-dbs/commit/644918c56d9d939f43f4a0346f42e16722bd6fe9))
21
+ * arithmetic operators can only be used with scalar operands ([#1307](https://github.com/cap-js/cds-dbs/issues/1307)) ([d58d335](https://github.com/cap-js/cds-dbs/commit/d58d33539e22f818d18240bb86ba596fc6fe21d1))
22
+ * detect path expression inside nested xpr after `exists` ([#1292](https://github.com/cap-js/cds-dbs/issues/1292)) ([852d915](https://github.com/cap-js/cds-dbs/commit/852d9155d5bb09a56a6c152259c8282662ceb29d)), closes [#1225](https://github.com/cap-js/cds-dbs/issues/1225)
23
+ * reject comparison of two empty structures ([#1306](https://github.com/cap-js/cds-dbs/issues/1306)) ([d97304d](https://github.com/cap-js/cds-dbs/commit/d97304d95c7f629afe75aba57192277b7124eb3e))
24
+
7
25
  ## [2.3.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.2.0...db-service-v2.3.0) (2025-07-28)
8
26
 
9
27
 
package/lib/SQLService.js CHANGED
@@ -355,6 +355,15 @@ class SQLService extends DatabaseService {
355
355
  return count
356
356
  }
357
357
 
358
+ /**
359
+ * Streaming API variant of .run().
360
+ * @param {import('@sap/cds/apis/cqn').SELECT} query - SELECT CQN
361
+ * @param {function} callback - Function to be invoked for each row
362
+ */
363
+ foreach (query, callback) {
364
+ return query.foreach(callback)
365
+ }
366
+
358
367
  /**
359
368
  * Helper class for results of INSERTs.
360
369
  * Subclasses may override this.
package/lib/cqn4sql.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  getImplicitAlias,
13
13
  defineProperty,
14
14
  getModelUtils,
15
+ hasOwnSkip,
15
16
  } = require('./utils')
16
17
 
17
18
  /**
@@ -20,14 +21,14 @@ const {
20
21
  */
21
22
  const eqOps = [['is'], ['='] /* ['=='] */]
22
23
  /**
23
- * For operators of <notEqOps>, do the same but use or instead of and.
24
- * This ensures that not struct == <value> is the same as struct != <value>.
24
+ * For operators of <notEqOps>, do the same but use `or` instead of `and`.
25
+ * This ensures that `not struct == <value>` is the same as `struct != <value>`.
25
26
  */
26
27
  const notEqOps = [['is', 'not'], ['<>'], ['!=']]
27
28
  /**
28
29
  * not supported in comparison w/ struct because of unclear semantics
29
30
  */
30
- const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
31
+ const notSupportedOps = [['>'], ['<'], ['>='], ['<='], ['*'], ['+'], ['-'], ['/']]
31
32
 
32
33
  const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
33
34
 
@@ -467,7 +468,7 @@ function cqn4sql(originalQuery, model) {
467
468
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
468
469
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
469
470
 
470
- if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)) return
471
+ if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
471
472
 
472
473
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
473
474
  flatColumns.forEach(flatColumn => {
@@ -966,7 +967,7 @@ function cqn4sql(originalQuery, model) {
966
967
  } else if (pseudos.elements[col.ref?.[0]]) {
967
968
  res.push({ ...col })
968
969
  } else if (col.ref) {
969
- if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true))
970
+ if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
970
971
  continue
971
972
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
972
973
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1189,7 +1190,7 @@ function cqn4sql(originalQuery, model) {
1189
1190
  let { baseName, columnAlias = column.as, tableAlias } = names
1190
1191
  const { exclude, replace } = excludeAndReplace || {}
1191
1192
  const { $refLinks, flatName, isJoinRelevant } = column
1192
- let leafAssoc
1193
+ let firstNonJoinRelevantAssoc, stepAfterAssoc
1193
1194
  let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
1194
1195
  if (isWildcard && element.type === 'cds.LargeBinary') return []
1195
1196
  if (element.on && !element.keys)
@@ -1197,14 +1198,23 @@ function cqn4sql(originalQuery, model) {
1197
1198
  else if (element.virtual === true) return []
1198
1199
  else if (!isJoinRelevant && flatName) baseName = flatName
1199
1200
  else if (isJoinRelevant) {
1200
- const leaf = column.$refLinks.at(-1)
1201
- leafAssoc = [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1202
- let elements = leafAssoc.definition.elements || leafAssoc.definition.foreignKeys
1203
- if (elements && leaf.definition.name in elements) {
1204
- element = leafAssoc.definition
1205
- baseName = getFullName(leafAssoc.definition)
1201
+ const leafAssocIndex = column.$refLinks.findIndex(link => link.definition.isAssociation && link.onlyForeignKeyAccess)
1202
+ firstNonJoinRelevantAssoc = column.$refLinks[leafAssocIndex] || [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1203
+ stepAfterAssoc = column.$refLinks.at(leafAssocIndex + 1) || column.$refLinks.at(-1)
1204
+ let elements = firstNonJoinRelevantAssoc.definition.elements || firstNonJoinRelevantAssoc.definition.foreignKeys
1205
+ if (elements && stepAfterAssoc.definition.name in elements) {
1206
+ element = firstNonJoinRelevantAssoc.definition
1207
+ baseName = getFullName(firstNonJoinRelevantAssoc.definition)
1206
1208
  columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1207
1209
  } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
1210
+
1211
+ if(column.element && !isAssocOrStruct(column.element)) {
1212
+ columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1213
+ const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1214
+ setElementOnColumns(res, element)
1215
+ return [res]
1216
+ }
1217
+
1208
1218
  } else if (!baseName && structsAreUnfoldedAlready) {
1209
1219
  baseName = element.name // name is already fully constructed
1210
1220
  } else {
@@ -1245,11 +1255,11 @@ function cqn4sql(originalQuery, model) {
1245
1255
  const flattenThisForeignKey =
1246
1256
  !$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
1247
1257
  element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
1248
- keyElement === $refLinks.at(-1).definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1258
+ keyElement === stepAfterAssoc.definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1249
1259
  if (flattenThisForeignKey) {
1250
1260
  const fkElement = getElementForRef(k.ref, getDefinition(element.target))
1251
1261
  let fkBaseName
1252
- if (!leafAssoc || leafAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
1262
+ if (!firstNonJoinRelevantAssoc || firstNonJoinRelevantAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
1253
1263
  // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
1254
1264
  else fkBaseName = k.ref.at(-1)
1255
1265
  const fkPath = [...csnPath, k.ref.at(-1)]
@@ -1462,6 +1472,7 @@ function cqn4sql(originalQuery, model) {
1462
1472
  flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1463
1473
  }
1464
1474
  }
1475
+ // TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
1465
1476
  if (flatKeys.length > 1)
1466
1477
  throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1467
1478
  flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
@@ -1474,35 +1485,38 @@ function cqn4sql(originalQuery, model) {
1474
1485
  transformedTokenStream.push({ ...token })
1475
1486
  } else {
1476
1487
  // expand `struct = null | struct2`
1477
- const definition = token.$refLinks?.at(-1).definition
1478
1488
  const next = tokenStream[i + 1]
1479
- if (allOps.some(([firstOp]) => firstOp === next) && (definition?.elements || definition?.keys)) {
1489
+ let indexRhs = i + 2
1490
+ let rhs = tokenStream[indexRhs] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1491
+ const lhsDef = token.$refLinks?.at(-1).definition
1492
+ let rhsDef = rhs?.$refLinks?.at(-1)?.definition
1493
+ if (
1494
+ allOps.some(([firstOp]) => firstOp === next) &&
1495
+ (lhsDef?.elements || lhsDef?.keys || rhsDef?.elements || rhsDef?.keys)
1496
+ ) {
1480
1497
  const ops = [next]
1481
- let indexRhs = i + 2
1482
- let rhs = tokenStream[i + 2] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1483
1498
  if (allOps.some(([, secondOp]) => secondOp === rhs)) {
1484
1499
  ops.push(rhs)
1485
1500
  rhs = tokenStream[i + 3]
1486
1501
  indexRhs += 1
1502
+ rhsDef = rhs?.$refLinks?.at(-1)?.definition
1487
1503
  }
1488
- if (
1489
- isAssocOrStruct(rhs.$refLinks?.[rhs.$refLinks.length - 1].definition) ||
1490
- rhs.val !== undefined ||
1491
- /* unary operator `is null` parsed as string */
1492
- rhs === 'null'
1493
- ) {
1494
- if (notSupportedOps.some(([firstOp]) => firstOp === next))
1495
- throw new Error(`The operator "${next}" is not supported for structure comparison`)
1496
- const newTokens = expandComparison(token, ops, rhs, $baseLink)
1497
- const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1498
- transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1499
- i = indexRhs // jump to next relevant index
1500
- }
1504
+
1505
+ if (notSupportedOps.some(([firstOp]) => firstOp === next))
1506
+ throw new Error(`The operator "${next}" can only be used with scalar operands`)
1507
+
1508
+ const newTokens = expandComparison(token, ops, rhs, $baseLink)
1509
+ if(newTokens.length === 0)
1510
+ throw new Error(`Can't compare two empty structures`)
1511
+
1512
+ const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1513
+ transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1514
+ i = indexRhs // jump to next relevant index
1501
1515
  } else {
1502
1516
  // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1503
1517
  assertNoStructInXpr(token, $baseLink)
1504
1518
  // reject virtual elements in expressions as they will lead to a sql error down the line
1505
- if (definition?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1519
+ if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1506
1520
 
1507
1521
  let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1508
1522
  if (token.ref) {
@@ -1528,7 +1542,7 @@ function cqn4sql(originalQuery, model) {
1528
1542
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1529
1543
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1530
1544
  if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1531
- let name = calculateElementName(token, getFullName)
1545
+ let name = calculateElementName(token)
1532
1546
  result.ref = [tableAlias, name]
1533
1547
  } else if (tableAlias) {
1534
1548
  result.ref = [tableAlias, token.flatName]
@@ -1557,9 +1571,9 @@ function cqn4sql(originalQuery, model) {
1557
1571
  /**
1558
1572
  * Expand the given definition and compare all leafs to `val`.
1559
1573
  *
1560
- * @param {object} token with $refLinks
1574
+ * @param {object} lhs with $refLinks
1561
1575
  * @param {string} operator one of allOps
1562
- * @param {object} value either `null` or a column (with `ref` and `$refLinks`)
1576
+ * @param {object} rhs either `null` or a column (with `ref` and `$refLinks`)
1563
1577
  * @param {object} $baseLink optional base `$refLink`, e.g. for infix filters of scoped queries.
1564
1578
  * In the following example, we must pass `bookshop:Reproduce` as $baseLink for `author`:
1565
1579
  *
@@ -1567,26 +1581,21 @@ function cqn4sql(originalQuery, model) {
1567
1581
  * ^^^^^^
1568
1582
  * @returns {array}
1569
1583
  */
1570
- function expandComparison(token, operator, value, $baseLink = null) {
1571
- const { definition } = token.$refLinks[token.$refLinks.length - 1]
1572
- let flatRhs
1584
+ function expandComparison(lhs, operator, rhs, $baseLink = null) {
1585
+ const { definition: lhsDef, val: lhsVal } = lhs.val ? lhs : lhs.$refLinks.at(-1)
1586
+ const { definition: rhsDef, val: rhsVal } = rhs.val ? rhs : rhs.$refLinks?.at(-1) || {}
1573
1587
  const result = []
1574
- if (value.$refLinks) {
1575
- // structural comparison
1576
- flatRhs = flattenWithBaseName(value)
1577
- }
1578
-
1579
- if (flatRhs) {
1580
- const flatLhs = flattenWithBaseName(token)
1581
- // make sure we can compare both structures
1582
- if (flatRhs.length !== flatLhs.length) {
1583
- throw new Error(
1584
- `Can't compare "${definition.name}" with "${
1585
- value.$refLinks[value.$refLinks.length - 1].definition.name
1586
- }": the operands must have the same structure`,
1587
- )
1588
- }
1589
-
1588
+ if (lhsDef && rhsDef) {
1589
+ // both must be structured
1590
+ const lhsIsStructured = isAssocOrStruct(lhsDef)
1591
+ const rhsIsStructured = isAssocOrStruct(rhsDef)
1592
+ if (!lhsIsStructured)
1593
+ throw new Error(`Can't compare structure “${rhs.ref.map(idOnly).join('.')}” with non-structure “${lhs.ref.map(idOnly).join('.')}”`)
1594
+ if (!rhsIsStructured)
1595
+ throw new Error(`Can't compare structure “${lhs.ref.map(idOnly).join('.')}” with non-structure “${rhs.ref.map(idOnly).join('.')}”`)
1596
+
1597
+ const flatLhs = flattenWithBaseName(lhs)
1598
+ const flatRhs = flattenWithBaseName(rhs)
1590
1599
  const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1591
1600
  while (flatLhs.length > 0) {
1592
1601
  // retrieve and remove one flat element from LHS and search for it in RHS (remove it there too)
@@ -1598,27 +1607,47 @@ function cqn4sql(originalQuery, model) {
1598
1607
  })
1599
1608
  // not found in rhs --> exit
1600
1609
  if (indexOfElementOnRhs === -1) {
1601
- const lhsPath = token.ref.join('.')
1602
- const rhsPath = value.ref.join('.')
1610
+ const lhsPath = lhs.ref.map(idOnly).join('.')
1611
+ const rhsPath = rhs.ref.map(idOnly).join('.')
1603
1612
  throw new Error(`Can't compare "${lhsPath}" with "${rhsPath}": the operands must have the same structure`)
1604
1613
  }
1605
- const rhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1606
- result.push({ ref }, ...operator, rhs)
1614
+ const cleansedRhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1615
+ result.push({ ref }, ...operator, cleansedRhs)
1607
1616
  if (flatLhs.length > 0) result.push(boolOp)
1608
1617
  }
1609
- } else {
1618
+ } else if (lhsDef && (rhsVal || rhs === 'null' || rhs.val === null)) {
1610
1619
  // compare with value
1611
- const flatLhs = flattenWithBaseName(token)
1612
- if (flatLhs.length > 1 && value.val !== null && value !== 'null')
1613
- throw new Error(`Can't compare structure "${token.ref.join('.')}" with value "${value.val}"`)
1620
+ const flatLhs = flattenWithBaseName(lhs)
1621
+ if (flatLhs.length !== 1 && rhsVal && rhs !== 'null')
1622
+ canOnlyCompareToExactlyOneLeaf(lhsDef, lhs.ref, rhsVal)
1623
+
1614
1624
  const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1615
1625
  flatLhs.forEach((column, i) => {
1616
- result.push(column, ...operator, value)
1626
+ result.push(column, ...operator, rhs)
1617
1627
  if (flatLhs[i + 1]) result.push(boolOp)
1618
1628
  })
1629
+ } else if (lhsVal && rhsDef) {
1630
+ const flatRhs = flattenWithBaseName(rhs)
1631
+ // comparing a struct to a value is ok if structure has exactly one leaf
1632
+ if (flatRhs.length !== 1 && lhsVal)
1633
+ canOnlyCompareToExactlyOneLeaf(rhsDef, rhs.ref, lhsVal)
1634
+
1635
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1636
+ flatRhs.forEach((column, i) => {
1637
+ result.push(lhs, ...operator, column)
1638
+ if (flatRhs[i + 1]) result.push(boolOp)
1639
+ })
1619
1640
  }
1620
1641
  return result
1621
1642
 
1643
+ function canOnlyCompareToExactlyOneLeaf(struct, structRef, val) {
1644
+ const what = struct.isAssociation ? 'association' : 'structure'
1645
+ const postfix = struct.isAssociation ? 'associations with one foreign key' : 'structures with one sub-element'
1646
+ throw new Error(
1647
+ `Can't compare ${what} "${structRef.map(idOnly).join('.')}" to value "${val}"; only possible for ${postfix}`
1648
+ )
1649
+ }
1650
+
1622
1651
  function flattenWithBaseName(def) {
1623
1652
  if (!def.$refLinks) return def
1624
1653
  const leaf = def.$refLinks[def.$refLinks.length - 1]
@@ -2127,7 +2156,7 @@ function cqn4sql(originalQuery, model) {
2127
2156
  * @param {boolean} inWhere whether or not the path is part of the queries where clause
2128
2157
  * -> if it is, target and source side are flipped in the where exists subquery
2129
2158
  * @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
2130
- *
2159
+ *
2131
2160
  * @returns {CQN.SELECT}
2132
2161
  */
2133
2162
  function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
@@ -2165,26 +2194,29 @@ function cqn4sql(originalQuery, model) {
2165
2194
  where: on,
2166
2195
  }
2167
2196
  // this requires sub-sequent transformation of the subquery
2168
- if (next.pathExpressionInsideFilter || (queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))) {
2169
- SELECT.where = next.pathExpressionInsideFilter ? customWhere : [];
2170
- if (queryModifier) assignQueryModifiers(SELECT, queryModifier);
2171
-
2172
- const transformedExists = transformSubquery({ SELECT });
2197
+ if (
2198
+ next.pathExpressionInsideFilter ||
2199
+ (queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))
2200
+ ) {
2201
+ SELECT.where = customWhere || []
2202
+ if (queryModifier) assignQueryModifiers(SELECT, queryModifier)
2203
+
2204
+ const transformedExists = transformSubquery({ SELECT })
2173
2205
  if (transformedExists.SELECT.where?.length) {
2174
- const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2175
- ? [asXpr(transformedExists.SELECT.where)]
2176
- : transformedExists.SELECT.where;
2206
+ const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2207
+ ? [asXpr(transformedExists.SELECT.where)]
2208
+ : transformedExists.SELECT.where
2177
2209
 
2178
- on.push('and', ...wrappedWhere);
2210
+ on.push('and', ...wrappedWhere)
2179
2211
  }
2180
- transformedExists.SELECT.where = on;
2181
- return transformedExists.SELECT;
2212
+ transformedExists.SELECT.where = on
2213
+ return transformedExists.SELECT
2182
2214
  }
2183
2215
 
2184
2216
  if (customWhere) {
2185
- const filter = getTransformedTokenStream(customWhere, next);
2186
- const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter;
2187
- on.push('and', ...wrappedFilter);
2217
+ const filter = getTransformedTokenStream(customWhere, next)
2218
+ const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2219
+ on.push('and', ...wrappedFilter)
2188
2220
  }
2189
2221
  return SELECT
2190
2222
  }
@@ -2349,7 +2381,6 @@ function getParentEntity(element) {
2349
2381
  else return getParentEntity(element.parent)
2350
2382
  }
2351
2383
 
2352
-
2353
2384
  function asXpr(thing) {
2354
2385
  return { xpr: thing }
2355
2386
  }
@@ -4,7 +4,7 @@ const cds = require('@sap/cds')
4
4
 
5
5
  const JoinTree = require('./join-tree')
6
6
  const { pseudos } = require('./pseudos')
7
- const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty } = require('../utils')
7
+ const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip } = require('../utils')
8
8
  const cdsTypes = cds.linked({
9
9
  definitions: {
10
10
  Timestamp: { type: 'cds.Timestamp' },
@@ -404,7 +404,7 @@ function infer(originalQuery, model) {
404
404
  if (arg.list) arg.list.forEach(arg => inferArg(arg, null, $baseLink, context))
405
405
  if (arg.xpr)
406
406
  arg.xpr.forEach((token, i) =>
407
- inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: arg.xpr[i - 1] === 'exists' }),
407
+ inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: inExists || arg.xpr[i - 1] === 'exists' }),
408
408
  ) // e.g. function in expression
409
409
 
410
410
  if (!arg.ref) {
@@ -587,7 +587,7 @@ function infer(originalQuery, model) {
587
587
  }
588
588
 
589
589
  arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
590
- if (getDefinition(arg.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true) isPersisted = false
590
+ if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
591
591
  if (!arg.ref[i + 1]) {
592
592
  const flatName = nameSegments.join('_')
593
593
  defineProperty(arg, 'flatName', flatName)
@@ -640,7 +640,7 @@ function infer(originalQuery, model) {
640
640
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
641
641
  if (arg.expand) {
642
642
  const { $refLinks } = arg
643
- const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)
643
+ const skip = $refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))
644
644
  if (skip) {
645
645
  $refLinks[$refLinks.length - 1].skipExpand = true
646
646
  return
@@ -937,8 +937,15 @@ function infer(originalQuery, model) {
937
937
  return true
938
938
  }
939
939
  if (assoc) {
940
+ // if(!link.definition.isAssociation) continue
941
+ let fkIndex = assoc.keys?.findIndex(key => key.ref.every((step, j) => column.ref[i + j] === step))
940
942
  // foreign key access without filters never join relevant
941
- if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
943
+ if (fkIndex !== -1) {
944
+ if(column.ref.slice(i).some(s => s.where)) continue // probably join relevant later on
945
+ fkAccess = true
946
+ assoc = null
947
+ continue
948
+ }
942
949
  // <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
943
950
  return true
944
951
  }
@@ -212,6 +212,8 @@ class JoinTree {
212
212
  // filter is always join relevant
213
213
  // if the column ends up in an `inline` -> each assoc step is join relevant
214
214
  child.$refLink.onlyForeignKeyAccess = false
215
+ // all parents are now also join relevant
216
+ markParentAsJoinRelevant(child.parent)
215
217
  } else {
216
218
  child.$refLink.onlyForeignKeyAccess = true
217
219
  }
@@ -223,6 +225,8 @@ class JoinTree {
223
225
  if (node.$refLink && (!elements || !(child.$refLink.definition.name in elements))) {
224
226
  // no foreign key access
225
227
  node.$refLink.onlyForeignKeyAccess = false
228
+ markParentAsJoinRelevant(node.parent)
229
+
226
230
  col.$refLinks[i - 1] = node.$refLink
227
231
  }
228
232
 
@@ -233,6 +237,15 @@ class JoinTree {
233
237
  }
234
238
  return true
235
239
 
240
+ function markParentAsJoinRelevant(parent) {
241
+ while (parent) {
242
+ if (parent.$refLink?.definition.isAssociation) {
243
+ parent.$refLink.onlyForeignKeyAccess = false
244
+ }
245
+ parent = parent.parent
246
+ }
247
+ }
248
+
236
249
  function joinId(step, args, where) {
237
250
  let appendix
238
251
  if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
package/lib/utils.js CHANGED
@@ -21,6 +21,12 @@ function prettyPrintRef(ref, model = null) {
21
21
  }, '')
22
22
  }
23
23
 
24
+ function hasOwnSkip(definition) {
25
+ return (
26
+ definition && Object.hasOwn(definition, '@cds.persistence.skip') && definition['@cds.persistence.skip'] === true
27
+ )
28
+ }
29
+
24
30
  /**
25
31
  * Determines if a definition is calculated on read.
26
32
  * - Stored calculated elements are not unfolded
@@ -123,7 +129,6 @@ function getModelUtils(model, query) {
123
129
  if (!def || !isLocalized(def)) return def
124
130
  return model.definitions[`localized.${def.name}`] || def
125
131
  }
126
-
127
132
  return {
128
133
  getLocalizedName,
129
134
  isLocalized,
@@ -139,4 +144,5 @@ module.exports = {
139
144
  getImplicitAlias,
140
145
  defineProperty,
141
146
  getModelUtils,
147
+ hasOwnSkip,
142
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.3.0",
3
+ "version": "2.4.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": {