@cap-js/db-service 2.2.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,40 @@
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
+
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)
26
+
27
+
28
+ ### Added
29
+
30
+ * **`exists`:** support additional query modifiers ([#1261](https://github.com/cap-js/cds-dbs/issues/1261)) ([1394b46](https://github.com/cap-js/cds-dbs/commit/1394b46b10f628748dd1f945095ab4ce7f3963f6))
31
+ * **hierarchy:** LimitedRank ([#1268](https://github.com/cap-js/cds-dbs/issues/1268)) ([52e16db](https://github.com/cap-js/cds-dbs/commit/52e16db2c83d06e318ea05947a3c3c3153bd3ab2))
32
+
33
+
34
+ ### Fixed
35
+
36
+ * `count` subquery for queries with only `expand` columns ([#1264](https://github.com/cap-js/cds-dbs/issues/1264)) ([097a332](https://github.com/cap-js/cds-dbs/commit/097a332f78156526823b2088bdc35278aea09854))
37
+ * **cqn4sql:** multiple path expressions in where clause ([#1272](https://github.com/cap-js/cds-dbs/issues/1272)) ([9b35366](https://github.com/cap-js/cds-dbs/commit/9b353660f4c2568176f57baa642ab2b052cfcff9))
38
+ * **hierarchy:** only modify where if existent ([#1265](https://github.com/cap-js/cds-dbs/issues/1265)) ([eaca855](https://github.com/cap-js/cds-dbs/commit/eaca855ec06087e22bf780fac5e4010ef1b5ff4f))
39
+ * TypeError for empty `list` ([#1269](https://github.com/cap-js/cds-dbs/issues/1269)) ([f262718](https://github.com/cap-js/cds-dbs/commit/f26271813e161c9f7c05fbc82b10c4d5f14916a7))
40
+
7
41
  ## [2.2.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.1.2...db-service-v2.2.0) (2025-06-30)
8
42
 
9
43
 
package/lib/SQLService.js CHANGED
@@ -337,9 +337,11 @@ class SQLService extends DatabaseService {
337
337
 
338
338
  // Keep original query columns when potentially used insde conditions
339
339
  const { having, groupBy } = query.SELECT
340
- const columns = (having?.length || groupBy?.length)
341
- ? query.SELECT.columns.filter(c => !c.expand)
342
- : [{ val: 1 }]
340
+ let columns = []
341
+ if((having?.length || groupBy?.length)) {
342
+ columns = query.SELECT.columns.filter(c => !c.expand)
343
+ }
344
+ if (columns.length === 0) columns.push({ val: 1 })
343
345
  const cq = SELECT.one([{ func: 'count' }]).from(
344
346
  cds.ql.clone(query, {
345
347
  columns,
@@ -353,6 +355,15 @@ class SQLService extends DatabaseService {
353
355
  return count
354
356
  }
355
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
+
356
367
  /**
357
368
  * Helper class for results of INSERTs.
358
369
  * Subclasses may override this.
package/lib/cqn2sql.js CHANGED
@@ -282,10 +282,10 @@ class CQN2SQLRenderer {
282
282
  SELECT_recurse(q) {
283
283
  let { from, columns, where, orderBy, recurse, _internal } = q.SELECT
284
284
 
285
- const keys = []
286
285
  const _target = q._target
287
286
 
288
- if (_target) {
287
+ if (_target && where) {
288
+ const keys = []
289
289
  for (const _key in _target.keys) {
290
290
  const k = _target.keys[_key]
291
291
  if (!k.virtual && !k.isAssociation && !k.value) {
@@ -324,6 +324,7 @@ class CQN2SQLRenderer {
324
324
  DistanceFromRoot: { xpr: [{ ref: ['HIERARCHY_LEVEL'] }, '-', { val: 1, param: false }], as: 'DistanceFromRoot' },
325
325
  DrillState: false,
326
326
  LimitedDescendantCount: { xpr: [{ ref: ['HIERARCHY_TREE_SIZE'] }, '-', { val: 1, param: false }], as: 'LimitedDescendantCount' },
327
+ LimitedRank: { xpr: [{ func: 'row_number', args: [] }, 'OVER', { xpr: [] }, '-', { val: 1, param: false }], as: 'LimitedRank' }
327
328
  }
328
329
 
329
330
  const columnsFiltered = columns
@@ -1176,7 +1177,7 @@ class CQN2SQLRenderer {
1176
1177
  .map((x, i) => {
1177
1178
  if (x in { LIKE: 1, like: 1 } && is_regexp(xpr[i + 1]?.val)) return this.operator('regexp')
1178
1179
  if (typeof x === 'string') return this.operator(x, i, xpr)
1179
- if (x.xpr) return `(${this.xpr(x)})`
1180
+ if (x.xpr && !x.func) return `(${this.xpr(x)})`
1180
1181
  else return this.expr(x)
1181
1182
  })
1182
1183
  .join(' ')
package/lib/cqn4sql.js CHANGED
@@ -5,7 +5,15 @@ cds.infer.target ??= q => q._target || q.target // instanceof cds.entity ? q._ta
5
5
 
6
6
  const infer = require('./infer')
7
7
  const { computeColumnsToBeSearched } = require('./search')
8
- const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement, getImplicitAlias, defineProperty, getModelUtils } = require('./utils')
8
+ const {
9
+ prettyPrintRef,
10
+ isCalculatedOnRead,
11
+ isCalculatedElement,
12
+ getImplicitAlias,
13
+ defineProperty,
14
+ getModelUtils,
15
+ hasOwnSkip,
16
+ } = require('./utils')
9
17
 
10
18
  /**
11
19
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -13,14 +21,14 @@ const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement, getImplicitAlia
13
21
  */
14
22
  const eqOps = [['is'], ['='] /* ['=='] */]
15
23
  /**
16
- * For operators of <notEqOps>, do the same but use or instead of and.
17
- * 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>`.
18
26
  */
19
27
  const notEqOps = [['is', 'not'], ['<>'], ['!=']]
20
28
  /**
21
29
  * not supported in comparison w/ struct because of unclear semantics
22
30
  */
23
- const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
31
+ const notSupportedOps = [['>'], ['<'], ['>='], ['<='], ['*'], ['+'], ['-'], ['/']]
24
32
 
25
33
  const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
26
34
 
@@ -63,19 +71,8 @@ function cqn4sql(originalQuery, model) {
63
71
  }
64
72
  // query modifiers can also be defined in from ref leaf infix filter
65
73
  // > SELECT from bookshop.Books[order by price] {ID}
66
- if (inferred.SELECT?.from.ref) {
67
- for (const [key, val] of Object.entries(inferred.SELECT.from.ref.at(-1))) {
68
- if (key in { orderBy: 1, groupBy: 1 }) {
69
- if (inferred.SELECT[key]) inferred.SELECT[key].push(...val)
70
- else inferred.SELECT[key] = val
71
- } else if (key === 'limit') {
72
- // limit defined on the query has precedence
73
- if (!inferred.SELECT.limit) inferred.SELECT.limit = val
74
- } else if (key === 'having') {
75
- if (!inferred.SELECT.having) inferred.SELECT.having = val
76
- else inferred.SELECT.having.push('and', ...val)
77
- }
78
- }
74
+ if (inferred.SELECT?.from.ref?.at(-1).id) {
75
+ assignQueryModifiers(inferred.SELECT, inferred.SELECT.from.ref.at(-1))
79
76
  }
80
77
  inferred = infer(inferred, model)
81
78
  const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
@@ -95,6 +92,7 @@ function cqn4sql(originalQuery, model) {
95
92
  const from = queryProp.from
96
93
 
97
94
  const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
95
+ const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
98
96
 
99
97
  // Transform the existing where, prepend table aliases, and so on...
100
98
  if (where) {
@@ -104,7 +102,47 @@ function cqn4sql(originalQuery, model) {
104
102
  // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
105
103
  // The already transformed `where` clause is then glued together with the resulting subqueries.
106
104
  const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
107
- const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
105
+
106
+ // build a subquery for DELETE / UPDATE queries with path expressions and match the primary keys
107
+ if (queryNeedsJoins && (inferred.UPDATE || inferred.DELETE)) {
108
+ const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
109
+ const subquery = {
110
+ SELECT: {
111
+ from: { ...(from || entity) },
112
+ columns: [], // primary keys of the query target will be added later
113
+ where: [...where],
114
+ },
115
+ }
116
+ // The alias of the original query is now the alias for the subquery
117
+ // so that potential references in the where clause to the alias match.
118
+ // Hence, replace the alias of the original query with the next
119
+ // available alias, so that each alias is unique.
120
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
121
+ transformedFrom.as = uniqueSubqueryAlias
122
+
123
+ // calculate the primary keys of the target entity, there is always exactly
124
+ // one query source for UPDATE / DELETE
125
+ const queryTarget = Object.values(inferred.sources)[0].definition
126
+ const primaryKey = { list: [] }
127
+ for (const k of Object.keys(queryTarget.elements)) {
128
+ const e = queryTarget.elements[k]
129
+ if (e.key === true && !e.virtual && e.isAssociation !== true) {
130
+ subquery.SELECT.columns.push({ ref: [e.name] })
131
+ primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
132
+ }
133
+ }
134
+
135
+ const transformedSubquery = cqn4sql(subquery, model)
136
+
137
+ // replace where condition of original query with the transformed subquery
138
+ // correlate UPDATE / DELETE query with subquery by primary key matches
139
+ transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
140
+
141
+ if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
142
+ else transformedQuery.DELETE.from = transformedFrom
143
+
144
+ return transformedQuery
145
+ }
108
146
 
109
147
  if (inferred.SELECT) {
110
148
  transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
@@ -130,45 +168,7 @@ function cqn4sql(originalQuery, model) {
130
168
  }
131
169
 
132
170
  if (queryNeedsJoins) {
133
- if (inferred.UPDATE || inferred.DELETE) {
134
- const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
135
- const subquery = {
136
- SELECT: {
137
- from: { ...transformedFrom },
138
- columns: [], // primary keys of the query target will be added later
139
- where: [...transformedProp.where],
140
- },
141
- }
142
- // The alias of the original query is now the alias for the subquery
143
- // so that potential references in the where clause to the alias match.
144
- // Hence, replace the alias of the original query with the next
145
- // available alias, so that each alias is unique.
146
- const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
147
- transformedFrom.as = uniqueSubqueryAlias
148
-
149
- // calculate the primary keys of the target entity, there is always exactly
150
- // one query source for UPDATE / DELETE
151
- const queryTarget = Object.values(inferred.sources)[0].definition
152
- const primaryKey = { list: [] }
153
- for (const k of Object.keys(queryTarget.elements)) {
154
- const e = queryTarget.elements[k]
155
- if (e.key === true && !e.virtual && e.isAssociation !== true) {
156
- subquery.SELECT.columns.push({ ref: [e.name] })
157
- primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
158
- }
159
- }
160
-
161
- const transformedSubquery = cqn4sql(subquery, model)
162
-
163
- // replace where condition of original query with the transformed subquery
164
- // correlate UPDATE / DELETE query with subquery by primary key matches
165
- transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
166
-
167
- if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
168
- else transformedQuery.DELETE.from = transformedFrom
169
- } else {
170
- transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
171
- }
171
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
172
172
  }
173
173
  }
174
174
 
@@ -468,7 +468,7 @@ function cqn4sql(originalQuery, model) {
468
468
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
469
469
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
470
470
 
471
- 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
472
472
 
473
473
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
474
474
  flatColumns.forEach(flatColumn => {
@@ -967,7 +967,7 @@ function cqn4sql(originalQuery, model) {
967
967
  } else if (pseudos.elements[col.ref?.[0]]) {
968
968
  res.push({ ...col })
969
969
  } else if (col.ref) {
970
- if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true))
970
+ if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
971
971
  continue
972
972
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
973
973
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1190,7 +1190,7 @@ function cqn4sql(originalQuery, model) {
1190
1190
  let { baseName, columnAlias = column.as, tableAlias } = names
1191
1191
  const { exclude, replace } = excludeAndReplace || {}
1192
1192
  const { $refLinks, flatName, isJoinRelevant } = column
1193
- let leafAssoc
1193
+ let firstNonJoinRelevantAssoc, stepAfterAssoc
1194
1194
  let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
1195
1195
  if (isWildcard && element.type === 'cds.LargeBinary') return []
1196
1196
  if (element.on && !element.keys)
@@ -1198,14 +1198,23 @@ function cqn4sql(originalQuery, model) {
1198
1198
  else if (element.virtual === true) return []
1199
1199
  else if (!isJoinRelevant && flatName) baseName = flatName
1200
1200
  else if (isJoinRelevant) {
1201
- const leaf = column.$refLinks.at(-1)
1202
- leafAssoc = [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1203
- let elements = leafAssoc.definition.elements || leafAssoc.definition.foreignKeys
1204
- if (elements && leaf.definition.name in elements) {
1205
- element = leafAssoc.definition
1206
- 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)
1207
1208
  columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1208
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
+
1209
1218
  } else if (!baseName && structsAreUnfoldedAlready) {
1210
1219
  baseName = element.name // name is already fully constructed
1211
1220
  } else {
@@ -1246,11 +1255,11 @@ function cqn4sql(originalQuery, model) {
1246
1255
  const flattenThisForeignKey =
1247
1256
  !$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
1248
1257
  element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
1249
- 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
1250
1259
  if (flattenThisForeignKey) {
1251
1260
  const fkElement = getElementForRef(k.ref, getDefinition(element.target))
1252
1261
  let fkBaseName
1253
- 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)}`
1254
1263
  // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
1255
1264
  else fkBaseName = k.ref.at(-1)
1256
1265
  const fkPath = [...csnPath, k.ref.at(-1)]
@@ -1420,7 +1429,8 @@ function cqn4sql(originalQuery, model) {
1420
1429
  }
1421
1430
  const { definition: fkSource } = next
1422
1431
  ensureValidForeignKeys(fkSource, ref)
1423
- whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1432
+ const { where, ...args } = step
1433
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, true, args))
1424
1434
  }
1425
1435
 
1426
1436
  const whereExists = { SELECT: whereExistsSubqueries(whereExistsSubSelects) }
@@ -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]
@@ -1668,8 +1697,7 @@ function cqn4sql(originalQuery, model) {
1668
1697
  function getTransformedFrom(from, existingWhere = []) {
1669
1698
  const transformedWhere = []
1670
1699
  let transformedFrom = copy(from) // REVISIT: too expensive!
1671
- if (from.$refLinks)
1672
- defineProperty(transformedFrom, '$refLinks', [...from.$refLinks])
1700
+ if (from.$refLinks) defineProperty(transformedFrom, '$refLinks', [...from.$refLinks])
1673
1701
  if (from.args) {
1674
1702
  transformedFrom.args = []
1675
1703
  from.args.forEach(arg => {
@@ -1716,7 +1744,7 @@ function cqn4sql(originalQuery, model) {
1716
1744
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1717
1745
 
1718
1746
  if (current.definition.target && next) {
1719
- const { where, args } = nextStep
1747
+ const { where, ...args } = nextStep
1720
1748
  if (isStructured(next.definition)) {
1721
1749
  // find next association / entity in the ref because this is actually our real nextStep
1722
1750
  const nextStepIndex =
@@ -1777,12 +1805,10 @@ function cqn4sql(originalQuery, model) {
1777
1805
  // adjust ref & $refLinks after associations have turned into where exists subqueries
1778
1806
  transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1)
1779
1807
 
1780
- let args = from.ref.at(-1).args
1781
1808
  const subquerySource =
1782
1809
  getDefinition(transformedFrom.$refLinks[0].definition.target) || transformedFrom.$refLinks[0].target
1783
- if (subquerySource.params && !args) args = {}
1784
1810
  const id = getLocalizedName(subquerySource)
1785
- transformedFrom.ref = [args ? { id, args } : id]
1811
+ transformedFrom.ref = [subquerySource.params ? { id, args: from.ref.at(-1).args || {} } : id]
1786
1812
 
1787
1813
  return { transformedWhere, transformedFrom }
1788
1814
  }
@@ -1814,10 +1840,6 @@ function cqn4sql(originalQuery, model) {
1814
1840
  return inferred.joinTree.addNextAvailableTableAlias(id, inferred.outerQueries)
1815
1841
  }
1816
1842
 
1817
- function asXpr(thing) {
1818
- return { xpr: thing }
1819
- }
1820
-
1821
1843
  /**
1822
1844
  * @param {CSN.Element} elt
1823
1845
  * @returns {boolean}
@@ -2133,9 +2155,11 @@ function cqn4sql(originalQuery, model) {
2133
2155
  * @param {object[]} customWhere infix filter which must be part of the where exists subquery on condition
2134
2156
  * @param {boolean} inWhere whether or not the path is part of the queries where clause
2135
2157
  * -> if it is, target and source side are flipped in the where exists subquery
2158
+ * @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
2159
+ *
2136
2160
  * @returns {CQN.SELECT}
2137
2161
  */
2138
- function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, customArgs = null) {
2162
+ function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
2139
2163
  const { definition } = current
2140
2164
  const { definition: nextDefinition } = next
2141
2165
  const on = []
@@ -2156,10 +2180,9 @@ function cqn4sql(originalQuery, model) {
2156
2180
 
2157
2181
  const subquerySource = getDefinition(nextDefinition.target) || nextDefinition
2158
2182
  const id = getLocalizedName(subquerySource)
2159
- if (subquerySource.params && !customArgs) customArgs = {}
2160
2183
  const SELECT = {
2161
2184
  from: {
2162
- ref: [customArgs ? { id, args: customArgs } : id],
2185
+ ref: [subquerySource.params ? { id, args: queryModifier.args || {} } : id],
2163
2186
  as: next.alias,
2164
2187
  },
2165
2188
  columns: [
@@ -2170,25 +2193,30 @@ function cqn4sql(originalQuery, model) {
2170
2193
  ],
2171
2194
  where: on,
2172
2195
  }
2173
- if (next.pathExpressionInsideFilter) {
2174
- SELECT.where = customWhere
2196
+ // this requires sub-sequent transformation of the subquery
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
+
2175
2204
  const transformedExists = transformSubquery({ SELECT })
2176
- // infix filter conditions are wrapped in `xpr` when added to the on-condition
2177
- if (transformedExists.SELECT.where) {
2178
- on.push(
2179
- ...[
2180
- 'and',
2181
- ...(hasLogicalOr(transformedExists.SELECT.where)
2182
- ? [asXpr(transformedExists.SELECT.where)]
2183
- : transformedExists.SELECT.where),
2184
- ],
2185
- )
2205
+ if (transformedExists.SELECT.where?.length) {
2206
+ const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2207
+ ? [asXpr(transformedExists.SELECT.where)]
2208
+ : transformedExists.SELECT.where
2209
+
2210
+ on.push('and', ...wrappedWhere)
2186
2211
  }
2187
2212
  transformedExists.SELECT.where = on
2188
2213
  return transformedExists.SELECT
2189
- } else if (customWhere) {
2214
+ }
2215
+
2216
+ if (customWhere) {
2190
2217
  const filter = getTransformedTokenStream(customWhere, next)
2191
- on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2218
+ const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2219
+ on.push('and', ...wrappedFilter)
2192
2220
  }
2193
2221
  return SELECT
2194
2222
  }
@@ -2353,6 +2381,25 @@ function getParentEntity(element) {
2353
2381
  else return getParentEntity(element.parent)
2354
2382
  }
2355
2383
 
2384
+ function asXpr(thing) {
2385
+ return { xpr: thing }
2386
+ }
2387
+
2388
+ function assignQueryModifiers(SELECT, modifiers) {
2389
+ for (const [key, val] of Object.entries(modifiers)) {
2390
+ if (key in { orderBy: 1, groupBy: 1 }) {
2391
+ if (SELECT[key]) SELECT[key].push(...val)
2392
+ else SELECT[key] = val
2393
+ } else if (key === 'limit') {
2394
+ // limit defined on the query has precedence
2395
+ if (!SELECT.limit) SELECT.limit = val
2396
+ } else if (key === 'having') {
2397
+ if (!SELECT.having) SELECT.having = val
2398
+ else SELECT.having.push('and', ...val)
2399
+ }
2400
+ }
2401
+ }
2402
+
2356
2403
  /**
2357
2404
  * Assigns the given `element` as non-enumerable property 'element' onto `col`.
2358
2405
  *
@@ -1,5 +1,5 @@
1
1
  const cds = require('@sap/cds')
2
- const { hasDeep } = require('../lib/deep-queries')
2
+ const { hasDeep } = require('./deep-queries')
3
3
 
4
4
  // REVISIT: very deep & fragile dependencies to internal modules -> copy these into here
5
5
  const propagateForeignKeys = require('@sap/cds/libx/_runtime/common/utils/propagateForeignKeys')
@@ -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.2.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": {