@cap-js/db-service 1.13.0 → 1.14.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,6 +4,28 @@
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.14.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.0...db-service-v1.14.1) (2024-10-28)
8
+
9
+
10
+ ### Fixed
11
+
12
+ * deep delete for views ([#496](https://github.com/cap-js/cds-dbs/issues/496)) ([82154ef](https://github.com/cap-js/cds-dbs/commit/82154ef8b837f17e81e2516056e03ff215f1dff8))
13
+ * properly support `default`, `cds.on.insert` and `cds.on.update` for `UPSERT` queries ([#425](https://github.com/cap-js/cds-dbs/issues/425)) ([338e9f5](https://github.com/cap-js/cds-dbs/commit/338e9f5de9109d36013208547fc648c17ce8c7b0))
14
+ * SELECT cds.hana.BINARY ([#870](https://github.com/cap-js/cds-dbs/issues/870)) ([33c3ebe](https://github.com/cap-js/cds-dbs/commit/33c3ebe84be4c0181b1c230d5f2d332332201ce0))
15
+
16
+ ## [1.14.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.13.0...db-service-v1.14.0) (2024-10-15)
17
+
18
+
19
+ ### Added
20
+
21
+ * assoc-like calc elements after exists predicate ([#831](https://github.com/cap-js/cds-dbs/issues/831)) ([05f7d75](https://github.com/cap-js/cds-dbs/commit/05f7d75837495d58cc4f72ad628077bdebb0acf6))
22
+
23
+
24
+ ### Fixed
25
+
26
+ * Improved behavioral consistency between the database services ([#837](https://github.com/cap-js/cds-dbs/issues/837)) ([b6f7187](https://github.com/cap-js/cds-dbs/commit/b6f718701e48dfb1c4c3d98ee016ec45930f8e7b))
27
+ * Treat assoc-like calculated elements as unmanaged assocs ([#830](https://github.com/cap-js/cds-dbs/issues/830)) ([cbe0df7](https://github.com/cap-js/cds-dbs/commit/cbe0df7a66fec0d421947767adc8621ed8bf236c))
28
+
7
29
  ## [1.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
8
30
 
9
31
 
package/lib/SQLService.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const cds = require('@sap/cds'),
2
2
  DEBUG = cds.debug('sql|db')
3
3
  const { Readable } = require('stream')
4
- const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
4
+ const { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
5
5
  const DatabaseService = require('./common/DatabaseService')
6
6
  const cqn4sql = require('./cqn4sql')
7
7
 
@@ -25,14 +25,16 @@ class SQLService extends DatabaseService {
25
25
  this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
26
26
  if (cds.env.features.db_strict) {
27
27
  this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => {
28
- const elements = query.target?.elements; if (!elements) return
28
+ const elements = query.target?.elements
29
+ if (!elements) return
29
30
  const kind = query.kind || Object.keys(query)[0]
30
31
  const operation = query[kind]
31
32
  if (!operation.columns && !operation.entries && !operation.data) return
32
33
  const columns =
33
34
  operation.columns ||
34
35
  Object.keys(
35
- operation.data || operation.entries?.reduce((acc, obj) => {
36
+ operation.data ||
37
+ operation.entries?.reduce((acc, obj) => {
36
38
  return Object.assign(acc, obj)
37
39
  }, {}),
38
40
  )
@@ -214,7 +216,31 @@ class SQLService extends DatabaseService {
214
216
  // REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
215
217
  return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
216
218
  async function deep_delete(/** @type {Request} */ req) {
217
- let { compositions } = req.target
219
+ const transitions = getTransition(req.target, this, false, req.query.cmd || 'DELETE')
220
+ if (transitions.target !== transitions.queryTarget) {
221
+ const keys = []
222
+ const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
223
+ for (const key in transitionsTarget) {
224
+ const exists = e => e && !e.virtual && !e.value && !e.isAssociation
225
+ if (exists(transitionsTarget[key])) keys.push(key)
226
+ }
227
+ const matchedKeys = keys.filter(key => transitions.mapping.has(key)).map(k => ({ ref: [k] }))
228
+ const query = DELETE.from({
229
+ ref: [
230
+ {
231
+ id: transitions.target.name,
232
+ where: [
233
+ { list: matchedKeys.map(k => transitions.mapping.get(k.ref[0])) },
234
+ 'in',
235
+ SELECT.from(req.query.DELETE.from).columns(matchedKeys).where(req.query.DELETE.where),
236
+ ],
237
+ },
238
+ ],
239
+ })
240
+ return this.onDELETE({ query, target: transitions.target })
241
+ }
242
+ const table = getDBTable(req.target)
243
+ const { compositions } = table
218
244
  if (compositions) {
219
245
  // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
220
246
  let { from, where } = req.query.DELETE
@@ -241,6 +267,7 @@ class SQLService extends DatabaseService {
241
267
  )
242
268
  // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
243
269
  const query = DELETE.from({ ref: [...from.ref, c.name] })
270
+ query.target = c._target
244
271
  return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
245
272
  }),
246
273
  )
@@ -466,6 +493,10 @@ const sqls = new (class extends SQLService {
466
493
  get factory() {
467
494
  return null
468
495
  }
496
+
497
+ get model() {
498
+ return cds.model
499
+ }
469
500
  })()
470
501
  cds.extend(cds.ql.Query).with(
471
502
  class {
@@ -1,3 +1,5 @@
1
+ const cds = require("@sap/cds")
2
+
1
3
  const StandardFunctions = {
2
4
  // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
3
5
 
@@ -59,7 +61,7 @@ const StandardFunctions = {
59
61
  * @param {string} x
60
62
  * @returns {string}
61
63
  */
62
- countdistinct: x => `count(distinct ${x || '*'})`,
64
+ countdistinct: x => `count(distinct ${x || cds.error`countdistinct requires a ref to be counted`})`,
63
65
  /**
64
66
  * Generates SQL statement that produces the index of the first occurrence of the second string in the first string
65
67
  * @param {string} x
package/lib/cqn2sql.js CHANGED
@@ -4,12 +4,6 @@ const cqn4sql = require('./cqn4sql')
4
4
  const _simple_queries = cds.env.features.sql_simple_queries
5
5
  const _strict_booleans = _simple_queries < 2
6
6
 
7
- const BINARY_TYPES = {
8
- 'cds.Binary': 1,
9
- 'cds.LargeBinary': 1,
10
- 'cds.hana.BINARY': 1,
11
- }
12
-
13
7
  const { Readable } = require('stream')
14
8
 
15
9
  const DEBUG = (() => {
@@ -18,8 +12,8 @@ const DEBUG = (() => {
18
12
  return cds.debug('sql|sqlite')
19
13
  //if (DEBUG) {
20
14
  // return DEBUG
21
- // (sql, ...more) => DEBUG (sql.replace(/(?:SELECT[\n\r\s]+(json_group_array\()?[\n\r\s]*json_insert\((\n|\r|.)*?\)[\n\r\s]*\)?[\n\r\s]+as[\n\r\s]+_json_[\n\r\s]+FROM[\n\r\s]*\(|\)[\n\r\s]*(\)[\n\r\s]+AS )|\)$)/gim,(a,b,c,d) => d || ''), ...more)
22
- // FIXME: looses closing ) on INSERT queries
15
+ // (sql, ...more) => DEBUG (sql.replace(/(?:SELECT[\n\r\s]+(json_group_array\()?[\n\r\s]*json_insert\((\n|\r|.)*?\)[\n\r\s]*\)?[\n\r\s]+as[\n\r\s]+_json_[\n\r\s]+FROM[\n\r\s]*\(|\)[\n\r\s]*(\)[\n\r\s]+AS )|\)$)/gim,(a,b,c,d) => d || ''), ...more)
16
+ // FIXME: looses closing ) on INSERT queries
23
17
  //}
24
18
  })()
25
19
 
@@ -42,6 +36,12 @@ class CQN2SQLRenderer {
42
36
  }
43
37
  }
44
38
 
39
+ BINARY_TYPES = {
40
+ 'cds.Binary': 1,
41
+ 'cds.LargeBinary': 1,
42
+ 'cds.hana.BINARY': 1,
43
+ }
44
+
45
45
  static _add_mixins(aspect, mixins) {
46
46
  const fqn = this.name + aspect
47
47
  const types = cds.builtin.types
@@ -88,6 +88,7 @@ class CQN2SQLRenderer {
88
88
  this.values = [] // prepare values, filled in by subroutines
89
89
  this[kind]((this.cqn = q)) // actual sql rendering happens here
90
90
  if (vars?.length && !this.values?.length) this.values = vars
91
+ if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
91
92
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
92
93
  DEBUG?.(
93
94
  this.sql,
@@ -116,8 +117,13 @@ class CQN2SQLRenderer {
116
117
  * @param {import('./infer/cqn').CREATE} q
117
118
  */
118
119
  CREATE(q) {
119
- const { target } = q,
120
- { query } = target
120
+ let { target } = q
121
+ let query = target?.query || q.CREATE.as
122
+ if (!target || target._unresolved) {
123
+ const entity = q.CREATE.entity
124
+ target = typeof entity === 'string' ? { name: entity } : q.CREATE.entity
125
+ }
126
+
121
127
  const name = this.name(target.name)
122
128
  // Don't allow place holders inside views
123
129
  delete this.values
@@ -136,13 +142,15 @@ class CQN2SQLRenderer {
136
142
  */
137
143
  CREATE_elements(elements) {
138
144
  let sql = ''
145
+ let keys = ''
139
146
  for (let e in elements) {
140
147
  const definition = elements[e]
141
148
  if (definition.isAssociation) continue
149
+ if (definition.key) keys = `${keys}, ${this.quote(definition.name)}`
142
150
  const s = this.CREATE_element(definition)
143
- if (s) sql += `${s}, `
151
+ if (s) sql += `, ${s}`
144
152
  }
145
- return sql.slice(0, -2)
153
+ return `${sql.slice(2)}${keys && `, PRIMARY KEY(${keys.slice(2)})`}`
146
154
  }
147
155
 
148
156
  /**
@@ -203,8 +211,9 @@ class CQN2SQLRenderer {
203
211
  */
204
212
  DROP(q) {
205
213
  const { target } = q
206
- const isView = target.query || target.projection
207
- return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(target.name))}`)
214
+ const isView = target?.query || target?.projection || q.DROP.view
215
+ const name = target?.name || q.DROP.table?.ref?.[0] || q.DROP.view?.ref?.[0]
216
+ return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(name))}`)
208
217
  }
209
218
 
210
219
  // SELECT Statements ------------------------------------------------
@@ -223,7 +232,7 @@ class CQN2SQLRenderer {
223
232
 
224
233
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
225
234
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
226
- let columns = this.SELECT_columns(q)
235
+ const columns = this.SELECT_columns(q)
227
236
  let sql = `SELECT`
228
237
  if (distinct) sql += ` DISTINCT`
229
238
  if (!_empty(columns)) sql += ` ${columns}`
@@ -484,8 +493,6 @@ class CQN2SQLRenderer {
484
493
  */
485
494
  INSERT_entries(q) {
486
495
  const { INSERT } = q
487
- const entity = this.name(q.target?.name || INSERT.into.ref[0])
488
- const alias = INSERT.into.as
489
496
  const elements = q.elements || q.target?.elements
490
497
  if (!elements && !INSERT.entries?.length) {
491
498
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
@@ -497,19 +504,14 @@ class CQN2SQLRenderer {
497
504
  /** @type {string[]} */
498
505
  this.columns = columns
499
506
 
507
+ const alias = INSERT.into.as
508
+ const entity = this.name(q.target?.name || INSERT.into.ref[0])
500
509
  if (!elements) {
501
510
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
502
511
  const param = this.param.bind(this, { ref: ['?'] })
503
512
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
504
513
  }
505
514
 
506
- const extractions = this.managed(
507
- columns.map(c => ({ name: c })),
508
- elements,
509
- !!q.UPSERT,
510
- )
511
- const extraction = extractions.map(c => c.sql)
512
-
513
515
  // Include this.values for placeholders
514
516
  /** @type {unknown[][]} */
515
517
  this.entries = []
@@ -523,8 +525,9 @@ class CQN2SQLRenderer {
523
525
  this.entries = [[...this.values, stream]]
524
526
  }
525
527
 
528
+ const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
526
529
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
527
- }) SELECT ${extraction} FROM json_each(?)`)
530
+ }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
528
531
  }
529
532
 
530
533
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -562,7 +565,7 @@ class CQN2SQLRenderer {
562
565
 
563
566
  buffer += '"'
564
567
  } else {
565
- if (elements[key]?.type in BINARY_TYPES) {
568
+ if (elements[key]?.type in this.BINARY_TYPES) {
566
569
  val = transformBase64(val)
567
570
  }
568
571
  buffer += `${keyJSON}${JSON.stringify(val)}`
@@ -610,7 +613,7 @@ class CQN2SQLRenderer {
610
613
 
611
614
  buffer += '"'
612
615
  } else {
613
- if (elements[this.columns[key]]?.type in BINARY_TYPES) {
616
+ if (elements[this.columns[key]]?.type in this.BINARY_TYPES) {
614
617
  val = transformBase64(val)
615
618
  }
616
619
  buffer += `${sepsub}${val === undefined ? 'null' : JSON.stringify(val)}`
@@ -639,18 +642,7 @@ class CQN2SQLRenderer {
639
642
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
640
643
  const alias = INSERT.into.as
641
644
  const elements = q.elements || q.target?.elements
642
- const columns = INSERT.columns
643
- || cds.error`Cannot insert rows without columns or elements`
644
-
645
- const inputConverter = this.class._convertInput
646
- const extraction = columns.map((c, i) => {
647
- const extract = `value->>'$[${i}]'`
648
- const element = elements?.[c]
649
- const converter = element?.[inputConverter]
650
- return converter?.(extract, element) || extract
651
- })
652
-
653
- this.columns = columns
645
+ const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
654
646
 
655
647
  if (!elements) {
656
648
  this.entries = INSERT.rows
@@ -668,6 +660,10 @@ class CQN2SQLRenderer {
668
660
  this.entries = [[...this.values, stream]]
669
661
  }
670
662
 
663
+ const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
664
+ .slice(0, columns.length)
665
+ .map(c => c.converter(c.extract))
666
+
671
667
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
672
668
  }) SELECT ${extraction} FROM json_each(?)`)
673
669
  }
@@ -679,7 +675,7 @@ class CQN2SQLRenderer {
679
675
  */
680
676
  INSERT_values(q) {
681
677
  let { columns, values } = q.INSERT
682
- return this.INSERT_rows({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
678
+ return this.render({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
683
679
  }
684
680
 
685
681
  /**
@@ -730,14 +726,37 @@ class CQN2SQLRenderer {
730
726
  */
731
727
  UPSERT(q) {
732
728
  const { UPSERT } = q
733
- const elements = q.target?.elements || {}
729
+
734
730
  let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
735
- let keys = q.target?.keys
736
- if (!keys) return this.sql = sql
737
- keys = Object.keys(keys).filter(k => !keys[k].isAssociation && !keys[k].virtual)
731
+ if (!q.target?.keys) return sql
732
+ const keys = []
733
+ for (const k of ObjectKeys(q.target?.keys)) {
734
+ const element = q.target.keys[k]
735
+ if (element.isAssociation || element.virtual) continue
736
+ keys.push(k)
737
+ }
738
+
739
+ const elements = q.target?.elements || {}
740
+ // temporal data
741
+ for (const k of ObjectKeys(elements)) {
742
+ if (elements[k]['@cds.valid.from']) keys.push(k)
743
+ }
744
+
745
+ const keyCompare = keys
746
+ .map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
747
+ .join(' AND ')
748
+
749
+ const columns = this.columns // this.columns is computed as part of this.INSERT
750
+ const managed = this._managed.slice(0, columns.length)
751
+
752
+ const extractkeys = managed
753
+ .filter(c => keys.includes(c.name))
754
+ .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
738
755
 
739
- let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
740
- updateColumns = updateColumns.filter(c => {
756
+ const entity = this.name(q.target?.name || UPSERT.into.ref[0])
757
+ sql = `SELECT ${managed.map(c => c.upsert)} FROM (SELECT value, ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
758
+
759
+ const updateColumns = columns.filter(c => {
741
760
  if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
742
761
  let e = elements[c]
743
762
  if (!e) return true //> pass through to native SQL columns not in CDS model
@@ -747,14 +766,8 @@ class CQN2SQLRenderer {
747
766
  else return true
748
767
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
749
768
 
750
- // temporal data
751
- keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
752
-
753
- keys = keys.map(k => this.quote(k))
754
- const conflict = updateColumns.length
755
- ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
756
- : `ON CONFLICT(${keys}) DO NOTHING`
757
- return (this.sql = `${sql} WHERE true ${conflict}`)
769
+ return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
770
+ } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
758
771
  }
759
772
 
760
773
  // UPDATE Statements ------------------------------------------------
@@ -783,7 +796,9 @@ class CQN2SQLRenderer {
783
796
  }
784
797
  }
785
798
 
786
- const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
799
+ const extraction = this.managed(columns, elements)
800
+ .filter((c, i) => columns[i] || c.onUpdate)
801
+ .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
787
802
 
788
803
  sql += ` SET ${extraction}`
789
804
  if (where) sql += ` WHERE ${this.where(where)}`
@@ -1035,56 +1050,104 @@ class CQN2SQLRenderer {
1035
1050
  }
1036
1051
 
1037
1052
  /**
1038
- * Convers the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
1053
+ * Converts the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
1039
1054
  * @param {object[]} columns
1040
1055
  * @param {import('./infer/cqn').elements} elements
1041
1056
  * @param {Boolean} isUpdate
1042
1057
  * @returns {string[]} Array of SQL expressions for processing input JSON data
1043
1058
  */
1044
- managed(columns, elements, isUpdate = false) {
1045
- const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
1059
+ managed(columns, elements) {
1060
+ const cdsOnInsert = '@cds.on.insert'
1061
+ const cdsOnUpdate = '@cds.on.update'
1062
+
1046
1063
  const { _convertInput } = this.class
1047
1064
  // Ensure that missing managed columns are added
1048
1065
  const requiredColumns = !elements
1049
1066
  ? []
1050
- : Object.keys(elements)
1051
- .filter(
1052
- e =>
1053
- (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
1054
- !columns.find(c => c.name === e),
1055
- )
1067
+ : ObjectKeys(elements)
1068
+ .filter(e => {
1069
+ const element = elements[e]
1070
+ // Actual mandatory check
1071
+ if (!(element.default || element[cdsOnInsert] || element[cdsOnUpdate])) return false
1072
+ // Physical column check
1073
+ if (!element || element.virtual || element.isAssociation) return false
1074
+ // Existence check
1075
+ if (columns.find(c => c.name === e)) return false
1076
+ return true
1077
+ })
1056
1078
  .map(name => ({ name, sql: 'NULL' }))
1057
1079
 
1080
+ const keys = ObjectKeys(elements).filter(e => elements[e].key && !elements[e].isAssociation)
1081
+ const keyZero = keys[0] && this.quote(keys[0])
1082
+
1058
1083
  return [...columns, ...requiredColumns].map(({ name, sql }) => {
1059
- let element = elements?.[name] || {}
1060
- if (!sql) sql = `value->>'$."${name}"'`
1061
-
1062
- let converter = element[_convertInput]
1063
- if (converter && sql[0] !== '$') sql = converter(sql, element)
1064
-
1065
- let val = _managed[element[annotation]?.['=']]
1066
- if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val, param: false }] })})`
1067
- else if (!isUpdate && element.default) {
1068
- const d = element.default
1069
- if (d.val !== undefined || d.ref?.[0] === '$now') {
1070
- // REVISIT: d.ref is not used afterwards
1071
- sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
1072
- } ELSE ${sql} END)`
1073
- }
1084
+ const element = elements?.[name] || {}
1085
+
1086
+ const converter = a => element[_convertInput]?.(a, element) || a
1087
+ let extract
1088
+ if (!sql) {
1089
+ ({ sql, extract } = this.managed_extract(name, element, converter))
1090
+ } else {
1091
+ extract = sql = converter(sql)
1074
1092
  }
1093
+ // if (sql[0] !== '$') sql = converter(sql, element)
1094
+
1095
+ let onInsert = this.managed_session_context(element[cdsOnInsert]?.['='])
1096
+ || this.managed_session_context(element.default?.ref?.[0])
1097
+ || (element.default?.val !== undefined && { val: element.default.val, param: false })
1098
+ let onUpdate = this.managed_session_context(element[cdsOnUpdate]?.['='])
1099
+
1100
+ if (onInsert) onInsert = this.expr(onInsert)
1101
+ if (onUpdate) onUpdate = this.expr(onUpdate)
1102
+
1103
+ const qname = this.quote(name)
1104
+
1105
+ const insert = onInsert ? this.managed_default(name, converter(onInsert), sql) : sql
1106
+ const update = onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql
1107
+ const upsert = keyZero && (
1108
+ // upsert requires the keys to be provided for the existance join (default values optional)
1109
+ element.key
1110
+ // If both insert and update have the same managed definition exclude the old value check
1111
+ || (onInsert && onUpdate && insert === update)
1112
+ ? `${insert} as ${qname}`
1113
+ : `CASE WHEN OLD.${keyZero} IS NULL THEN ${
1114
+ // If key of old is null execute insert
1115
+ insert
1116
+ } ELSE ${
1117
+ // Else execute managed update or keep old if no new data if provided
1118
+ onUpdate ? update : this.managed_default(name, `OLD.${qname}`, update)
1119
+ } END as ${qname}`
1120
+ )
1075
1121
 
1076
- return { name, sql }
1122
+ return {
1123
+ name, // Element name
1124
+ sql, // Reference SQL
1125
+ extract, // Source SQL
1126
+ converter, // Converter logic
1127
+ // action specific full logic
1128
+ insert, update, upsert,
1129
+ // action specific isolated logic
1130
+ onInsert, onUpdate
1131
+ }
1077
1132
  })
1078
1133
  }
1079
1134
 
1080
- /**
1081
- * Returns the default value
1082
- * @param {string} defaultValue
1083
- * @returns {string}
1084
- */
1085
- // REVISIT: This is a strange method, also overridden inconsistently in postgres
1086
- defaultValue(defaultValue = this.context.timestamp.toISOString()) {
1087
- return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
1135
+ managed_extract(name, element, converter) {
1136
+ const { UPSERT, INSERT } = this.cqn
1137
+ const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)
1138
+ ? `value->>'$[${this.columns.indexOf(name)}]'`
1139
+ : `value->>'$."${name.replace(/"/g, '""')}"'`
1140
+ const sql = converter?.(extract) || extract
1141
+ return { extract, sql }
1142
+ }
1143
+
1144
+ managed_session_context(src) {
1145
+ const val = _managed[src]
1146
+ return val && { func: 'session_context', args: [{ val, param: false }] }
1147
+ }
1148
+
1149
+ managed_default(name, managed, src) {
1150
+ return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)`
1088
1151
  }
1089
1152
  }
1090
1153
 
package/lib/cqn4sql.js CHANGED
@@ -4,7 +4,7 @@ const cds = require('@sap/cds')
4
4
 
5
5
  const infer = require('./infer')
6
6
  const { computeColumnsToBeSearched } = require('./search')
7
- const { prettyPrintRef } = require('./utils')
7
+ const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement } = require('./utils')
8
8
 
9
9
  /**
10
10
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -317,10 +317,6 @@ function cqn4sql(originalQuery, model) {
317
317
  }
318
318
  }
319
319
 
320
- function isCalculatedOnRead(def) {
321
- return def?.value && !def.value.stored
322
- }
323
-
324
320
  /**
325
321
  * Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
326
322
  *
@@ -809,7 +805,7 @@ function cqn4sql(originalQuery, model) {
809
805
  const subqueryBase = {}
810
806
  for (const [key, value] of Object.entries(column)) {
811
807
  if (!(key in { ref: true, expand: true })) {
812
- subqueryBase[key] = value;
808
+ subqueryBase[key] = value
813
809
  }
814
810
  }
815
811
  const subquery = {
@@ -1365,20 +1361,17 @@ function cqn4sql(originalQuery, model) {
1365
1361
 
1366
1362
  const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
1367
1363
  next.alias = as
1368
- if (next.definition.value) {
1369
- throw new Error(
1370
- `Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
1371
- .map(idOnly)
1372
- .join('.')}”`,
1373
- )
1374
- }
1375
1364
  if (!next.definition.target) {
1365
+ let type = next.definition.type
1366
+ if (isCalculatedElement(next.definition)) {
1367
+ // try to infer the type at the leaf for better error message
1368
+ const { $refLinks } = next.definition.value
1369
+ type = $refLinks?.at(-1).definition.type || 'expression'
1370
+ }
1376
1371
  throw new Error(
1377
1372
  `Expecting path “${tokenStream[i + 1].ref
1378
1373
  .map(idOnly)
1379
- .join('.')}” following “EXISTS” predicate to end with association/composition, found “${
1380
- next.definition.type
1381
- }”`,
1374
+ .join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
1382
1375
  )
1383
1376
  }
1384
1377
  const { definition: fkSource } = next
@@ -4,6 +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 } = require('../utils')
7
8
  const cdsTypes = cds.linked({
8
9
  definitions: {
9
10
  Timestamp: { type: 'cds.Timestamp' },
@@ -746,7 +747,7 @@ function infer(originalQuery, model) {
746
747
  joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
747
748
  }
748
749
  }
749
- if (leafArt.value && !leafArt.value.stored) {
750
+ if (isCalculatedOnRead(leafArt)) {
750
751
  linkCalculatedElement(column, $baseLink, baseColumn)
751
752
  }
752
753
 
@@ -1054,7 +1055,7 @@ function infer(originalQuery, model) {
1054
1055
  if (element.type !== 'cds.LargeBinary') {
1055
1056
  queryElements[k] = element
1056
1057
  }
1057
- if (element.value) {
1058
+ if (isCalculatedOnRead(element)) {
1058
1059
  linkCalculatedElement(element)
1059
1060
  }
1060
1061
  }
@@ -1071,7 +1072,7 @@ function infer(originalQuery, model) {
1071
1072
  if (exclude(name) || name in queryElements) return true
1072
1073
  const element = tableAliases[0].tableAlias.elements[name]
1073
1074
  if (element.type !== 'cds.LargeBinary') queryElements[name] = element
1074
- if (element.value) {
1075
+ if (isCalculatedOnRead(element)) {
1075
1076
  linkCalculatedElement(element)
1076
1077
  }
1077
1078
  })
package/lib/utils.js CHANGED
@@ -21,7 +21,26 @@ function prettyPrintRef(ref, model = null) {
21
21
  }, '')
22
22
  }
23
23
 
24
+ /**
25
+ * Determines if a definition is calculated on read.
26
+ * - Stored calculated elements are not unfolded
27
+ * - Association like calculated elements have been re-written by the compiler
28
+ * they essentially behave like unmanaged associations as their calculations
29
+ * have been incorporated into an on-condition which is handled elsewhere
30
+ *
31
+ * @param {Object} def - The definition to check.
32
+ * @returns {boolean} - Returns true if the definition is calculated on read, otherwise false.
33
+ */
34
+ function isCalculatedOnRead(def) {
35
+ return isCalculatedElement(def) && !def.value.stored && !def.on
36
+ }
37
+ function isCalculatedElement(def) {
38
+ return def?.value
39
+ }
40
+
24
41
  // export the function to be used in other modules
25
42
  module.exports = {
26
43
  prettyPrintRef,
44
+ isCalculatedOnRead,
45
+ isCalculatedElement
27
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.13.0",
3
+ "version": "1.14.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": {
@@ -22,7 +22,7 @@
22
22
  "CHANGELOG.md"
23
23
  ],
24
24
  "scripts": {
25
- "test": "jest --silent"
25
+ "test": "cds-test"
26
26
  },
27
27
  "dependencies": {
28
28
  "generic-pool": "^3.9.0"