@cap-js/db-service 2.8.2 → 2.10.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/lib/cqn2sql.js CHANGED
@@ -1,14 +1,17 @@
1
1
  const cds = require('@sap/cds')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
+ const { resolveTable } = require('./utils')
5
+
4
6
  const _simple_queries = cds.env.features.sql_simple_queries
5
7
  const _strict_booleans = _simple_queries < 2
8
+ // REVISIT: make string the default in next major
9
+ const _count_as_string = cds.env.features.count_as_string
10
+ const _count = _count_as_string ? { func: 'count', cast: { type: 'cds.String' } } : { func: 'count' }
6
11
 
7
12
  const { Readable } = require('stream')
8
13
 
9
- const DEBUG = cds.debug('sql|sqlite')
10
- const LOG_SQL = cds.log('sql')
11
- const LOG_SQLITE = cds.log('sqlite')
14
+ const DEBUG = cds.log('sql|sqlite')
12
15
 
13
16
  class CQN2SQLRenderer {
14
17
  /**
@@ -26,7 +29,8 @@ class CQN2SQLRenderer {
26
29
  if (cds.env.sql.names === 'quoted') {
27
30
  this.class.prototype.name = (name, query) => {
28
31
  const e = name.id || name
29
- return (query?._target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
32
+ const entity = query?._target || this.model?.definitions[e]
33
+ return (!entity?.['@cds.persistence.skip'] && entity?.['@cds.persistence.name']) || e
30
34
  }
31
35
  this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
32
36
  }
@@ -76,6 +80,7 @@ class CQN2SQLRenderer {
76
80
  */
77
81
  render(q, vars) {
78
82
  const kind = q.kind || Object.keys(q)[0] // SELECT, INSERT, ...
83
+ if (q._with) this._with = q._with
79
84
  /**
80
85
  * @type {string} the rendered SQL string
81
86
  */
@@ -90,13 +95,12 @@ class CQN2SQLRenderer {
90
95
  if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
91
96
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
92
97
 
93
-
94
- if (DEBUG && (LOG_SQL._debug || LOG_SQLITE._debug)) {
98
+ if (DEBUG._debug) {
95
99
  let values = sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []
96
100
  if (values && !Array.isArray(values)) {
97
101
  values = [values]
98
102
  }
99
- DEBUG(this.sql, values)
103
+ DEBUG.debug(this.sql, values)
100
104
  }
101
105
 
102
106
  return this
@@ -257,13 +261,15 @@ class CQN2SQLRenderer {
257
261
 
258
262
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
259
263
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
260
- const columns = this.SELECT_columns(q)
264
+
261
265
  let sql = `SELECT`
262
266
  if (distinct) sql += ` DISTINCT`
263
- if (!_empty(columns)) sql += ` ${columns}`
264
- if (recurse) sql += ` FROM ${this.SELECT_recurse(q)}`
265
- else if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
266
- else sql += this.from_dummy()
267
+ if (recurse) sql += this.SELECT_recurse(q)
268
+ else {
269
+ sql += ` ${this.SELECT_columns(q)}`
270
+ if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
271
+ else sql += this.from_dummy()
272
+ }
267
273
  if (!recurse && !_empty(where)) sql += ` WHERE ${this.where(where)}`
268
274
  if (!recurse && !_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
269
275
  if (!recurse && !_empty(having)) sql += ` HAVING ${this.having(having)}`
@@ -296,7 +302,7 @@ class CQN2SQLRenderer {
296
302
 
297
303
  // `where` needs to be wrapped to also support `where == ['exists', { SELECT }]` which is not allowed in `START WHERE`
298
304
  const clone = q.clone()
299
- clone.columns(keys)
305
+ clone.SELECT.columns = keys
300
306
  clone.SELECT.recurse = undefined
301
307
  clone.SELECT.limit = undefined
302
308
  clone.SELECT.expand = undefined // omits JSON
@@ -347,10 +353,16 @@ class CQN2SQLRenderer {
347
353
  for (const name in target.elements) {
348
354
  const ref = { ref: [name] }
349
355
  const element = target.elements[name]
350
- if (element.virtual || element.value || element.isAssociation) continue
351
- if (element['@Core.Computed'] && name in availableComputedColumns) continue
356
+ if (element.virtual || element.isAssociation) continue
357
+ if (name in availableComputedColumns) continue
352
358
  if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
353
- columnsIn.push(ref)
359
+ // This only supports calculated elements within the scope of the own entity
360
+ if ('value' in element) {
361
+ const requested = columnsFiltered.find(c => this.column_name(c) === element.name)
362
+ if (requested) columnsIn.push(requested)
363
+ else continue
364
+ }
365
+ else columnsIn.push(ref)
354
366
  const foreignkey4 = element._foreignKey4
355
367
  if (
356
368
  from.args ||
@@ -380,7 +392,7 @@ class CQN2SQLRenderer {
380
392
  )
381
393
 
382
394
  if (orderBy) {
383
- orderBy = orderBy.map(r => {
395
+ orderBy = orderBy.filter(o => o.ref).map(r => {
384
396
  let col = r.ref.at(-1)
385
397
  if (col.toUpperCase() in reservedColumnNames) col = `$$${col}$$`
386
398
  if (!columnsIn.find(c => this.column_name(c) === col)) {
@@ -497,13 +509,19 @@ class CQN2SQLRenderer {
497
509
  }
498
510
  }
499
511
 
512
+ const columnsQuery = cds.ql(q).clone()
513
+ columnsQuery.SELECT.columns = columns.map(x => {
514
+ if (x.element && 'value' in x.element) return { element: x.element, ref: [this.column_name(x)] }
515
+ return x
516
+ })
517
+ const recurseColumns = this.SELECT_columns(columnsQuery)
500
518
  // Only apply result join if the columns contain a references which doesn't start with the source alias
501
519
  if (from.args && columns.find(c => c.ref?.[0] === alias)) {
502
520
  graph.as = alias
503
- return this.from(setStableFrom(from, graph))
521
+ return ` ${recurseColumns} FROM ${this.from(setStableFrom(from, graph))}`
504
522
  }
505
523
 
506
- return `(${this.SELECT(graph)})${alias ? ` AS ${this.quote(alias)}` : ''} `
524
+ return ` ${recurseColumns} FROM (${this.SELECT(graph)})${alias ? ` AS ${this.quote(alias)}` : ''} `
507
525
 
508
526
  function collectDistanceTo(where, innot = false) {
509
527
  for (let i = 0; i < where.length; i++) {
@@ -640,7 +658,7 @@ class CQN2SQLRenderer {
640
658
 
641
659
  SELECT_count(q) {
642
660
  const countQuery = cds.ql.clone(q, {
643
- columns: [{ func: 'count' }],
661
+ columns: [_count],
644
662
  one: 0, limit: 0, orderBy: 0, expand: 0, count: 0
645
663
  })
646
664
  countQuery.as = q.as + '@odata.count'
@@ -730,6 +748,38 @@ class CQN2SQLRenderer {
730
748
  return this.xpr({ xpr })
731
749
  }
732
750
 
751
+ /**
752
+ * Renders a transformed where clause that maps the query target view to the source table
753
+ * @param {import('./infer/cqn').source} alias
754
+ * @param {import('./infer/cqn').predicate} where
755
+ * @param {import('./infer/cqn').query} q
756
+ * @returns SQL
757
+ */
758
+ where_resolved(alias, where, q) {
759
+ const transitions = this.srv.resolve.transitions(q)
760
+ if (transitions.target === transitions.queryTarget) return this.where(where)
761
+
762
+ // view and table column refs to be matched
763
+ const viewCols = []
764
+ const tableCols = []
765
+
766
+ // Only match key columns when possible
767
+ const elements = q._target.keys || q._target.elements
768
+ for (const c in elements) {
769
+ if (
770
+ c in elements
771
+ && transitions.mapping.has(c)
772
+ && this.physical_column(elements, c)
773
+ ) {
774
+ viewCols.push({ ref: [c] })
775
+ tableCols.push(transitions.mapping.get(c))
776
+ }
777
+ }
778
+ return tableCols.length > 0
779
+ ? this.where([{ list: tableCols }, 'in', SELECT.from(q._target).alias(alias).columns(viewCols).where(where)])
780
+ : this.where(where)
781
+ }
782
+
733
783
  /**
734
784
  * Renders a HAVING clause into generic SQL
735
785
  * @param {import('./infer/cqn').predicate} xpr
@@ -835,15 +885,20 @@ class CQN2SQLRenderer {
835
885
  if (!elements && !INSERT.entries?.length) {
836
886
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
837
887
  }
888
+ const transitions = this.srv.resolve.transitions(q)
838
889
  const columns = elements
839
- ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation)
890
+ ? ObjectKeys(elements).filter(c => this.physical_column(elements, c)
891
+ && (c = transitions.mapping.get(c)?.ref?.[0] || c)
892
+ && c in transitions.target.elements
893
+ && this.physical_column(transitions.target.elements, c)
894
+ )
840
895
  : ObjectKeys(INSERT.entries[0])
841
896
 
842
897
  /** @type {string[]} */
843
898
  this.columns = columns
844
899
 
845
900
  const alias = INSERT.into.as
846
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
901
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
847
902
  if (!elements) {
848
903
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
849
904
  const param = this.param.bind(this, { ref: ['?'] })
@@ -865,8 +920,8 @@ class CQN2SQLRenderer {
865
920
  }
866
921
 
867
922
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
868
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
869
- }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
923
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
924
+ }) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`)
870
925
  }
871
926
 
872
927
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -972,7 +1027,7 @@ class CQN2SQLRenderer {
972
1027
  */
973
1028
  INSERT_rows(q) {
974
1029
  const { INSERT } = q
975
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
1030
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
976
1031
  const alias = INSERT.into.as
977
1032
  const elements = q.elements || q._target?.elements
978
1033
  const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
@@ -997,7 +1052,8 @@ class CQN2SQLRenderer {
997
1052
  .slice(0, columns.length)
998
1053
  .map(c => c.converter(c.extract))
999
1054
 
1000
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
1055
+ const transitions = this.srv.resolve.transitions(q)
1056
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
1001
1057
  }) SELECT ${extraction} FROM json_each(?)`)
1002
1058
  }
1003
1059
 
@@ -1018,20 +1074,24 @@ class CQN2SQLRenderer {
1018
1074
  */
1019
1075
  INSERT_select(q) {
1020
1076
  const { INSERT } = q
1021
- const entity = this.name(q._target.name, q)
1077
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1022
1078
  const alias = INSERT.into.as
1079
+ const src = this.cqn4sql(INSERT.from)
1023
1080
  const elements = q.elements || q._target?.elements || {}
1024
- let columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1025
- c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
1026
- ))
1081
+ const transitions = this.srv.resolve.transitions(q)
1082
+ let columns = (this.columns = (INSERT.columns || src.SELECT.columns?.map(c => this.column_name(c)) || ObjectKeys(src.elements) || ObjectKeys(elements))
1083
+ .filter(c => this.physical_column(elements, c)
1084
+ && (c = transitions.mapping.get(c)?.ref?.[0] || c)
1085
+ && c in transitions.target.elements
1086
+ && this.physical_column(transitions.target.elements, c)
1087
+ ))
1027
1088
 
1028
- const src = this.cqn4sql(INSERT.from)
1029
1089
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements)
1030
1090
  const sql = extractions.length > columns.length
1031
1091
  ? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW`
1032
1092
  : this.SELECT(src)
1033
1093
  if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
1034
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${sql}`
1094
+ this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql}`
1035
1095
  this.entries = [this.values]
1036
1096
  return this.sql
1037
1097
  }
@@ -1085,7 +1145,7 @@ class CQN2SQLRenderer {
1085
1145
  .join(' AND ')
1086
1146
 
1087
1147
  let columns = this.columns // this.columns is computed as part of this.INSERT
1088
- const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1148
+ const entity = q._target ? this.table_name(q) : this.name(UPSERT.into.ref[0], q)
1089
1149
  if (UPSERT.entries || UPSERT.rows || UPSERT.values) {
1090
1150
  const managed = this._managed.slice(0, columns.length)
1091
1151
 
@@ -1121,7 +1181,8 @@ class CQN2SQLRenderer {
1121
1181
  else return true
1122
1182
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
1123
1183
 
1124
- return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
1184
+ const transitions = this.srv.resolve.transitions(q)
1185
+ return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql
1125
1186
  } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
1126
1187
  }
1127
1188
 
@@ -1134,29 +1195,36 @@ class CQN2SQLRenderer {
1134
1195
  */
1135
1196
  UPDATE(q) {
1136
1197
  const { entity, with: _with, data, where } = q.UPDATE
1198
+ const transitions = this.srv.resolve.transitions(q)
1137
1199
  const elements = q._target?.elements
1138
- let sql = `UPDATE ${this.quote(this.name(entity.ref?.[0] || entity, q))}`
1200
+ let sql = `UPDATE ${this.quote(this.table_name(q))}`
1139
1201
  if (entity.as) sql += ` AS ${this.quote(entity.as)}`
1140
1202
 
1141
- let columns = []
1142
- if (data) _add(data, val => this.val({ val }))
1143
- if (_with) _add(_with, x => this.expr(x))
1144
- function _add(data, sql4) {
1145
- for (let c in data) {
1146
- const columnExistsInDatabase =
1147
- elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
1203
+ const _add = (data, sql4) => {
1204
+ for (let col in data) {
1205
+ const c = transitions.mapping.get(col)?.ref?.[0] || col
1206
+ const columnExistsInDatabase = elements
1207
+ && this.physical_column(elements, col)
1208
+ && c in transitions.target.elements
1209
+ && this.physical_column(transitions.target.elements, c)
1148
1210
  if (!elements || columnExistsInDatabase) {
1149
- columns.push({ name: c, sql: sql4(data[c]) })
1211
+ columns.push({ name: c, sql: sql4(data[col], col) })
1150
1212
  }
1151
1213
  }
1152
1214
  }
1153
1215
 
1216
+ let columns = []
1217
+ if (data) _add(data, val => this.val({ val }))
1218
+ if (_with) _add(_with, x => this.expr(x))
1219
+
1154
1220
  const extraction = this.managed(columns, elements)
1155
- .filter((c, i) => columns[i] || c.onUpdate)
1156
- .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1221
+ .filter((c, i) => {
1222
+ if (transitions.mapping.get(c.name)?.ref?.length > 1) return false
1223
+ return columns[i] || c.onUpdate
1224
+ }).map((c, i) => `${this.quote(transitions.mapping.get(c.name)?.ref?.[0] || c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1157
1225
 
1158
1226
  sql += ` SET ${extraction}`
1159
- if (where) sql += ` WHERE ${this.where(where)}`
1227
+ if (where) sql += ` WHERE ${this.where_resolved(entity.as, where, q)}`
1160
1228
  return (this.sql = sql)
1161
1229
  }
1162
1230
 
@@ -1168,8 +1236,9 @@ class CQN2SQLRenderer {
1168
1236
  * @returns {string} SQL
1169
1237
  */
1170
1238
  DELETE(q) {
1171
- const { DELETE: { from, where } } = q
1172
- let sql = `DELETE FROM ${this.from(from, q)}`
1239
+ const { DELETE: { where, from } } = q
1240
+ let sql = `DELETE FROM ${this.quote(this.table_name(q))}`
1241
+ if (from.as) sql += ` AS ${this.quote(from.as)}`
1173
1242
  if (where) sql += ` WHERE ${this.where(where)}`
1174
1243
  return (this.sql = sql)
1175
1244
  }
@@ -1382,6 +1451,16 @@ class CQN2SQLRenderer {
1382
1451
  return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
1383
1452
  }
1384
1453
 
1454
+ /**
1455
+ * Calculates the Database table name of the query target
1456
+ * @param {import('./infer/cqn').Query} query
1457
+ * @returns {string} Database table name
1458
+ */
1459
+ table_name(q) {
1460
+ const table = resolveTable(q._target)
1461
+ return this.name(table.name, { _target: table })
1462
+ }
1463
+
1385
1464
  /**
1386
1465
  * Calculates the Database name of the given name
1387
1466
  * @param {string|import('./infer/cqn').ref} name
@@ -1489,6 +1568,10 @@ class CQN2SQLRenderer {
1489
1568
  })
1490
1569
  }
1491
1570
 
1571
+ physical_column(elements, c) {
1572
+ return elements[c] && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation
1573
+ }
1574
+
1492
1575
  managed_extract(name, element, converter) {
1493
1576
  const { UPSERT, INSERT } = this.cqn
1494
1577
  const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)