@cap-js/db-service 2.1.2 → 2.3.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,29 @@
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.3.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.2.0...db-service-v2.3.0) (2025-07-28)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`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))
13
+ * **hierarchy:** LimitedRank ([#1268](https://github.com/cap-js/cds-dbs/issues/1268)) ([52e16db](https://github.com/cap-js/cds-dbs/commit/52e16db2c83d06e318ea05947a3c3c3153bd3ab2))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * `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))
19
+ * **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))
20
+ * **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))
21
+ * TypeError for empty `list` ([#1269](https://github.com/cap-js/cds-dbs/issues/1269)) ([f262718](https://github.com/cap-js/cds-dbs/commit/f26271813e161c9f7c05fbc82b10c4d5f14916a7))
22
+
23
+ ## [2.2.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.1.2...db-service-v2.2.0) (2025-06-30)
24
+
25
+
26
+ ### Added
27
+
28
+ * **recurse:** object-page hierarchies ([#1247](https://github.com/cap-js/cds-dbs/issues/1247)) ([6fe81f2](https://github.com/cap-js/cds-dbs/commit/6fe81f27bc1aee0b8edebe3d9251928ebea8474a))
29
+
7
30
  ## [2.1.2](https://github.com/cap-js/cds-dbs/compare/db-service-v2.1.1...db-service-v2.1.2) (2025-06-12)
8
31
 
9
32
 
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,
package/lib/cqn2sql.js CHANGED
@@ -282,6 +282,25 @@ class CQN2SQLRenderer {
282
282
  SELECT_recurse(q) {
283
283
  let { from, columns, where, orderBy, recurse, _internal } = q.SELECT
284
284
 
285
+ const _target = q._target
286
+
287
+ if (_target && where) {
288
+ const keys = []
289
+ for (const _key in _target.keys) {
290
+ const k = _target.keys[_key]
291
+ if (!k.virtual && !k.isAssociation && !k.value) {
292
+ keys.push({ ref: [_key] })
293
+ }
294
+ }
295
+
296
+ // `where` needs to be wrapped to also support `where == ['exists', { SELECT }]` which is not allowed in `START WHERE`
297
+ const clone = q.clone()
298
+ clone.columns(keys)
299
+ clone.SELECT.recurse = undefined
300
+ clone.SELECT.expand = undefined // omits JSON
301
+ where = [{ list: keys }, 'in', clone]
302
+ }
303
+
285
304
  const requiredComputedColumns = { PARENT_ID: true, NODE_ID: true }
286
305
  if (!_internal) requiredComputedColumns.RANK = true
287
306
  const addComputedColumn = (name) => {
@@ -305,6 +324,7 @@ class CQN2SQLRenderer {
305
324
  DistanceFromRoot: { xpr: [{ ref: ['HIERARCHY_LEVEL'] }, '-', { val: 1, param: false }], as: 'DistanceFromRoot' },
306
325
  DrillState: false,
307
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' }
308
328
  }
309
329
 
310
330
  const columnsFiltered = columns
@@ -1157,7 +1177,7 @@ class CQN2SQLRenderer {
1157
1177
  .map((x, i) => {
1158
1178
  if (x in { LIKE: 1, like: 1 } && is_regexp(xpr[i + 1]?.val)) return this.operator('regexp')
1159
1179
  if (typeof x === 'string') return this.operator(x, i, xpr)
1160
- if (x.xpr) return `(${this.xpr(x)})`
1180
+ if (x.xpr && !x.func) return `(${this.xpr(x)})`
1161
1181
  else return this.expr(x)
1162
1182
  })
1163
1183
  .join(' ')
package/lib/cqn4sql.js CHANGED
@@ -5,7 +5,14 @@ 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
+ } = require('./utils')
9
16
 
10
17
  /**
11
18
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -63,19 +70,8 @@ function cqn4sql(originalQuery, model) {
63
70
  }
64
71
  // query modifiers can also be defined in from ref leaf infix filter
65
72
  // > 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
- }
73
+ if (inferred.SELECT?.from.ref?.at(-1).id) {
74
+ assignQueryModifiers(inferred.SELECT, inferred.SELECT.from.ref.at(-1))
79
75
  }
80
76
  inferred = infer(inferred, model)
81
77
  const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
@@ -95,6 +91,7 @@ function cqn4sql(originalQuery, model) {
95
91
  const from = queryProp.from
96
92
 
97
93
  const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
94
+ const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
98
95
 
99
96
  // Transform the existing where, prepend table aliases, and so on...
100
97
  if (where) {
@@ -104,7 +101,47 @@ function cqn4sql(originalQuery, model) {
104
101
  // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
105
102
  // The already transformed `where` clause is then glued together with the resulting subqueries.
106
103
  const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
107
- const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
104
+
105
+ // build a subquery for DELETE / UPDATE queries with path expressions and match the primary keys
106
+ if (queryNeedsJoins && (inferred.UPDATE || inferred.DELETE)) {
107
+ const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
108
+ const subquery = {
109
+ SELECT: {
110
+ from: { ...(from || entity) },
111
+ columns: [], // primary keys of the query target will be added later
112
+ where: [...where],
113
+ },
114
+ }
115
+ // The alias of the original query is now the alias for the subquery
116
+ // so that potential references in the where clause to the alias match.
117
+ // Hence, replace the alias of the original query with the next
118
+ // available alias, so that each alias is unique.
119
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
120
+ transformedFrom.as = uniqueSubqueryAlias
121
+
122
+ // calculate the primary keys of the target entity, there is always exactly
123
+ // one query source for UPDATE / DELETE
124
+ const queryTarget = Object.values(inferred.sources)[0].definition
125
+ const primaryKey = { list: [] }
126
+ for (const k of Object.keys(queryTarget.elements)) {
127
+ const e = queryTarget.elements[k]
128
+ if (e.key === true && !e.virtual && e.isAssociation !== true) {
129
+ subquery.SELECT.columns.push({ ref: [e.name] })
130
+ primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
131
+ }
132
+ }
133
+
134
+ const transformedSubquery = cqn4sql(subquery, model)
135
+
136
+ // replace where condition of original query with the transformed subquery
137
+ // correlate UPDATE / DELETE query with subquery by primary key matches
138
+ transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
139
+
140
+ if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
141
+ else transformedQuery.DELETE.from = transformedFrom
142
+
143
+ return transformedQuery
144
+ }
108
145
 
109
146
  if (inferred.SELECT) {
110
147
  transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
@@ -130,45 +167,7 @@ function cqn4sql(originalQuery, model) {
130
167
  }
131
168
 
132
169
  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
- }
170
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
172
171
  }
173
172
  }
174
173
 
@@ -1420,7 +1419,8 @@ function cqn4sql(originalQuery, model) {
1420
1419
  }
1421
1420
  const { definition: fkSource } = next
1422
1421
  ensureValidForeignKeys(fkSource, ref)
1423
- whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1422
+ const { where, ...args } = step
1423
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, true, args))
1424
1424
  }
1425
1425
 
1426
1426
  const whereExists = { SELECT: whereExistsSubqueries(whereExistsSubSelects) }
@@ -1668,8 +1668,7 @@ function cqn4sql(originalQuery, model) {
1668
1668
  function getTransformedFrom(from, existingWhere = []) {
1669
1669
  const transformedWhere = []
1670
1670
  let transformedFrom = copy(from) // REVISIT: too expensive!
1671
- if (from.$refLinks)
1672
- defineProperty(transformedFrom, '$refLinks', [...from.$refLinks])
1671
+ if (from.$refLinks) defineProperty(transformedFrom, '$refLinks', [...from.$refLinks])
1673
1672
  if (from.args) {
1674
1673
  transformedFrom.args = []
1675
1674
  from.args.forEach(arg => {
@@ -1716,7 +1715,7 @@ function cqn4sql(originalQuery, model) {
1716
1715
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1717
1716
 
1718
1717
  if (current.definition.target && next) {
1719
- const { where, args } = nextStep
1718
+ const { where, ...args } = nextStep
1720
1719
  if (isStructured(next.definition)) {
1721
1720
  // find next association / entity in the ref because this is actually our real nextStep
1722
1721
  const nextStepIndex =
@@ -1777,12 +1776,10 @@ function cqn4sql(originalQuery, model) {
1777
1776
  // adjust ref & $refLinks after associations have turned into where exists subqueries
1778
1777
  transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1)
1779
1778
 
1780
- let args = from.ref.at(-1).args
1781
1779
  const subquerySource =
1782
1780
  getDefinition(transformedFrom.$refLinks[0].definition.target) || transformedFrom.$refLinks[0].target
1783
- if (subquerySource.params && !args) args = {}
1784
1781
  const id = getLocalizedName(subquerySource)
1785
- transformedFrom.ref = [args ? { id, args } : id]
1782
+ transformedFrom.ref = [subquerySource.params ? { id, args: from.ref.at(-1).args || {} } : id]
1786
1783
 
1787
1784
  return { transformedWhere, transformedFrom }
1788
1785
  }
@@ -1814,10 +1811,6 @@ function cqn4sql(originalQuery, model) {
1814
1811
  return inferred.joinTree.addNextAvailableTableAlias(id, inferred.outerQueries)
1815
1812
  }
1816
1813
 
1817
- function asXpr(thing) {
1818
- return { xpr: thing }
1819
- }
1820
-
1821
1814
  /**
1822
1815
  * @param {CSN.Element} elt
1823
1816
  * @returns {boolean}
@@ -2133,9 +2126,11 @@ function cqn4sql(originalQuery, model) {
2133
2126
  * @param {object[]} customWhere infix filter which must be part of the where exists subquery on condition
2134
2127
  * @param {boolean} inWhere whether or not the path is part of the queries where clause
2135
2128
  * -> if it is, target and source side are flipped in the where exists subquery
2129
+ * @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
2130
+ *
2136
2131
  * @returns {CQN.SELECT}
2137
2132
  */
2138
- function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, customArgs = null) {
2133
+ function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
2139
2134
  const { definition } = current
2140
2135
  const { definition: nextDefinition } = next
2141
2136
  const on = []
@@ -2156,10 +2151,9 @@ function cqn4sql(originalQuery, model) {
2156
2151
 
2157
2152
  const subquerySource = getDefinition(nextDefinition.target) || nextDefinition
2158
2153
  const id = getLocalizedName(subquerySource)
2159
- if (subquerySource.params && !customArgs) customArgs = {}
2160
2154
  const SELECT = {
2161
2155
  from: {
2162
- ref: [customArgs ? { id, args: customArgs } : id],
2156
+ ref: [subquerySource.params ? { id, args: queryModifier.args || {} } : id],
2163
2157
  as: next.alias,
2164
2158
  },
2165
2159
  columns: [
@@ -2170,25 +2164,27 @@ function cqn4sql(originalQuery, model) {
2170
2164
  ],
2171
2165
  where: on,
2172
2166
  }
2173
- if (next.pathExpressionInsideFilter) {
2174
- SELECT.where = customWhere
2175
- 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
- )
2167
+ // 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 });
2173
+ if (transformedExists.SELECT.where?.length) {
2174
+ const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2175
+ ? [asXpr(transformedExists.SELECT.where)]
2176
+ : transformedExists.SELECT.where;
2177
+
2178
+ on.push('and', ...wrappedWhere);
2186
2179
  }
2187
- transformedExists.SELECT.where = on
2188
- return transformedExists.SELECT
2189
- } else if (customWhere) {
2190
- const filter = getTransformedTokenStream(customWhere, next)
2191
- on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2180
+ transformedExists.SELECT.where = on;
2181
+ return transformedExists.SELECT;
2182
+ }
2183
+
2184
+ if (customWhere) {
2185
+ const filter = getTransformedTokenStream(customWhere, next);
2186
+ const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter;
2187
+ on.push('and', ...wrappedFilter);
2192
2188
  }
2193
2189
  return SELECT
2194
2190
  }
@@ -2353,6 +2349,26 @@ function getParentEntity(element) {
2353
2349
  else return getParentEntity(element.parent)
2354
2350
  }
2355
2351
 
2352
+
2353
+ function asXpr(thing) {
2354
+ return { xpr: thing }
2355
+ }
2356
+
2357
+ function assignQueryModifiers(SELECT, modifiers) {
2358
+ for (const [key, val] of Object.entries(modifiers)) {
2359
+ if (key in { orderBy: 1, groupBy: 1 }) {
2360
+ if (SELECT[key]) SELECT[key].push(...val)
2361
+ else SELECT[key] = val
2362
+ } else if (key === 'limit') {
2363
+ // limit defined on the query has precedence
2364
+ if (!SELECT.limit) SELECT.limit = val
2365
+ } else if (key === 'having') {
2366
+ if (!SELECT.having) SELECT.having = val
2367
+ else SELECT.having.push('and', ...val)
2368
+ }
2369
+ }
2370
+ }
2371
+
2356
2372
  /**
2357
2373
  * Assigns the given `element` as non-enumerable property 'element' onto `col`.
2358
2374
  *
@@ -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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.1.2",
3
+ "version": "2.3.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": {