@cap-js/db-service 1.20.2 → 2.0.1

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,22 +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
- ## [1.20.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.20.1...db-service-v1.20.2) (2025-05-28)
7
+ ## [2.0.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.0.0...db-service-v2.0.1) (2025-05-27)
8
8
 
9
9
 
10
10
  ### Fixed
11
11
 
12
- * close dependency ranges to cds^8 ([#1214](https://github.com/cap-js/cds-dbs/issues/1214)) ([a4156e8](https://github.com/cap-js/cds-dbs/commit/a4156e8db4bba8688457fe635d76aa0f1ac38d1e))
12
+ * **`search`:** do not search on non-projected elements ([#1198](https://github.com/cap-js/cds-dbs/issues/1198)) ([73d9e67](https://github.com/cap-js/cds-dbs/commit/73d9e67b1bc7d7727c04b4577cb73f4daaed852b))
13
+ * add shortcut for empty UPDATE.data ([#1203](https://github.com/cap-js/cds-dbs/issues/1203)) ([cf991ff](https://github.com/cap-js/cds-dbs/commit/cf991ff8179efee6a4621d2a2bd8bf6265e58893))
14
+ * hierarchies in quoted mode ([3465cba](https://github.com/cap-js/cds-dbs/commit/3465cbab579d4560d12d3b230c55b746d4d3f5a5))
15
+ * only sort by locale if locale is set ([#1193](https://github.com/cap-js/cds-dbs/issues/1193)) ([3465cba](https://github.com/cap-js/cds-dbs/commit/3465cbab579d4560d12d3b230c55b746d4d3f5a5))
13
16
 
14
- ## [1.20.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.20.0...db-service-v1.20.1) (2025-05-27)
17
+
18
+ ### Changed
19
+
20
+ * remove stream_compat ([#1139](https://github.com/cap-js/cds-dbs/issues/1139)) ([#1144](https://github.com/cap-js/cds-dbs/issues/1144)) ([1b8b2d9](https://github.com/cap-js/cds-dbs/commit/1b8b2d9539cd97be2cef088c98d88ef9ec7dd1bf))
21
+
22
+ ## [2.0.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.20.0...db-service-v2.0.0) (2025-05-07)
23
+
24
+
25
+ ### ⚠ BREAKING CHANGES
26
+
27
+ * update peer dependency to @sap/cds@9 ([#1178](https://github.com/cap-js/cds-dbs/issues/1178))
15
28
 
16
29
 
17
30
  ### Fixed
18
31
 
19
- * **`search`:** do not search on non-projected elements ([#1198](https://github.com/cap-js/cds-dbs/issues/1198)) ([1461673](https://github.com/cap-js/cds-dbs/commit/14616730ba8c27e8ffa30c5962b881badfab991c))
20
- * current_utctimestamp as default ([#1161](https://github.com/cap-js/cds-dbs/issues/1161)) ([c0cccad](https://github.com/cap-js/cds-dbs/commit/c0cccad921c45db96e14f0e2afeced6af69da4a2))
21
- * exists within expression is properly detected ([#1156](https://github.com/cap-js/cds-dbs/issues/1156)) ([febe175](https://github.com/cap-js/cds-dbs/commit/febe1755186d291b92edbdf69cebbab68a53d0af))
22
- * resilience for query re-use scenarios ([#1175](https://github.com/cap-js/cds-dbs/issues/1175)) ([fe9abd5](https://github.com/cap-js/cds-dbs/commit/fe9abd5e4c5e9153c0afad9164f240ae2eadf581))
32
+ * Adopt to recurse `DistanceTo` cqn format ([#1093](https://github.com/cap-js/cds-dbs/issues/1093)) ([246e0b3](https://github.com/cap-js/cds-dbs/commit/246e0b38840f7e132ea49cae335b6be7a55354b3))
33
+ * current_utctimestamp as default ([#1161](https://github.com/cap-js/cds-dbs/issues/1161)) ([7c6b2f5](https://github.com/cap-js/cds-dbs/commit/7c6b2f5a6837afbeb1e24daef9a49e25cf7e92f0))
34
+ * exists within expression is properly detected ([#1156](https://github.com/cap-js/cds-dbs/issues/1156)) ([5a7b50c](https://github.com/cap-js/cds-dbs/commit/5a7b50cb02776cf6052c79bd276421dd87161882))
35
+ * resilience for query re-use scenarios ([#1175](https://github.com/cap-js/cds-dbs/issues/1175)) ([2352767](https://github.com/cap-js/cds-dbs/commit/2352767465ea88db77dc89bcaa76e268583146e1))
36
+
37
+
38
+ ### Changed
39
+
40
+ * update peer dependency to @sap/cds@9 ([#1178](https://github.com/cap-js/cds-dbs/issues/1178)) ([0507edd](https://github.com/cap-js/cds-dbs/commit/0507edd4e1dcb98983b1fb65ade1344d978b7524))
23
41
 
24
42
  ## [1.20.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.19.1...db-service-v1.20.0) (2025-04-17)
25
43
 
@@ -26,9 +26,9 @@ module.exports = class InsertResult {
26
26
  * Lazy access to auto-generated keys.
27
27
  */
28
28
  get [iterator]() {
29
- // For INSERT.as(SELECT.from(...)) return a dummy iterator with correct length
29
+ // For INSERT.from(SELECT.from(...)) return a dummy iterator with correct length
30
30
  const { INSERT } = this.query
31
- if (INSERT.as) {
31
+ if (INSERT.from || INSERT.as) {
32
32
  return (super[iterator] = function* () {
33
33
  for (let i = 0; i < this.affectedRows; i++) yield {}
34
34
  })
@@ -81,7 +81,7 @@ module.exports = class InsertResult {
81
81
  */
82
82
  get affectedRows() {
83
83
  const { INSERT: _ } = this.query
84
- if (_.as) return (super.affectedRows = this.affectedRows4(this.results[0] || this.results))
84
+ if (_.from || _.as) return (super.affectedRows = this.affectedRows4(this.results[0] || this.results))
85
85
  else return (super.affectedRows = _.entries?.length || _.rows?.length || this.results.length || 1)
86
86
  }
87
87
 
package/lib/SQLService.js CHANGED
@@ -11,6 +11,20 @@ const BINARY_TYPES = {
11
11
  'cds.hana.BINARY': 1
12
12
  }
13
13
 
14
+ /**
15
+ * Checks if parameter is an object that at least contains one property.
16
+ *
17
+ * @param {*} obj
18
+ * @returns Boolean
19
+ */
20
+ const _hasProps = (obj) => {
21
+ if (!obj) return false
22
+ for (const p in obj) {
23
+ return true
24
+ }
25
+ return false
26
+ }
27
+
14
28
  /** @typedef {import('@sap/cds/apis/services').Request} Request */
15
29
 
16
30
  /**
@@ -57,24 +71,18 @@ class SQLService extends DatabaseService {
57
71
  return super.init()
58
72
  }
59
73
 
60
- _changeToStreams(columns, rows, one, compat) {
74
+ _changeToStreams(columns, rows, one) {
61
75
  if (!rows || !columns) return
62
76
  if (!Array.isArray(rows)) rows = [rows]
63
- if (!rows.length || !Object.keys(rows[0]).length) return
64
-
65
- // REVISIT: remove after removing stream_compat feature flag
66
- if (compat) {
67
- rows[0][Object.keys(rows[0])[0]] = this._stream(Object.values(rows[0])[0])
68
- return
69
- }
77
+ if (!rows.length || !Object.keys(rows[0]).length) return
70
78
 
71
79
  let changes = false
72
80
  for (let col of columns) {
73
81
  const name = col.as || col.ref?.[col.ref.length - 1] || (typeof col === 'string' && col)
74
82
  if (col.element?.isAssociation) {
75
- if (one) this._changeToStreams(col.SELECT.columns, rows[0][name], false, compat)
83
+ if (one) this._changeToStreams(col.SELECT.columns, rows[0][name], false)
76
84
  else
77
- changes = rows.some(row => !this._changeToStreams(col.SELECT.columns, row[name], false, compat))
85
+ changes = rows.some(row => !this._changeToStreams(col.SELECT.columns, row[name], false))
78
86
  } else if (col.element?.type === 'cds.LargeBinary') {
79
87
  changes = true
80
88
  if (one) rows[0][name] = this._stream(rows[0][name])
@@ -141,23 +149,7 @@ class SQLService extends DatabaseService {
141
149
  if (expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
142
150
 
143
151
  if (!iterator) {
144
- // REVISIT: remove after removing stream_compat feature flag
145
- if (cds.env.features.stream_compat) {
146
- if (query._streaming) {
147
- if (!rows.length) return
148
- this._changeToStreams(cqn.SELECT.columns, rows, true, true)
149
- const result = rows[0]
150
-
151
- // stream is always on position 0. Further properties like etag are inserted later.
152
- let [key, val] = Object.entries(result)[0]
153
- result.value = val
154
- delete result[key]
155
-
156
- return result
157
- }
158
- } else {
159
- this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one, false)
160
- }
152
+ this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one)
161
153
  } else if (objectMode) {
162
154
  const converter = (row) => this._changeToStreams(cqn.SELECT.columns, row, true)
163
155
  const changeToStreams = new Transform({
@@ -216,8 +208,8 @@ class SQLService extends DatabaseService {
216
208
  async onUPDATE(req) {
217
209
  // noop if not a touch for @cds.on.update
218
210
  if (
219
- !req.query.UPDATE.data &&
220
- !req.query.UPDATE.with &&
211
+ !_hasProps(req.query.UPDATE.data) &&
212
+ !_hasProps(req.query.UPDATE.with) &&
221
213
  !Object.values(req.target?.elements || {}).some(e => e['@cds.on.update'])
222
214
  )
223
215
  return 0
@@ -404,8 +396,6 @@ class SQLService extends DatabaseService {
404
396
  let kind = q.kind || Object.keys(q)[0]
405
397
  if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
406
398
  q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
407
- let target = q[kind]._transitions?.[0].target
408
- if (target) q._target = target // REVISIT: Why isn't that done in resolveView?
409
399
  }
410
400
  let cqn2sql = new this.class.CQN2SQL(this)
411
401
  return cqn2sql.render(q, values)
@@ -495,16 +485,16 @@ class PreparedStatement {
495
485
  }
496
486
  SQLService.prototype.PreparedStatement = PreparedStatement
497
487
 
488
+ /** @param {import('@sap/cds').ql.Query} q */
498
489
  const _target_name4 = q => {
499
- const target =
500
- q._target_ref ||
501
- q.SELECT?.from ||
502
- q.INSERT?.into ||
503
- q.UPSERT?.into ||
504
- q.UPDATE?.entity ||
505
- q.DELETE?.from ||
506
- q.CREATE?.entity ||
507
- q.DROP?.entity
490
+ const target = q._subject
491
+ || q.SELECT?.from
492
+ || q.INSERT?.into
493
+ || q.UPSERT?.into
494
+ || q.UPDATE?.entity
495
+ || q.DELETE?.from
496
+ || q.CREATE?.entity
497
+ || q.DROP?.entity
508
498
  if (target?.SET?.op === 'union') throw new cds.error('UNION-based queries are not supported')
509
499
  if (!target?.ref) return target
510
500
  const [first] = target.ref
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const cds = require('@sap/cds')
4
+
3
5
  // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
4
6
  const StandardFunctions = {
5
7
  /**
@@ -18,10 +20,15 @@ const StandardFunctions = {
18
20
  } catch {
19
21
  val = sub[2] || sub[3] || ''
20
22
  }
21
- arg.val = arg.__proto__.val = val
23
+ arg.val = val
22
24
  const refs = ref.list
23
- const { toString } = ref
24
- return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
25
+ return `(${refs.map(ref => this.expr({
26
+ func: 'contains',
27
+ args: [
28
+ { func: 'tolower', args: [ref] },
29
+ { func: 'tolower', args: [arg] },
30
+ ]
31
+ })).join(' or ')})`
25
32
  },
26
33
 
27
34
  // ==============================
@@ -141,7 +148,7 @@ const StandardFunctions = {
141
148
  * @returns {string} - SQL statement
142
149
  */
143
150
  now: function () {
144
- return this.session_context({ val: '$now' })
151
+ return this.expr({ func: 'session_context', args: [{ val: '$now' }] })
145
152
  },
146
153
 
147
154
  /**
@@ -184,6 +191,226 @@ const HANAFunctions = {
184
191
  * @returns {string} - SQL statement
185
192
  */
186
193
  current_timestamp: p => (p ? `current_timestamp(${p})` : 'current_timestamp'),
194
+
195
+ /**
196
+ * Generates SQL statement for the hierarchy function
197
+ * @param {string} [p] -
198
+ * @returns {string} - SQL statement
199
+ */
200
+ HIERARCHY: function (args) {
201
+ let uniqueCounter = this._with?.length ?? 0
202
+ let src = args.xpr[1]
203
+
204
+ // Ensure that the orderBy column are exposed by the source for hierarchy sorting
205
+ const orderBy = args.xpr.find((_, i, arr) => /ORDER/i.test(arr[i - 2]) && /BY/i.test(arr[i - 1]))
206
+
207
+ const passThroughColumns = src.SELECT.columns.map(c => ({ ref: ['Source', this.column_name(c)] }))
208
+ src.as = 'H' + (uniqueCounter++)
209
+ src = this.expr(this.with(src))
210
+
211
+ let recursive = cds.ql(`
212
+ SELECT
213
+ 1 as HIERARCHY_LEVEL,
214
+ NODE_ID as HIERARCHY_ROOT_ID
215
+ FROM ${src} AS Source
216
+ WHERE parent_ID IS NULL
217
+ UNION ALL
218
+ SELECT
219
+ Parent.HIERARCHY_LEVEL + 1,
220
+ Parent.HIERARCHY_ROOT_ID
221
+ FROM ${src} AS Source
222
+ JOIN H${uniqueCounter} AS Parent ON Source.PARENT_ID=Parent.NODE_ID
223
+ ORDER BY HIERARCHY_LEVEL DESC${orderBy ? `,${orderBy}` : ''}`)
224
+ recursive.as = 'H' + (uniqueCounter++)
225
+ recursive.SET.args[0].SELECT.columns = [...recursive.SET.args[0].SELECT.columns, ...passThroughColumns]
226
+ recursive.SET.args[1].SELECT.columns = [...recursive.SET.args[1].SELECT.columns, ...passThroughColumns]
227
+ recursive = this.expr(this.with(recursive))
228
+
229
+ let ranked = cds.ql(`
230
+ SELECT
231
+ HIERARCHY_LEVEL,
232
+ row_number() over () as HIERARCHY_RANK,
233
+ HIERARCHY_ROOT_ID
234
+ FROM ${recursive} AS Source`)
235
+ ranked.as = 'H' + (uniqueCounter++)
236
+ ranked.SELECT.columns = [...ranked.SELECT.columns, ...passThroughColumns]
237
+ ranked = this.expr(this.with(ranked))
238
+
239
+ let Hierarchy = cds.ql(`
240
+ SELECT
241
+ HIERARCHY_LEVEL,
242
+ HIERARCHY_RANK,
243
+ (SELECT HIERARCHY_RANK FROM ${ranked} AS Ranked WHERE Ranked.NODE_ID = Source.PARENT_ID) AS HIERARCHY_PARENT_RANK,
244
+ (SELECT HIERARCHY_RANK FROM ${ranked} AS Ranked WHERE Ranked.NODE_ID = Source.HIERARCHY_ROOT_ID) AS HIERARCHY_ROOT_RANK,
245
+ coalesce(
246
+ (SELECT MIN(HIERARCHY_RANK) FROM ${ranked} AS Ranked WHERE Ranked.HIERARCHY_RANK > Source.HIERARCHY_RANK AND Ranked.HIERARCHY_LEVEL <= Source.HIERARCHY_LEVEL),
247
+ (SELECT MAX(HIERARCHY_RANK) + 1 FROM ${ranked})
248
+ ) - Source.HIERARCHY_RANK AS HIERARCHY_TREE_SIZE
249
+ FROM ${ranked} AS Source`)
250
+ Hierarchy.as = 'H' + (uniqueCounter++)
251
+ Hierarchy.SELECT.columns = [...Hierarchy.SELECT.columns, ...passThroughColumns]
252
+ Hierarchy = this.expr(this.with(Hierarchy))
253
+
254
+ return Hierarchy
255
+ },
256
+
257
+ /**
258
+ * Generates SQL statement for the hierarchy_descendants function
259
+ * @param {string} [p] -
260
+ * @returns {string} - SQL statement
261
+ */
262
+ HIERARCHY_DESCENDANTS: function (args) {
263
+ // Find Hierarchy function call source query
264
+ const passThroughColumns = args.xpr[1].args[0].xpr[1].SELECT.columns.map(c => ({ ref: [this.column_name(c)] }))
265
+ // REVISIT: currently only supports func: HIERARCHY as source
266
+ const src = this.expr(args.xpr[1])
267
+
268
+ let uniqueCounter = this._with?.length ?? 0
269
+
270
+ let alias = args.xpr.find((_, i, arr) => /AS/i.test(arr[i - 1]))
271
+ const where = args.xpr.find((a, i, arr) => a.xpr && /WHERE/i.test(arr[i - 1]) && /START/i.test(arr[i - 2]))
272
+ const distance = args.xpr.find((a, i, arr) => typeof a.val === 'number' && (/DISTANCE/i.test(arr[i - 1]) || /DISTANCE/i.test(arr[i - 2])))
273
+ const distanceFrom = args.xpr.find((a, i, arr) => /FROM/.test(a) && /DISTANCE/i.test(arr[i - 1]))
274
+
275
+ if (alias.startsWith('"') && alias.endsWith('"')) alias = alias.slice(1, -1).replace(/""/g, '"')
276
+
277
+ let HierarchyDescendants = cds.ql(`
278
+ SELECT
279
+ HIERARCHY_LEVEL,
280
+ HIERARCHY_PARENT_RANK,
281
+ HIERARCHY_RANK,
282
+ HIERARCHY_ROOT_RANK,
283
+ HIERARCHY_TREE_SIZE,
284
+ 0 as HIERARCHY_DISTANCE
285
+ FROM ${src} AS ![${alias}]
286
+ UNION ALL
287
+ SELECT
288
+ Source.HIERARCHY_LEVEL,
289
+ Source.HIERARCHY_PARENT_RANK,
290
+ Source.HIERARCHY_RANK,
291
+ Source.HIERARCHY_ROOT_RANK,
292
+ Source.HIERARCHY_TREE_SIZE,
293
+ Child.HIERARCHY_DISTANCE + 1
294
+ FROM ${src} AS Source
295
+ JOIN H${uniqueCounter} AS Child ON Source.PARENT_ID=Child.NODE_ID`)
296
+ HierarchyDescendants.as = 'H' + uniqueCounter
297
+ HierarchyDescendants.SET.args[0].SELECT.where = where.xpr
298
+ HierarchyDescendants.SET.args[0].SELECT.columns = [...HierarchyDescendants.SET.args[0].SELECT.columns, ...passThroughColumns.map(r => ({ ref: [alias, r.ref[0]] }))]
299
+ HierarchyDescendants.SET.args[1].SELECT.columns = [...HierarchyDescendants.SET.args[1].SELECT.columns, ...passThroughColumns.map(r => ({ ref: ['Source', r.ref[0]] }))]
300
+
301
+ HierarchyDescendants = this.with(HierarchyDescendants)
302
+ HierarchyDescendants.as = 'HierarchyDescendants'
303
+
304
+ return this.expr({
305
+ SELECT: {
306
+ columns: [
307
+ { ref: ['HIERARCHY_LEVEL'] },
308
+ { ref: ['HIERARCHY_PARENT_RANK'] },
309
+ { ref: ['HIERARCHY_RANK'] },
310
+ { ref: ['HIERARCHY_ROOT_RANK'] },
311
+ { ref: ['HIERARCHY_TREE_SIZE'] },
312
+ {
313
+ SELECT: {
314
+ columns: [{ func: 'MAX', args: [{ ref: ['HIERARCHY_DISTANCE'] }] }],
315
+ from: HierarchyDescendants,
316
+ where: [{ ref: [HierarchyDescendants.as, 'HIERARCHY_RANK'] }, '=', { ref: [src, 'HIERARCHY_RANK'] }]
317
+ },
318
+ as: 'HIERARCHY_DISTANCE',
319
+ },
320
+ ...passThroughColumns,
321
+ ],
322
+ from: { ref: [src] },
323
+ where: [
324
+ { ref: ['HIERARCHY_RANK'] },
325
+ 'IN',
326
+ {
327
+ SELECT: {
328
+ columns: [{ ref: ['HIERARCHY_RANK'] }],
329
+ from: HierarchyDescendants,
330
+ where: [{ ref: ['HIERARCHY_DISTANCE'] }, distanceFrom ? '>=' : '=', distance]
331
+ }
332
+ }
333
+ ]
334
+ }
335
+ })
336
+ },
337
+
338
+ /**
339
+ * Generates SQL statement for the hierarchy_ancestors function
340
+ * @param {string} [p] -
341
+ * @returns {string} - SQL statement
342
+ */
343
+ HIERARCHY_ANCESTORS: function (args) {
344
+ // Find Hierarchy function call source query
345
+ const passThroughColumns = args.xpr[1].args[0].xpr[1].SELECT.columns.map(c => ({ ref: [this.column_name(c)] }))
346
+ // REVISIT: currently only supports func: HIERARCHY as source
347
+ const src = this.expr(args.xpr[1])
348
+
349
+ let uniqueCounter = this._with?.length ?? 0
350
+
351
+ let alias = args.xpr.find((_, i, arr) => /AS/i.test(arr[i - 1]))
352
+ const where = args.xpr.find((a, i, arr) => a.xpr && /WHERE/i.test(arr[i - 1]) && /START/i.test(arr[i - 2]))
353
+
354
+ if (alias.startsWith('"') && alias.endsWith('"')) alias = alias.slice(1, -1).replace(/""/g, '"')
355
+
356
+ let HierarchyAncestors = cds.ql(`
357
+ SELECT
358
+ HIERARCHY_LEVEL,
359
+ HIERARCHY_PARENT_RANK,
360
+ HIERARCHY_RANK,
361
+ HIERARCHY_ROOT_RANK,
362
+ HIERARCHY_TREE_SIZE,
363
+ 0 as HIERARCHY_DISTANCE
364
+ FROM ${src} AS ![${alias}]
365
+ UNION ALL
366
+ SELECT
367
+ Source.HIERARCHY_LEVEL,
368
+ Source.HIERARCHY_PARENT_RANK,
369
+ Source.HIERARCHY_RANK,
370
+ Source.HIERARCHY_ROOT_RANK,
371
+ Source.HIERARCHY_TREE_SIZE,
372
+ Child.HIERARCHY_DISTANCE - 1
373
+ FROM ${src} AS Source
374
+ JOIN H${uniqueCounter} AS Child ON Source.NODE_ID=Child.PARENT_ID`)
375
+ HierarchyAncestors.as = 'H' + uniqueCounter
376
+ HierarchyAncestors.SET.args[0].SELECT.where = where.xpr
377
+ HierarchyAncestors.SET.args[0].SELECT.columns = [...HierarchyAncestors.SET.args[0].SELECT.columns, ...passThroughColumns.map(r => ({ ref: [alias, r.ref[0]] }))]
378
+ HierarchyAncestors.SET.args[1].SELECT.columns = [...HierarchyAncestors.SET.args[1].SELECT.columns, ...passThroughColumns.map(r => ({ ref: ['Source', r.ref[0]] }))]
379
+
380
+ HierarchyAncestors = this.with(HierarchyAncestors)
381
+ HierarchyAncestors.as = 'HierarchyAncestors'
382
+ return this.expr({
383
+ SELECT: {
384
+ columns: [
385
+ { ref: ['HIERARCHY_LEVEL'] },
386
+ { ref: ['HIERARCHY_PARENT_RANK'] },
387
+ { ref: ['HIERARCHY_RANK'] },
388
+ { ref: ['HIERARCHY_ROOT_RANK'] },
389
+ { ref: ['HIERARCHY_TREE_SIZE'] },
390
+ {
391
+ SELECT: {
392
+ columns: [{ func: 'MIN', args: [{ ref: ['HIERARCHY_DISTANCE'] }] }],
393
+ from: HierarchyAncestors,
394
+ where: [{ ref: [HierarchyAncestors.as, 'HIERARCHY_RANK'] }, '=', { ref: [src, 'HIERARCHY_RANK'] }]
395
+ },
396
+ as: 'HIERARCHY_DISTANCE',
397
+ },
398
+ ...passThroughColumns,
399
+ ],
400
+ from: { ref: [src] },
401
+ where: [
402
+ { ref: ['HIERARCHY_RANK'] },
403
+ 'IN',
404
+ {
405
+ SELECT: {
406
+ columns: [{ ref: ['HIERARCHY_RANK'] }],
407
+ from: HierarchyAncestors,
408
+ }
409
+ }
410
+ ]
411
+ }
412
+ })
413
+ },
187
414
  }
188
415
 
189
416
  for (let each in HANAFunctions) HANAFunctions[each.toUpperCase()] = HANAFunctions[each]
package/lib/cqn2sql.js CHANGED
@@ -82,6 +82,9 @@ class CQN2SQLRenderer {
82
82
  /** @type {unknown[]} */
83
83
  this.values = [] // prepare values, filled in by subroutines
84
84
  this[kind]((this.cqn = q)) // actual sql rendering happens here
85
+ if (this._with?.length) {
86
+ this.render_with()
87
+ }
85
88
  if (vars?.length && !this.values?.length) this.values = vars
86
89
  if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
87
90
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
@@ -95,10 +98,28 @@ class CQN2SQLRenderer {
95
98
  DEBUG(this.sql, values)
96
99
  }
97
100
 
98
-
99
101
  return this
100
102
  }
101
103
 
104
+ render_with() {
105
+ const sql = this.sql
106
+ let recursive = false
107
+ const values = this.values
108
+ const prefix = this._with.map(q => {
109
+ const values = this.values = []
110
+ let sql
111
+ if ('SELECT' in q) sql = `${this.quote(q.as)} AS (${this.SELECT(q)})`
112
+ else if ('SET' in q) {
113
+ recursive = true
114
+ const { SET } = q
115
+ sql = `${this.quote(q.as)}(${SET.args[0].SELECT.columns?.map(c => this.quote(this.column_name(c))) || ''}) AS (${this.SELECT(SET.args[0])} ${SET.op?.toUpperCase() || 'UNION'} ${SET.all ? 'ALL' : ''} ${this.SELECT(SET.args[1])}${SET.orderBy ? ` ORDER BY ${this.orderBy(SET.orderBy)}` : ''})`
116
+ }
117
+ return { sql, values }
118
+ })
119
+ this.sql = `WITH${recursive ? ' RECURSIVE' : ''} ${prefix.map(p => p.sql)} ${sql}`
120
+ this.values = [...prefix.map(p => p.values).flat(), ...values]
121
+ }
122
+
102
123
  /**
103
124
  * Links the incoming query with the current service model
104
125
  * @param {import('./infer/cqn').Query} q
@@ -258,8 +279,275 @@ class CQN2SQLRenderer {
258
279
  return (this.sql = sql)
259
280
  }
260
281
 
261
- SELECT_recurse() {
262
- cds.error`Feature "recurse" queries not supported.`
282
+ SELECT_recurse(q) {
283
+ let { from, columns, where, orderBy, recurse, _internal } = q.SELECT
284
+
285
+ const requiredComputedColumns = { PARENT_ID: true, NODE_ID: true }
286
+ if (!_internal) requiredComputedColumns.RANK = true
287
+ const addComputedColumn = (name) => {
288
+ if (requiredComputedColumns[name]) return
289
+ requiredComputedColumns[name] = true
290
+ }
291
+
292
+ // The hierarchy functions will output the following columns. Which might clash with the entity columns
293
+ const reservedColumnNames = {
294
+ PARENT_ID: 1, NODE_ID: 1,
295
+ HIERARCHY_RANK: 1, HIERARCHY_DISTANCE: 1, HIERARCHY_LEVEL: 1, HIERARCHY_TREE_SIZE: 1
296
+ }
297
+ const availableComputedColumns = {
298
+ // Input computed columns
299
+ PARENT_ID: false,
300
+ NODE_ID: false,
301
+
302
+ // Output computed columns
303
+ RANK: { xpr: [{ ref: ['HIERARCHY_RANK'] }, '-', { val: 1, param: false }], as: 'RANK' },
304
+ Distance: { func: where?.length ? 'min' : 'max', args: [{ ref: ['HIERARCHY_DISTANCE'] }], as: 'Distance' },
305
+ DistanceFromRoot: { xpr: [{ ref: ['HIERARCHY_LEVEL'] }, '-', { val: 1, param: false }], as: 'DistanceFromRoot' },
306
+ DrillState: false,
307
+ LimitedDescendantCount: { xpr: [{ ref: ['HIERARCHY_TREE_SIZE'] }, '-', { val: 1, param: false }], as: 'LimitedDescendantCount' },
308
+ }
309
+
310
+ const columnsFiltered = columns
311
+ .filter(x => {
312
+ if (x.element?.isAssociation) return false
313
+ const name = this.column_name(x)
314
+ if (name === '$$RN$$') return false
315
+ // REVISIT: ensure that the selected column is one of the hierarchy computed columns by unifying their common definition
316
+ if (x.element?.['@Core.Computed'] && name in availableComputedColumns) {
317
+ addComputedColumn(name)
318
+ return false
319
+ }
320
+ return true
321
+ })
322
+ const columnsOut = []
323
+ const columnsIn = []
324
+ const target = q._target || q.target
325
+ for (const name in target.elements) {
326
+ const ref = { ref: [name] }
327
+ const element = target.elements[name]
328
+ if (element.virtual || element.value || element.isAssociation) continue
329
+ if (element['@Core.Computed'] && name in availableComputedColumns) continue
330
+ if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
331
+ columnsIn.push(ref)
332
+ if (from.args || columnsFiltered.find(c => this.column_name(c) === name)) {
333
+ columnsOut.push(ref.as ? { ref: [ref.as], as: name } : ref)
334
+ }
335
+ }
336
+
337
+ const nodeKeys = []
338
+ const parentKeys = []
339
+ const association = target.elements[recurse.ref[0]]
340
+ association._foreignKeys.forEach(fk => {
341
+ nodeKeys.push(fk.childElement.name)
342
+ parentKeys.push(fk.parentElement.name)
343
+ })
344
+
345
+ columnsIn.push(
346
+ nodeKeys.length === 1
347
+ ? { ref: nodeKeys, as: 'NODE_ID' }
348
+ : { func: 'HIERARCHY_COMPOSITE_ID', args: nodeKeys.map(n => ({ ref: [n] })), as: 'NODE_ID' },
349
+ parentKeys.length === 1
350
+ ? { ref: parentKeys, as: 'PARENT_ID' }
351
+ : { func: 'HIERARCHY_COMPOSITE_ID', args: parentKeys.map(n => ({ ref: [n] })), as: 'PARENT_ID' },
352
+ )
353
+
354
+ if (orderBy) {
355
+ orderBy = orderBy.map(r => {
356
+ const col = r.ref.at(-1)
357
+ if (!columnsIn.find(c => this.column_name(c) === col)) {
358
+ columnsIn.push({ ref: [col] })
359
+ }
360
+ return { ...r, ref: [col] }
361
+ })
362
+ }
363
+
364
+ // In the case of join operations make sure to compute the hierarchy from the source table only
365
+ const stableFrom = getStableFrom(from)
366
+ const alias = stableFrom.as
367
+ const source = () => {
368
+ return ({
369
+ func: 'HIERARCHY',
370
+ args: [{ xpr: ['SOURCE', { SELECT: { columns: columnsIn, from: stableFrom } }, ...(orderBy ? ['SIBLING', 'ORDER', 'BY', `${this.orderBy(orderBy)}`] : [])] }],
371
+ as: alias
372
+ })
373
+ }
374
+
375
+ const expandedByNr = { list: [] } // DistanceTo(...,null)
376
+ const expandedByOne = { list: [] } // DistanceTo(...,1)
377
+ const expandedByZero = { list: [] } // not DistanceTo(...,null)
378
+ let expandedFilter = []
379
+ let distanceType = 'DistanceFromRoot'
380
+ let distanceVal
381
+
382
+ if (recurse.where) {
383
+ distanceType = 'Distance'
384
+ if (recurse.where[0] === 'and') recurse.where = recurse.where.slice(1)
385
+ expandedFilter = [...recurse.where]
386
+ collectDistanceTo(expandedFilter)
387
+ }
388
+
389
+ const direction = where?.length ? 'ANCESTORS' : 'DESCENDANTS'
390
+ // Ensure that the distance value is being computed
391
+ if (distanceType) addComputedColumn(distanceType)
392
+
393
+ let distanceClause = []
394
+ if (distanceType === 'Distance') {
395
+ const isOne = expandedByOne.list.length
396
+ distanceClause = ['DISTANCE', ...(
397
+ isOne
398
+ ? [{ val: 1 }]
399
+ : ['FROM', { val: 1 }]
400
+ )]
401
+ where = [{ ref: ['NODE_ID'] }, 'IN', isOne ? expandedByOne : expandedByNr]
402
+ expandedFilter = []
403
+ }
404
+
405
+ availableComputedColumns.DrillState = {
406
+ xpr: [ // When the node doesn't have children make it a leaf
407
+ 'CASE', 'WHEN', { ref: ['HIERARCHY_TREE_SIZE'] }, '=', { val: 1, param: false }, 'THEN', { val: 'leaf', param: false },
408
+ ...(where?.length // When there is a where filter the final node will always be a leaf
409
+ ? ['WHEN', { func: where?.length ? 'min' : 'max', args: [{ ref: ['HIERARCHY_DISTANCE'] }] }, '=', { val: 0, param: false }, 'THEN', { val: 'leaf', param: false }]
410
+ : []
411
+ ), // When having expanded by 0 level nodes make sure they are collapsed
412
+ ...(expandedByZero.list.length
413
+ ? ['WHEN', { ref: ['NODE_ID'] }, 'IN', expandedByZero, 'THEN', { val: 'collapsed', param: false }]
414
+ : []
415
+ ), // When having expanded by null or one nodes compute them as expanded
416
+ ...(expandedByNr.list.length || expandedByOne.list.length
417
+ ? ['WHEN', { ref: ['NODE_ID'] }, 'IN', { list: [...expandedByNr.list, ...expandedByOne.list] }, 'THEN', { val: 'expanded', param: false }]
418
+ : []
419
+ ), // When having expanded by one level node make its children collapsed
420
+ ...(expandedByOne.list.length
421
+ ? ['WHEN', { ref: ['PARENT_ID'] }, 'IN', expandedByOne, 'THEN', { val: 'collapsed', param: false }]
422
+ : []
423
+ ), // When using DistanceFromRoot compute all entries within the levels as expanded
424
+ ...(distanceType === 'DistanceFromRoot' && distanceVal
425
+ ? [
426
+ 'WHEN', { ref: ['HIERARCHY_LEVEL'] }, '<>', { val: distanceVal.val + 1 },
427
+ 'THEN', { val: 'expanded', param: false },
428
+ ]
429
+ : []
430
+ ), // Default to expanded when default filter behavior is truthy
431
+ 'ELSE', { val: (recurse.where && !expandedByZero.list.length) && distanceType ? 'collapsed' : 'expanded', param: false },
432
+ 'END',
433
+ ],
434
+ as: 'DrillState'
435
+ }
436
+
437
+ for (const name in requiredComputedColumns) {
438
+ const def = availableComputedColumns[name]
439
+ if (def) columnsOut.push(def)
440
+ }
441
+ if (_internal) columnsOut.push({ ref: ['NODE_ID'] })
442
+
443
+ const graph = distanceType === 'DistanceFromRoot' && !where
444
+ ? { SELECT: { columns: columnsOut, from: source(), where: expandedFilter } }
445
+ : {
446
+ SELECT: {
447
+ columns: columnsOut,
448
+ from: {
449
+ func: `HIERARCHY_${direction}`,
450
+ args: [{
451
+ xpr: [
452
+ 'SOURCE', source(), 'AS', this.quote(alias),
453
+ 'START', 'WHERE', {
454
+ xpr: where // Requires special where logic before being put into the args
455
+ ? from.args
456
+ ? [{ ref: ['NODE_ID'] }, 'IN', { SELECT: { columns: [columnsIn.find(c => c.as === 'NODE_ID')], from, where: where } }]
457
+ : this.is_comparator?.({ xpr: where }) ?? true ? where : [...where, '=', { val: true, param: false }]
458
+ : [{ ref: ['PARENT_ID'] }, '=', { val: null }]
459
+ },
460
+ ...distanceClause
461
+ ]
462
+ }]
463
+ },
464
+ where: expandedFilter.length ? expandedFilter : undefined,
465
+ orderBy: [{ ref: ['HIERARCHY_RANK'], sort: 'asc' }],
466
+ groupBy: [{ ref: ['NODE_ID'] },{ ref: ['PARENT_ID'] }, { ref: ['HIERARCHY_RANK'] }, { ref: ['HIERARCHY_LEVEL'] }, { ref: ['HIERARCHY_TREE_SIZE'] }, ...columnsOut.filter(c => c.ref)],
467
+ }
468
+ }
469
+
470
+ // Only apply result join if the columns contain a references which doesn't start with the source alias
471
+ if (from.args && columns.find(c => c.ref?.[0] === alias)) {
472
+ graph.as = alias
473
+ return this.from(setStableFrom(from, graph))
474
+ }
475
+
476
+ return `(${this.SELECT(graph)})${alias ? ` AS ${this.quote(alias)}` : ''} `
477
+
478
+ function collectDistanceTo(where, innot = false) {
479
+ for (let i = 0; i < where.length; i++) {
480
+ const c = where[i]
481
+ if (c === 'not') {
482
+ distanceType = 'DistanceFromRoot'
483
+ innot = true
484
+ }
485
+ else if (c.func === 'DistanceTo') {
486
+ const expr = c.args[0]
487
+ // { func: 'HIERARCHY_COMPOSITE_ID', args: nodeKeys.map(n => ({ val: cur[n] })) }
488
+ const to = c.args[1].val
489
+ const list = to === 1
490
+ ? expandedByOne
491
+ : innot
492
+ ? expandedByZero
493
+ : expandedByNr
494
+
495
+ if (!list._where) {
496
+ list._where = []
497
+ where.splice(i, 1,
498
+ ...(to === 1
499
+ ? [{ ref: ['PARENT_ID'] }, 'IN', list]
500
+ : [{ ref: ['NODE_ID'] }, 'IN', {
501
+ SELECT: {
502
+ _internal: true,
503
+ columns: [{ ref: ['NODE_ID'], element: { '@Core.Computed': true } }],
504
+ from: q.SELECT.from,
505
+ recurse: {
506
+ ref: recurse.ref,
507
+ where: list._where,
508
+ },
509
+ },
510
+ target,
511
+ }])
512
+ )
513
+ i += 2
514
+ } else {
515
+ // Remove current entry from where
516
+ if (where[i - 1] === 'not') {
517
+ where.splice(i - 2, 3)
518
+ i -= 3
519
+ } else {
520
+ where.splice(i - 1, 2)
521
+ i -= 2
522
+ }
523
+ }
524
+ list.list.push(expr)
525
+ list._where.push(c)
526
+ }
527
+ else if (c.ref?.[0] === 'DistanceFromRoot') {
528
+ distanceType = 'DistanceFromRoot'
529
+ where[i] = { ref: ['HIERARCHY_LEVEL'] }
530
+ i += 2
531
+ distanceVal = where[i]
532
+ where[i] = { val: where[i].val + 1 }
533
+ }
534
+ }
535
+ }
536
+
537
+ function getStableFrom(from) {
538
+ if (from.args) return getStableFrom(from.args[0])
539
+ return from
540
+ }
541
+
542
+ function setStableFrom(from, src) {
543
+ if (from.args) {
544
+ const ret = { ...from }
545
+ ret.args = [...ret.args]
546
+ ret.args[0] = setStableFrom(ret.args[0], src)
547
+ return ret
548
+ }
549
+ return src
550
+ }
263
551
  }
264
552
 
265
553
  /**
@@ -371,6 +659,18 @@ class CQN2SQLRenderer {
371
659
  }
372
660
  if (from.SELECT) return _aliased(`(${this.SELECT(from)})`)
373
661
  if (from.join) return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])}${from.on ? ` ON ${this.where(from.on)}` : ''}`
662
+ if (from.func) return _aliased(this.func(from))
663
+ }
664
+
665
+ /**
666
+ * Renders a FROM clause into generic SQL
667
+ * @param {import('./infer/cqn').source} from
668
+ * @returns {string} SQL
669
+ */
670
+ with(query) {
671
+ this._with ??= []
672
+ this._with.push(query)
673
+ return { ref: [query.as] }
374
674
  }
375
675
 
376
676
  /**
@@ -426,7 +726,7 @@ class CQN2SQLRenderer {
426
726
  */
427
727
  orderBy(orderBy, localized) {
428
728
  return orderBy.map(c => {
429
- const o = localized
729
+ const o = (localized && this.context.locale)
430
730
  ? this.expr(c) +
431
731
  (c.element?.[this.class._localized] ? ' COLLATE NOCASE' : '') +
432
732
  (c.sort?.toLowerCase() === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
@@ -489,7 +789,7 @@ class CQN2SQLRenderer {
489
789
  ? this.INSERT_rows(q)
490
790
  : INSERT.values
491
791
  ? this.INSERT_values(q)
492
- : INSERT.as
792
+ : INSERT.from || INSERT.as
493
793
  ? this.INSERT_select(q)
494
794
  : cds.error`Missing .entries, .rows, or .values in ${q}`
495
795
  }
@@ -695,7 +995,7 @@ class CQN2SQLRenderer {
695
995
  c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
696
996
  ))
697
997
  this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${this.SELECT(
698
- this.cqn4sql(INSERT.as),
998
+ this.cqn4sql(INSERT.from || INSERT.as),
699
999
  )}`
700
1000
  this.entries = [this.values]
701
1001
  return this.sql
@@ -998,7 +1298,7 @@ class CQN2SQLRenderer {
998
1298
  } else {
999
1299
  cds.error`Invalid arguments provided for function '${func}' (${args})`
1000
1300
  }
1001
- const fn = this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
1301
+ const fn = this.class.Functions[func]?.apply(this, args) || `${func}(${args})`
1002
1302
  if (xpr) return `${fn} ${this.xpr({ xpr })}`
1003
1303
  return fn
1004
1304
  }
package/lib/cqn4sql.js CHANGED
@@ -802,11 +802,7 @@ function cqn4sql(originalQuery, model) {
802
802
  })
803
803
  } else {
804
804
  outerAlias = transformedQuery.SELECT.from.as
805
- const getInnermostTarget = q => (q._target ? getInnermostTarget(q._target) : q)
806
- subqueryFromRef = [
807
- ...(transformedQuery.SELECT.from.ref || /* subq in from */ [getInnermostTarget(transformedQuery).name]),
808
- ...ref,
809
- ]
805
+ subqueryFromRef = [transformedQuery._target.name, ...ref]
810
806
  }
811
807
 
812
808
  // this is the alias of the column which holds the correlated subquery
@@ -46,7 +46,7 @@ function infer(originalQuery, model) {
46
46
 
47
47
  let $combinedElements
48
48
 
49
- const sources = inferTarget(_.from || _.into || _.entity, {})
49
+ const sources = inferTarget(_.into || _.from || _.entity, {}) // IMPORTANT: _.into has to go before _.from for INSERT.into().from(SELECT)
50
50
  const joinTree = new JoinTree(sources)
51
51
  const aliases = Object.keys(sources)
52
52
  const target = aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.20.2",
3
+ "version": "2.0.1",
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": ">=7.9 <9"
30
+ "@sap/cds": ">=9"
31
31
  },
32
- "license": "SEE LICENSE"
32
+ "license": "Apache-2.0"
33
33
  }