@cap-js/db-service 1.14.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,15 @@
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
+
7
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)
8
17
 
9
18
 
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
  )
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 = (() => {
@@ -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
@@ -142,13 +142,15 @@ class CQN2SQLRenderer {
142
142
  */
143
143
  CREATE_elements(elements) {
144
144
  let sql = ''
145
+ let keys = ''
145
146
  for (let e in elements) {
146
147
  const definition = elements[e]
147
148
  if (definition.isAssociation) continue
149
+ if (definition.key) keys = `${keys}, ${this.quote(definition.name)}`
148
150
  const s = this.CREATE_element(definition)
149
- if (s) sql += `${s}, `
151
+ if (s) sql += `, ${s}`
150
152
  }
151
- return sql.slice(0, -2)
153
+ return `${sql.slice(2)}${keys && `, PRIMARY KEY(${keys.slice(2)})`}`
152
154
  }
153
155
 
154
156
  /**
@@ -491,8 +493,6 @@ class CQN2SQLRenderer {
491
493
  */
492
494
  INSERT_entries(q) {
493
495
  const { INSERT } = q
494
- const entity = this.name(q.target?.name || INSERT.into.ref[0])
495
- const alias = INSERT.into.as
496
496
  const elements = q.elements || q.target?.elements
497
497
  if (!elements && !INSERT.entries?.length) {
498
498
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
@@ -504,19 +504,14 @@ class CQN2SQLRenderer {
504
504
  /** @type {string[]} */
505
505
  this.columns = columns
506
506
 
507
+ const alias = INSERT.into.as
508
+ const entity = this.name(q.target?.name || INSERT.into.ref[0])
507
509
  if (!elements) {
508
510
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
509
511
  const param = this.param.bind(this, { ref: ['?'] })
510
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)})`)
511
513
  }
512
514
 
513
- const extractions = this.managed(
514
- columns.map(c => ({ name: c })),
515
- elements,
516
- !!q.UPSERT,
517
- )
518
- const extraction = extractions.map(c => c.sql)
519
-
520
515
  // Include this.values for placeholders
521
516
  /** @type {unknown[][]} */
522
517
  this.entries = []
@@ -530,8 +525,9 @@ class CQN2SQLRenderer {
530
525
  this.entries = [[...this.values, stream]]
531
526
  }
532
527
 
528
+ const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
533
529
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
534
- }) SELECT ${extraction} FROM json_each(?)`)
530
+ }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
535
531
  }
536
532
 
537
533
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -569,7 +565,7 @@ class CQN2SQLRenderer {
569
565
 
570
566
  buffer += '"'
571
567
  } else {
572
- if (elements[key]?.type in BINARY_TYPES) {
568
+ if (elements[key]?.type in this.BINARY_TYPES) {
573
569
  val = transformBase64(val)
574
570
  }
575
571
  buffer += `${keyJSON}${JSON.stringify(val)}`
@@ -617,7 +613,7 @@ class CQN2SQLRenderer {
617
613
 
618
614
  buffer += '"'
619
615
  } else {
620
- if (elements[this.columns[key]]?.type in BINARY_TYPES) {
616
+ if (elements[this.columns[key]]?.type in this.BINARY_TYPES) {
621
617
  val = transformBase64(val)
622
618
  }
623
619
  buffer += `${sepsub}${val === undefined ? 'null' : JSON.stringify(val)}`
@@ -646,18 +642,7 @@ class CQN2SQLRenderer {
646
642
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
647
643
  const alias = INSERT.into.as
648
644
  const elements = q.elements || q.target?.elements
649
- const columns = INSERT.columns
650
- || cds.error`Cannot insert rows without columns or elements`
651
-
652
- const inputConverter = this.class._convertInput
653
- const extraction = columns.map((c, i) => {
654
- const extract = `value->>'$[${i}]'`
655
- const element = elements?.[c]
656
- const converter = element?.[inputConverter]
657
- return converter?.(extract, element) || extract
658
- })
659
-
660
- this.columns = columns
645
+ const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
661
646
 
662
647
  if (!elements) {
663
648
  this.entries = INSERT.rows
@@ -675,6 +660,10 @@ class CQN2SQLRenderer {
675
660
  this.entries = [[...this.values, stream]]
676
661
  }
677
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
+
678
667
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
679
668
  }) SELECT ${extraction} FROM json_each(?)`)
680
669
  }
@@ -686,7 +675,7 @@ class CQN2SQLRenderer {
686
675
  */
687
676
  INSERT_values(q) {
688
677
  let { columns, values } = q.INSERT
689
- 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] } })
690
679
  }
691
680
 
692
681
  /**
@@ -737,14 +726,37 @@ class CQN2SQLRenderer {
737
726
  */
738
727
  UPSERT(q) {
739
728
  const { UPSERT } = q
740
- const elements = q.target?.elements || {}
729
+
741
730
  let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
742
- let keys = q.target?.keys
743
- if (!keys) return this.sql = sql
744
- 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)}`)
745
755
 
746
- let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
747
- 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 => {
748
760
  if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
749
761
  let e = elements[c]
750
762
  if (!e) return true //> pass through to native SQL columns not in CDS model
@@ -754,14 +766,8 @@ class CQN2SQLRenderer {
754
766
  else return true
755
767
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
756
768
 
757
- // temporal data
758
- keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
759
-
760
- keys = keys.map(k => this.quote(k))
761
- const conflict = updateColumns.length
762
- ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
763
- : `ON CONFLICT(${keys}) DO NOTHING`
764
- 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'}`)
765
771
  }
766
772
 
767
773
  // UPDATE Statements ------------------------------------------------
@@ -790,7 +796,9 @@ class CQN2SQLRenderer {
790
796
  }
791
797
  }
792
798
 
793
- 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}`)
794
802
 
795
803
  sql += ` SET ${extraction}`
796
804
  if (where) sql += ` WHERE ${this.where(where)}`
@@ -1042,56 +1050,104 @@ class CQN2SQLRenderer {
1042
1050
  }
1043
1051
 
1044
1052
  /**
1045
- * 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
1046
1054
  * @param {object[]} columns
1047
1055
  * @param {import('./infer/cqn').elements} elements
1048
1056
  * @param {Boolean} isUpdate
1049
1057
  * @returns {string[]} Array of SQL expressions for processing input JSON data
1050
1058
  */
1051
- managed(columns, elements, isUpdate = false) {
1052
- 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
+
1053
1063
  const { _convertInput } = this.class
1054
1064
  // Ensure that missing managed columns are added
1055
1065
  const requiredColumns = !elements
1056
1066
  ? []
1057
- : Object.keys(elements)
1058
- .filter(
1059
- e =>
1060
- (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
1061
- !columns.find(c => c.name === e),
1062
- )
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
+ })
1063
1078
  .map(name => ({ name, sql: 'NULL' }))
1064
1079
 
1080
+ const keys = ObjectKeys(elements).filter(e => elements[e].key && !elements[e].isAssociation)
1081
+ const keyZero = keys[0] && this.quote(keys[0])
1082
+
1065
1083
  return [...columns, ...requiredColumns].map(({ name, sql }) => {
1066
- let element = elements?.[name] || {}
1067
- if (!sql) sql = `value->>'$."${name}"'`
1068
-
1069
- let converter = element[_convertInput]
1070
- if (converter && sql[0] !== '$') sql = converter(sql, element)
1071
-
1072
- let val = _managed[element[annotation]?.['=']]
1073
- if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val, param: false }] })})`
1074
- else if (!isUpdate && element.default) {
1075
- const d = element.default
1076
- if (d.val !== undefined || d.ref?.[0] === '$now') {
1077
- // REVISIT: d.ref is not used afterwards
1078
- sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
1079
- } ELSE ${sql} END)`
1080
- }
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)
1081
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
+ )
1082
1121
 
1083
- 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
+ }
1084
1132
  })
1085
1133
  }
1086
1134
 
1087
- /**
1088
- * Returns the default value
1089
- * @param {string} defaultValue
1090
- * @returns {string}
1091
- */
1092
- // REVISIT: This is a strange method, also overridden inconsistently in postgres
1093
- defaultValue(defaultValue = this.context.timestamp.toISOString()) {
1094
- 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)`
1095
1151
  }
1096
1152
  }
1097
1153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.14.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": {