@cap-js/db-service 1.8.0 → 1.9.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,38 @@
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.9.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.9.0...db-service-v1.9.1) (2024-05-16)
8
+
9
+
10
+ ### Fixed
11
+
12
+ * dont mistake non-key access with foreign key ([#642](https://github.com/cap-js/cds-dbs/issues/642)) ([2cd2349](https://github.com/cap-js/cds-dbs/commit/2cd234994d6a9e99765e56f7548a42a35279a790))
13
+
14
+ ## [1.9.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.8.0...db-service-v1.9.0) (2024-05-08)
15
+
16
+
17
+ ### Added
18
+
19
+ * Add missing `func` cqn structures ([#629](https://github.com/cap-js/cds-dbs/issues/629)) ([9d7539a](https://github.com/cap-js/cds-dbs/commit/9d7539ab0fc7e70a6a00c0bd9cb4b3e362976e16))
20
+
21
+
22
+ ### Fixed
23
+
24
+ * **`order by`:** reject non-fk traversals of own columns in order by ([#599](https://github.com/cap-js/cds-dbs/issues/599)) ([3288d63](https://github.com/cap-js/cds-dbs/commit/3288d63f0ee6a96580a3b2138ecb24a944371cf1))
25
+ * Align all quote functions with @sap/cds-compiler ([#619](https://github.com/cap-js/cds-dbs/issues/619)) ([42e9828](https://github.com/cap-js/cds-dbs/commit/42e9828baf11ec55281ea634ce56ce93e6741b91))
26
+ * assign artificial alias if selecting from anonymous subquery ([#608](https://github.com/cap-js/cds-dbs/issues/608)) ([e1a7711](https://github.com/cap-js/cds-dbs/commit/e1a77119f0a5241cfe4f50a37a473f2325ba5bde))
27
+ * avoid spread operator ([#630](https://github.com/cap-js/cds-dbs/issues/630)) ([a39fb65](https://github.com/cap-js/cds-dbs/commit/a39fb65f9419fe60e0324741039d004b40082903))
28
+ * flat update with arbitrary where clauses ([#598](https://github.com/cap-js/cds-dbs/issues/598)) ([f108798](https://github.com/cap-js/cds-dbs/commit/f108798c6c8035f9cdd0b9c6b8f334f1454c2faa))
29
+ * improved `=` and `!=` with val `null` ([#626](https://github.com/cap-js/cds-dbs/issues/626)) ([cbcfe3b](https://github.com/cap-js/cds-dbs/commit/cbcfe3b15e8ebcf7e844dc5406e4bc228d4c94c9))
30
+ * Improved placeholders and limit clause ([#567](https://github.com/cap-js/cds-dbs/issues/567)) ([d5d5dbb](https://github.com/cap-js/cds-dbs/commit/d5d5dbb7219bcef6134440715cf756fdd439f076))
31
+ * multiple result responses ([#602](https://github.com/cap-js/cds-dbs/issues/602)) ([bf0bed4](https://github.com/cap-js/cds-dbs/commit/bf0bed4549fe816e35481b0c9a7547a522a5a593))
32
+ * only consider persisted columns for simple operations ([#592](https://github.com/cap-js/cds-dbs/issues/592)) ([6e31bda](https://github.com/cap-js/cds-dbs/commit/6e31bda1bb15b1770b75c8971773806a26f7d452))
33
+
34
+
35
+ ### Changed
36
+
37
+ * require `>= sap/cds@7.9.0` ([#627](https://github.com/cap-js/cds-dbs/issues/627)) ([f4d09e2](https://github.com/cap-js/cds-dbs/commit/f4d09e27c3b07dd88925e196aefc1087d8357f7a))
38
+
7
39
  ## [1.8.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.7.0...db-service-v1.8.0) (2024-04-12)
8
40
 
9
41
 
package/lib/SQLService.js CHANGED
@@ -1,4 +1,4 @@
1
- const cds = require('@sap/cds/lib'),
1
+ const cds = require('@sap/cds'),
2
2
  DEBUG = cds.debug('sql|db')
3
3
  const { Readable } = require('stream')
4
4
  const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
@@ -135,7 +135,7 @@ class SQLService extends DatabaseService {
135
135
  if (query._streaming) {
136
136
  this._changeToStreams(cqn.SELECT.columns, rows, true, true)
137
137
  if (!rows.length) return
138
-
138
+
139
139
  const result = rows[0]
140
140
  // stream is always on position 0. Further properties like etag are inserted later.
141
141
  let [key, val] = Object.entries(result)[0]
@@ -1,7 +1,7 @@
1
1
  const SessionContext = require('./session-context')
2
2
  const ConnectionPool = require('./generic-pool')
3
3
  const infer = require('../infer')
4
- const cds = require('@sap/cds/lib')
4
+ const cds = require('@sap/cds')
5
5
 
6
6
  /** @typedef {unknown} DatabaseDriver */
7
7
 
@@ -1,4 +1,4 @@
1
- const cds = require('@sap/cds/lib')
1
+ const cds = require('@sap/cds')
2
2
 
3
3
  class SessionContext {
4
4
  constructor(ctx) {
@@ -215,20 +215,6 @@ const StandardFunctions = {
215
215
  */
216
216
  mindatetime: () => "'0001-01-01T00:00:00.000Z'",
217
217
 
218
- // odata spec defines the date time offset type as a normal ISO time stamp
219
- // Where the timezone can either be 'Z' (for UTC) or [+|-]xx:xx for the time offset
220
- /**
221
- * Generates SQL statement that produces the offset in minutes of a given date time offset string
222
- * @param {string} x
223
- * @returns {string}
224
- */
225
- totaloffsetminutes: x => `case
226
- when substr(${x}, length(${x})) = 'z' then 0
227
- else sign( cast( substr(${x}, length(${x}) - 5) as Integer )) *
228
- ( cast( strftime('%H', substr(${x}, length(${x}) - 4 )) as Integer ) * 60 +
229
- cast( strftime('%M', substr(${x},length(${x}) - 4 )) as Integer ))
230
- end`,
231
-
232
218
  // odata spec defines the value format for totalseconds as a duration like: P12DT23H59M59.999999999999S
233
219
  // P -> duration indicator
234
220
  // D -> days, T -> Time seperator, H -> hours, M -> minutes, S -> fractional seconds
package/lib/cqn2sql.js CHANGED
@@ -1,4 +1,4 @@
1
- const cds = require('@sap/cds/lib')
1
+ const cds = require('@sap/cds')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
4
 
@@ -165,12 +165,14 @@ class CQN2SQLRenderer {
165
165
  /** @type {Object<string,import('@sap/cds/apis/csn').Definition>} */
166
166
  static TypeMap = {
167
167
  // Utilizing cds.linked inheritance
168
+ UUID: () => `NVARCHAR(36)`,
168
169
  String: e => `NVARCHAR(${e.length || 5000})`,
169
170
  Binary: e => `VARBINARY(${e.length || 5000})`,
170
- Int64: () => 'BIGINT',
171
- Int32: () => 'INTEGER',
171
+ UInt8: () => 'TINYINT',
172
172
  Int16: () => 'SMALLINT',
173
- UInt8: () => 'SMALLINT',
173
+ Int32: () => 'INT',
174
+ Int64: () => 'BIGINT',
175
+ Integer: () => 'INT',
174
176
  Integer64: () => 'BIGINT',
175
177
  LargeString: () => 'NCLOB',
176
178
  LargeBinary: () => 'BLOB',
@@ -178,12 +180,11 @@ class CQN2SQLRenderer {
178
180
  Composition: () => false,
179
181
  array: () => 'NCLOB',
180
182
  // HANA types
181
- /* Disabled as these types are linked to normal cds types
182
- 'cds.hana.TINYINT': () => 'REAL',
183
+ 'cds.hana.TINYINT': () => 'TINYINT',
183
184
  'cds.hana.REAL': () => 'REAL',
184
185
  'cds.hana.CHAR': e => `CHAR(${e.length || 1})`,
185
186
  'cds.hana.ST_POINT': () => 'ST_POINT',
186
- 'cds.hana.ST_GEOMETRY': () => 'ST_GEO',*/
187
+ 'cds.hana.ST_GEOMETRY': () => 'ST_GEOMETRY',
187
188
  }
188
189
 
189
190
  // DROP Statements ------------------------------------------------
@@ -211,7 +212,7 @@ class CQN2SQLRenderer {
211
212
  if (from?.join && !q.SELECT.columns) {
212
213
  throw new Error('CQN query using joins must specify the selected columns.')
213
214
  }
214
-
215
+
215
216
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
216
217
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
217
218
  let columns = this.SELECT_columns(q)
@@ -290,7 +291,7 @@ class CQN2SQLRenderer {
290
291
  return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
291
292
  }
292
293
  ///////////////////////////////////////////////////////////////////////////////////////
293
- let sql = this.expr(x)
294
+ let sql = this.expr({ param: false, __proto__: x })
294
295
  let alias = this.column_alias4(x, q)
295
296
  if (alias) sql += ' as ' + this.quote(alias)
296
297
  return sql
@@ -302,7 +303,7 @@ class CQN2SQLRenderer {
302
303
  * @returns {string}
303
304
  */
304
305
  column_alias4(x) {
305
- return typeof x.as === 'string' ? x.as : x.func
306
+ return typeof x.as === 'string' ? x.as : x.func || x.val
306
307
  }
307
308
 
308
309
  /**
@@ -387,7 +388,7 @@ class CQN2SQLRenderer {
387
388
  */
388
389
  limit({ rows, offset }) {
389
390
  if (!rows) throw new Error('Rows parameter is missing in SELECT.limit(rows, offset)')
390
- return !offset ? rows.val : `${rows.val} OFFSET ${offset.val}`
391
+ return !offset ? this.val(rows) : `${this.val(rows)} OFFSET ${this.val(offset)}`
391
392
  }
392
393
 
393
394
  /**
@@ -749,8 +750,9 @@ class CQN2SQLRenderer {
749
750
  if (_with) _add(_with, x => this.expr(x))
750
751
  function _add(data, sql4) {
751
752
  for (let c in data) {
752
- if (!elements || (c in elements && !elements[c].virtual)) {
753
- if (cds.unfold && elements?.[c].is_struct) continue // skip structs from universal csn
753
+ const columnExistsInDatabase =
754
+ elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
755
+ if (!elements || columnExistsInDatabase) {
754
756
  columns.push({ name: c, sql: sql4(data[c]) })
755
757
  }
756
758
  }
@@ -798,8 +800,8 @@ class CQN2SQLRenderer {
798
800
  if (x.param) return wrap(this.param(x))
799
801
  if ('ref' in x) return wrap(this.ref(x))
800
802
  if ('val' in x) return wrap(this.val(x))
801
- if ('xpr' in x) return wrap(this.xpr(x))
802
803
  if ('func' in x) return wrap(this.func(x))
804
+ if ('xpr' in x) return wrap(this.xpr(x))
803
805
  if ('list' in x) return wrap(this.list(x))
804
806
  if ('SELECT' in x) return wrap(`(${this.SELECT(x)})`)
805
807
  else throw cds.error`Unsupported expr: ${x}`
@@ -831,18 +833,32 @@ class CQN2SQLRenderer {
831
833
  operator(x, i, xpr) {
832
834
 
833
835
  // Translate = to IS NULL for rhs operand being NULL literal
834
- if (x === '=') return xpr[i + 1]?.val === null ? 'is' : '='
836
+ if (x === '=') return xpr[i + 1]?.val === null
837
+ ? _inline_null(xpr[i + 1]) || 'is'
838
+ : '='
835
839
 
836
840
  // Translate == to IS NOT NULL for rhs operand being NULL literal, otherwise ...
837
841
  // Translate == to IS NOT DISTINCT FROM, unless both operands cannot be NULL
838
- if (x === '==') return xpr[i + 1]?.val === null ? 'is' : _not_null(i - 1) && _not_null(i + 1) ? '=' : this.is_not_distinct_from_
842
+ if (x === '==') return xpr[i + 1]?.val === null
843
+ ? _inline_null(xpr[i + 1]) || 'is'
844
+ : _not_null(i - 1) && _not_null(i + 1)
845
+ ? '='
846
+ : this.is_not_distinct_from_
839
847
 
840
848
  // Translate != to IS NULL for rhs operand being NULL literal, otherwise...
841
849
  // Translate != to IS DISTINCT FROM, unless both operands cannot be NULL
842
- if (x === '!=') return xpr[i + 1]?.val === null ? 'is not' : _not_null(i - 1) && _not_null(i + 1) ? '<>' : this.is_distinct_from_
850
+ if (x === '!=') return xpr[i + 1]?.val === null
851
+ ? _inline_null(xpr[i + 1]) || 'is not'
852
+ : _not_null(i - 1) && _not_null(i + 1)
853
+ ? '<>'
854
+ : this.is_distinct_from_
843
855
 
844
856
  else return x
845
857
 
858
+ function _inline_null(n) {
859
+ n.param = false
860
+ }
861
+
846
862
  /** Checks if the operand at xpr[i+-1] can be NULL. @returns true if not */
847
863
  function _not_null(i) {
848
864
  const operand = xpr[i]
@@ -883,27 +899,34 @@ class CQN2SQLRenderer {
883
899
  }
884
900
 
885
901
  /**
886
- * Renders a value into the correct SQL syntax of a placeholder for a prepared statement
902
+ * Renders a value into the correct SQL syntax or a placeholder for a prepared statement
887
903
  * @param {import('./infer/cqn').val} param0
888
904
  * @returns {string} SQL
889
905
  */
890
906
  val({ val, param }) {
891
907
  switch (typeof val) {
892
908
  case 'function': throw new Error('Function values not supported.')
893
- case 'undefined': return 'NULL'
909
+ case 'undefined': val = null
910
+ break
894
911
  case 'boolean': return `${val}`
895
- case 'number': return `${val}` // REVISIT for HANA
896
912
  case 'object':
897
- if (val === null) return 'NULL'
898
- if (val instanceof Date) val = val.toJSON() // returns null if invalid
899
- else if (val instanceof Readable); // go on with default below
900
- else if (Buffer.isBuffer(val)); // go on with default below
901
- else if (is_regexp(val)) val = val.source
902
- else val = JSON.stringify(val)
903
- case 'string': // eslint-disable-line no-fallthrough
913
+ if (val !== null) {
914
+ if (val instanceof Date) val = val.toJSON() // returns null if invalid
915
+ else if (val instanceof Readable); // go on with default below
916
+ else if (Buffer.isBuffer(val)); // go on with default below
917
+ else if (is_regexp(val)) val = val.source
918
+ else val = JSON.stringify(val)
919
+ }
904
920
  }
905
- if (!this.values || param === false) return this.string(val)
906
- else this.values.push(val)
921
+ if (!this.values || param === false) {
922
+ switch (typeof val) {
923
+ case 'string': return this.string(val)
924
+ case 'object': return 'NULL'
925
+ default:
926
+ return `${val}`
927
+ }
928
+ }
929
+ this.values.push(val)
907
930
  return '?'
908
931
  }
909
932
 
@@ -913,9 +936,32 @@ class CQN2SQLRenderer {
913
936
  * @param {import('./infer/cqn').func} param0
914
937
  * @returns {string} SQL
915
938
  */
916
- func({ func, args }) {
917
- args = (args || []).map(e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) }))
918
- return this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
939
+ func({ func, args, xpr }) {
940
+ const wrap = e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) })
941
+ args = args || []
942
+ if (Array.isArray(args)) {
943
+ args = args.map(wrap)
944
+ } else if (typeof args === 'object') {
945
+ const org = args
946
+ const wrapped = {
947
+ toString: () => {
948
+ const ret = []
949
+ for (const prop in org) {
950
+ ret.push(`${this.quote(prop)} => ${wrapped[prop]}`)
951
+ }
952
+ return ret.join(',')
953
+ }
954
+ }
955
+ for (const prop in args) {
956
+ wrapped[prop] = wrap(args[prop])
957
+ }
958
+ args = wrapped
959
+ } else {
960
+ cds.error`Invalid arguments provided for function '${func}' (${args})`
961
+ }
962
+ const fn = this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
963
+ if (xpr) return `${fn} ${this.xpr({ xpr })}`
964
+ return fn
919
965
  }
920
966
 
921
967
  /**
@@ -967,8 +1013,7 @@ class CQN2SQLRenderer {
967
1013
  quote(s) {
968
1014
  if (typeof s !== 'string') return '"' + s + '"'
969
1015
  if (s.includes('"')) return '"' + s.replace(/"/g, '""') + '"'
970
- // Column names like "Order" clash with "ORDER" keyword so toUpperCase is required
971
- if (s in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
1016
+ if (s in this.class.ReservedWords || !/^[A-Za-z_][A-Za-z_$0-9]*$/.test(s)) return '"' + s + '"'
972
1017
  return s
973
1018
  }
974
1019
 
package/lib/cqn4sql.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const cds = require('@sap/cds/lib')
3
+ const cds = require('@sap/cds')
4
4
  const { computeColumnsToBeSearched } = require('./search')
5
5
 
6
6
  const infer = require('./infer')
@@ -488,21 +488,23 @@ function cqn4sql(originalQuery, model) {
488
488
  }
489
489
 
490
490
  function getTransformedColumn(col) {
491
- if (col.xpr) {
492
- const xpr = { xpr: getTransformedTokenStream(col.xpr) }
493
- if (col.cast) xpr.cast = col.cast
494
- return xpr
495
- } else if (col.func) {
496
- const func = {
491
+ let ret
492
+ if (col.func) {
493
+ ret = {
497
494
  func: col.func,
498
- args: col.args && getTransformedTokenStream(col.args),
495
+ args: getTransformedFunctionArgs(col.args),
499
496
  as: col.func, // may be overwritten by the explicit alias
500
497
  }
501
- if (col.cast) func.cast = col.cast
502
- return func
503
- } else {
504
- return copy(col)
505
498
  }
499
+ if (col.xpr) {
500
+ ret ??= {}
501
+ ret.xpr = getTransformedTokenStream(col.xpr)
502
+ }
503
+ if (ret) {
504
+ if (col.cast) ret.cast = col.cast
505
+ return ret
506
+ }
507
+ return copy(col)
506
508
  }
507
509
 
508
510
  function handleEmptyColumns(columns) {
@@ -537,7 +539,7 @@ function cqn4sql(originalQuery, model) {
537
539
  } else if (val) {
538
540
  res = { val }
539
541
  } else if (func) {
540
- res = { args: getTransformedTokenStream(value.args, baseLink), func: value.func }
542
+ res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
541
543
  }
542
544
  if (!omitAlias) res.as = column.as || column.name || column.flatName
543
545
  return res
@@ -931,7 +933,7 @@ function cqn4sql(originalQuery, model) {
931
933
  let transformedColumn
932
934
  if (col.SELECT) transformedColumn = transformSubquery(col)
933
935
  else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
934
- else if (col.func) transformedColumn = { args: getTransformedTokenStream(col.args), func: col.func }
936
+ else if (col.func) transformedColumn = { args: getTransformedFunctionArgs(col.args), func: col.func }
935
937
  // val
936
938
  else transformedColumn = copy(col)
937
939
  if (col.sort) transformedColumn.sort = col.sort
@@ -1427,15 +1429,13 @@ function cqn4sql(originalQuery, model) {
1427
1429
  }
1428
1430
  } else if (token.SELECT) {
1429
1431
  result = transformSubquery(token)
1430
- } else if (token.xpr) {
1431
- result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1432
- } else if (token.func && token.args) {
1433
- result.args = token.args.map(t => {
1434
- if (!t.val)
1435
- // this must not be touched
1436
- return getTransformedTokenStream([t], $baseLink)[0]
1437
- return t
1438
- })
1432
+ } else {
1433
+ if (token.xpr) {
1434
+ result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1435
+ }
1436
+ if (token.func && token.args) {
1437
+ result.args = getTransformedFunctionArgs(token.args, $baseLink)
1438
+ }
1439
1439
  }
1440
1440
 
1441
1441
  transformedTokenStream.push(result)
@@ -1576,9 +1576,13 @@ function cqn4sql(originalQuery, model) {
1576
1576
  return { transformedFrom }
1577
1577
  } else if (from.SELECT) {
1578
1578
  transformedFrom = transformSubquery(from)
1579
- if (from.as)
1579
+ if (from.as) {
1580
1580
  // preserve explicit TA
1581
1581
  transformedFrom.as = from.as
1582
+ } else {
1583
+ // select from anonymous query, use artificial alias
1584
+ transformedFrom.as = Object.keys(originalQuery.sources)[0]
1585
+ }
1582
1586
  return { transformedFrom }
1583
1587
  } else {
1584
1588
  return _transformFrom()
@@ -2138,6 +2142,27 @@ function cqn4sql(originalQuery, model) {
2138
2142
  return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index)
2139
2143
  }
2140
2144
  }
2145
+ function getTransformedFunctionArgs(args, $baseLink = null) {
2146
+ let result = null
2147
+ if (Array.isArray(args)) {
2148
+ result = args.map(t => {
2149
+ if (!t.val)
2150
+ // this must not be touched
2151
+ return getTransformedTokenStream([t], $baseLink)[0]
2152
+ return t
2153
+ })
2154
+ } else if (typeof args === 'object') {
2155
+ result = {}
2156
+ for (const prop in args) {
2157
+ const t = args[prop]
2158
+ if (!t.val)
2159
+ // this must not be touched
2160
+ result[prop] = getTransformedTokenStream([t], $baseLink)[0]
2161
+ else result[prop] = t
2162
+ }
2163
+ }
2164
+ return result
2165
+ }
2141
2166
  }
2142
2167
 
2143
2168
  module.exports = Object.assign(cqn4sql, {
@@ -2150,10 +2175,10 @@ module.exports = Object.assign(cqn4sql, {
2150
2175
  function calculateElementName(token) {
2151
2176
  const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
2152
2177
  let name
2153
- if (nonJoinRelevantAssoc)
2178
+ if (nonJoinRelevantAssoc !== -1)
2154
2179
  // calculate fk name
2155
2180
  name = token.ref.slice(nonJoinRelevantAssoc).join('_')
2156
- else name = token.$refLinks[token.$refLinks.length - 1].definition.name
2181
+ else name = getFullName(token.$refLinks[token.$refLinks.length - 1].definition)
2157
2182
  return name
2158
2183
  }
2159
2184
 
@@ -2222,6 +2247,7 @@ function setElementOnColumns(col, element) {
2222
2247
  writable: true,
2223
2248
  })
2224
2249
  }
2250
+
2225
2251
  const getName = col => col.as || col.ref?.at(-1)
2226
2252
  const idOnly = ref => ref.id || ref
2227
2253
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
@@ -1,6 +1,21 @@
1
1
  const cds = require('@sap/cds')
2
- const { compareJson } = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson')
3
2
  const { _target_name4 } = require('./SQLService')
3
+ const InsertResult = require('../lib/InsertResults')
4
+
5
+ // REVISIT: remove old path with cds^8
6
+ let _compareJson
7
+ const compareJson = (...args) => {
8
+ if (!_compareJson) {
9
+ try {
10
+ // new path
11
+ _compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
12
+ } catch {
13
+ // old path
14
+ _compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
15
+ }
16
+ }
17
+ return _compareJson(...args)
18
+ }
4
19
 
5
20
  const handledDeep = Symbol('handledDeep')
6
21
 
@@ -35,7 +50,15 @@ async function onDeep(req, next) {
35
50
  if (query.UPDATE) return this.onUPDATE({ query })
36
51
  if (query.DELETE) return this.onSIMPLE({ query })
37
52
  }))
38
- return res[0] ?? 0 // TODO what todo with multiple result responses?
53
+ return (
54
+ beforeData.length ||
55
+ new InsertResult(query, [
56
+ {
57
+ changes: Array.isArray(req.data) ? req.data.length : 1,
58
+ ...(res[0]?.results[0]?.lastInsertRowid ? { lastInsertRowid: res[0].results[0].lastInsertRowid } : {}),
59
+ },
60
+ ])
61
+ )
39
62
  }
40
63
 
41
64
  const hasDeep = (q, target) => {
@@ -239,7 +262,7 @@ const _getDeepQueries = (diff, target, root = false) => {
239
262
  queries.push(cqn)
240
263
  }
241
264
 
242
- queries.push(...subQueries)
265
+ for (const q of subQueries) queries.push(q)
243
266
  }
244
267
 
245
268
  const insertQueries = new Map()
@@ -265,4 +288,5 @@ module.exports = {
265
288
  onDeep,
266
289
  getDeepQueries,
267
290
  getExpandForDeep,
291
+ hasDeep,
268
292
  }
@@ -1,4 +1,5 @@
1
1
  const cds = require('@sap/cds')
2
+ const { hasDeep } = require('../lib/deep-queries')
2
3
 
3
4
  // REVISIT: very deep & fragile dependencies to internal modules -> copy these into here
4
5
  const propagateForeignKeys = require('@sap/cds/libx/_runtime/common/utils/propagateForeignKeys')
@@ -57,9 +58,10 @@ const generateUUIDandPropagateKeys = (entity, data, event) => {
57
58
  module.exports = async function fill_in_keys(req, next) {
58
59
  // REVISIT dummy handler until we have input processing
59
60
  if (!req.target || !this.model || req.target._unresolved) return next()
60
- if (req.event === 'UPDATE') {
61
+ // only for deep update
62
+ if (req.event === 'UPDATE' && hasDeep(req.query, req.target)) {
61
63
  // REVISIT for deep update we need to inject the keys first
62
- enrichDataWithKeysFromWhere (req.data, req, this)
64
+ enrichDataWithKeysFromWhere(req.data, req, this)
63
65
  }
64
66
 
65
67
  // REVISIT no input processing for INPUT with rows/values
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const cds = require('@sap/cds/lib')
3
+ const cds = require('@sap/cds')
4
4
 
5
5
  const JoinTree = require('./join-tree')
6
6
  const { pseudos } = require('./pseudos')
@@ -123,8 +123,11 @@ function infer(originalQuery, model) {
123
123
  } else if (from.args) {
124
124
  from.args.forEach(a => inferTarget(a, querySources))
125
125
  } else if (from.SELECT) {
126
- infer(from, model) // we need the .elements in the sources
127
- querySources[from.as || ''] = { definition: from }
126
+ const subqueryInFrom = infer(from, model) // we need the .elements in the sources
127
+ // if no explicit alias is provided, we make up one
128
+ const subqueryAlias =
129
+ from.as || subqueryInFrom.joinTree.addNextAvailableTableAlias('__select__', subqueryInFrom.outerQueries)
130
+ querySources[subqueryAlias] = { definition: from }
128
131
  } else if (typeof from === 'string') {
129
132
  // TODO: Create unique alias, what about duplicates?
130
133
  const definition = getDefinition(from) || cds.error`"${from}" not found in the definitions of your model`
@@ -163,7 +166,7 @@ function infer(originalQuery, model) {
163
166
  function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
164
167
  const { ref, xpr, args, list } = arg
165
168
  if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
166
- if (args) args.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
169
+ if (args) applyToFunctionArgs(args, attachRefLinksToArg, [$baseLink, expandOrExists])
167
170
  if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
168
171
  if (!ref) return
169
172
  init$refLinks(arg)
@@ -180,13 +183,15 @@ function infer(originalQuery, model) {
180
183
  // only fk access in infix filter
181
184
  const nextStep = ref[1]?.id || ref[1]
182
185
  // no unmanaged assoc in infix filter path
183
- if (!expandOrExists && e.on)
184
- throw new Error(
185
- `"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
186
- )
186
+ if (!expandOrExists && e.on) {
187
+ const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”`
188
+ throw new Error(err)
189
+ }
187
190
  // no non-fk traversal in infix filter
188
191
  if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e))
189
- throw new Error(`Only foreign keys of "${e.name}" can be accessed in infix filter`)
192
+ throw new Error(
193
+ `Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`,
194
+ )
190
195
  }
191
196
  arg.$refLinks.push({ definition: e, target: definition })
192
197
  // filter paths are flattened
@@ -301,10 +306,15 @@ function infer(originalQuery, model) {
301
306
  if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
302
307
  if (col.xpr || col.SELECT) {
303
308
  queryElements[as] = getElementForXprOrSubquery(col)
304
- } else if (col.func) {
305
- col.args?.forEach(arg => inferQueryElement(arg, false)) // {func}.args are optional
309
+ }
310
+ if (col.func) {
311
+ if (col.args) {
312
+ // {func}.args are optional
313
+ applyToFunctionArgs(col.args, inferQueryElement, [false])
314
+ }
306
315
  queryElements[as] = getElementForCast(col)
307
- } else {
316
+ }
317
+ if (!queryElements[as]) {
308
318
  // either binding parameter (col.param) or value
309
319
  queryElements[as] = col.cast ? getElementForCast(col) : getCdsTypeForVal(col.val)
310
320
  }
@@ -334,17 +344,38 @@ function infer(originalQuery, model) {
334
344
  // link $refLinks -> special name resolution rules for orderBy
335
345
  orderBy.forEach(token => {
336
346
  let $baseLink
347
+ let rejectJoinRelevantPath
337
348
  // first check if token ref is resolvable in query elements
338
349
  if (columns) {
339
- const e = queryElements[token.ref?.[0]]
340
- const isAssocExpand = e?.$assocExpand // expand on structure can be addressed
341
- if (e && !isAssocExpand) $baseLink = { definition: { elements: queryElements }, target: inferred }
350
+ const firstStep = token.ref?.[0].id || token.ref?.[0]
351
+ const tokenPointsToQueryElements = columns.some(c => {
352
+ const columnName = c.as || c.flatName || c.ref?.at(-1).id || c.ref?.at(-1) || c.func
353
+ return columnName === firstStep
354
+ })
355
+ const needsElementsOfQueryAsBase =
356
+ tokenPointsToQueryElements &&
357
+ queryElements[token.ref?.[0]] &&
358
+ /* expand on structure can be addressed */ !queryElements[token.ref?.[0]].$assocExpand
359
+
360
+ // if the ref points into the query itself and follows an exposed association
361
+ // to a non-fk column, we must reject the ref, as we can't join with the queries own results
362
+ rejectJoinRelevantPath = needsElementsOfQueryAsBase
363
+ if (needsElementsOfQueryAsBase) $baseLink = { definition: { elements: queryElements }, target: inferred }
342
364
  } else {
343
365
  // fallback to elements of query source
344
366
  $baseLink = null
345
367
  }
346
368
 
347
369
  inferQueryElement(token, false, $baseLink)
370
+ if (token.isJoinRelevant && rejectJoinRelevantPath) {
371
+ // reverse the array, find the last association and calculate the index of the association in non-reversed order
372
+ const assocIndex =
373
+ token.$refLinks.length - 1 - token.$refLinks.reverse().findIndex(link => link.definition.isAssociation)
374
+
375
+ throw new Error(
376
+ `Can follow managed association “${token.ref[assocIndex].id || token.ref[assocIndex]}” only to the keys of its target, not to “${token.ref[assocIndex + 1].id || token.ref[assocIndex + 1]}”`,
377
+ )
378
+ }
348
379
  })
349
380
  }
350
381
 
@@ -467,9 +498,11 @@ function infer(originalQuery, model) {
467
498
  */
468
499
 
469
500
  function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, context) {
470
- const { inExists, inExpr, inNestedProjection, inCalcElement, baseColumn } = context || {}
501
+ const { inExists, inExpr, inCalcElement, baseColumn, inInfixFilter } = context || {}
471
502
  if (column.param || column.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ?
472
- if (column.args) column.args.forEach(arg => inferQueryElement(arg, false, $baseLink, context)) // e.g. function in expression
503
+ if (column.args) {
504
+ applyToFunctionArgs(column.args, inferQueryElement, [false, $baseLink, context])
505
+ }
473
506
  if (column.list) column.list.forEach(arg => inferQueryElement(arg, false, $baseLink, context))
474
507
  if (column.xpr)
475
508
  column.xpr.forEach(token => inferQueryElement(token, false, $baseLink, { ...context, inExpr: true })) // e.g. function in expression
@@ -594,21 +627,25 @@ function infer(originalQuery, model) {
594
627
  if (!column.$refLinks[i].definition.target || danglingFilter)
595
628
  throw new Error('A filter can only be provided when navigating along associations')
596
629
  if (!column.expand) Object.defineProperty(column, 'isJoinRelevant', { value: true })
597
- // books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
598
- let skipJoinsForFilter = inExists
630
+ let skipJoinsForFilter = false
599
631
  step.where.forEach(token => {
600
632
  if (token === 'exists') {
601
- // no joins for infix filters along `exists <path>`
633
+ // books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
602
634
  skipJoinsForFilter = true
603
635
  } else if (token.ref || token.xpr) {
604
636
  inferQueryElement(token, false, column.$refLinks[i], {
605
637
  inExists: skipJoinsForFilter,
606
638
  inExpr: !!token.xpr,
639
+ inInfixFilter: true,
607
640
  })
608
641
  } else if (token.func) {
609
- token.args?.forEach(arg =>
610
- inferQueryElement(arg, false, column.$refLinks[i], { inExists: skipJoinsForFilter, inExpr: true }),
611
- )
642
+ if (token.args) {
643
+ applyToFunctionArgs(token.args, inferQueryElement, [
644
+ false,
645
+ column.$refLinks[i],
646
+ { inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: true },
647
+ ])
648
+ }
612
649
  }
613
650
  })
614
651
  }
@@ -668,17 +705,19 @@ function infer(originalQuery, model) {
668
705
  * @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element.
669
706
  */
670
707
  function rejectNonFkAccess(assoc) {
671
- if (!inNestedProjection && !inCalcElement && assoc.target) {
708
+ if (inInfixFilter && assoc.target) {
672
709
  // only fk access in infix filter
673
710
  const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
674
711
  // no unmanaged assoc in infix filter path
675
- if (!inExists && assoc.on)
676
- throw new Error(
677
- `"${assoc.name}" in path "${column.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
678
- )
712
+ if (!inExists && assoc.on) {
713
+ const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”`
714
+ throw new Error(err)
715
+ }
679
716
  // no non-fk traversal in infix filter in non-exists path
680
717
  if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc))
681
- throw new Error(`Only foreign keys of "${assoc.name}" can be accessed in infix filter`)
718
+ throw new Error(
719
+ `Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`,
720
+ )
682
721
  }
683
722
  }
684
723
  })
@@ -727,12 +766,14 @@ function infer(originalQuery, model) {
727
766
  function resolveInline(col, namePrefix = col.as || col.flatName) {
728
767
  const { inline, $refLinks } = col
729
768
  const $leafLink = $refLinks[$refLinks.length - 1]
730
- if(!$leafLink.definition.target && !$leafLink.definition.elements) {
731
- throw new Error(`Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`)
769
+ if (!$leafLink.definition.target && !$leafLink.definition.elements) {
770
+ throw new Error(
771
+ `Unexpected “inline” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
772
+ )
732
773
  }
733
774
  let elements = {}
734
775
  inline.forEach(inlineCol => {
735
- inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, inNestedProjection: true, baseColumn: col })
776
+ inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, baseColumn: col })
736
777
  if (inlineCol === '*') {
737
778
  const wildCardElements = {}
738
779
  // either the `.elements´ of the struct or the `.elements` of the assoc target
@@ -783,8 +824,10 @@ function infer(originalQuery, model) {
783
824
  function resolveExpand(col) {
784
825
  const { expand, $refLinks } = col
785
826
  const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
786
- if(!$leafLink.definition.target && !$leafLink.definition.elements) {
787
- throw new Error(`Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`)
827
+ if (!$leafLink.definition.target && !$leafLink.definition.elements) {
828
+ throw new Error(
829
+ `Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
830
+ )
788
831
  }
789
832
  const target = getDefinition($leafLink.definition.target)
790
833
  if (target) {
@@ -807,7 +850,7 @@ function infer(originalQuery, model) {
807
850
  if (e === '*') {
808
851
  elements = { ...elements, ...$leafLink.definition.elements }
809
852
  } else {
810
- inferQueryElement(e, false, $leafLink, { inExpr: true, inNestedProjection: true })
853
+ inferQueryElement(e, false, $leafLink, { inExpr: true })
811
854
  if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
812
855
  if (e.inline) elements = { ...elements, ...resolveInline(e) }
813
856
  else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
@@ -850,7 +893,7 @@ function infer(originalQuery, model) {
850
893
  const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
851
894
  if (alreadySeenCalcElements.has(calcElement)) return
852
895
  else alreadySeenCalcElements.add(calcElement)
853
- const { ref, xpr, func } = calcElement.value
896
+ const { ref, xpr } = calcElement.value
854
897
  if (ref || xpr) {
855
898
  baseLink = baseLink || { definition: calcElement.parent, target: calcElement.parent }
856
899
  attachRefLinksToArg(calcElement.value, baseLink, true)
@@ -864,8 +907,9 @@ function infer(originalQuery, model) {
864
907
  }
865
908
  mergePathsIntoJoinTree(calcElement.value, basePath)
866
909
  }
867
- if (func)
868
- calcElement.value.args?.forEach(arg => {
910
+
911
+ if (calcElement.value.args) {
912
+ const processArgument = (arg, calcElement, column) => {
869
913
  inferQueryElement(
870
914
  arg,
871
915
  false,
@@ -877,7 +921,12 @@ function infer(originalQuery, model) {
877
921
  ? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
878
922
  : { $refLinks: [], ref: [] }
879
923
  mergePathsIntoJoinTree(arg, basePath)
880
- }) // {func}.args are optional
924
+ }
925
+
926
+ if (calcElement.value.args) {
927
+ applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column])
928
+ }
929
+ }
881
930
 
882
931
  /**
883
932
  * Calculates all paths from a given ref and merges them into the join tree.
@@ -1167,4 +1216,9 @@ function isForeignKeyOf(e, assoc) {
1167
1216
  }
1168
1217
  const idOnly = ref => ref.id || ref
1169
1218
 
1219
+ function applyToFunctionArgs(funcArgs, cb, cbArgs) {
1220
+ if (Array.isArray(funcArgs)) funcArgs.forEach(arg => cb(arg, ...cbArgs))
1221
+ else if (typeof funcArgs === 'object') Object.keys(funcArgs).forEach(prop => cb(funcArgs[prop], ...cbArgs))
1222
+ }
1223
+
1170
1224
  module.exports = infer
@@ -176,6 +176,8 @@ class JoinTree {
176
176
  i += 1 // skip first step which is table alias
177
177
  }
178
178
 
179
+ // if no root node was found, the column is selected from a subquery
180
+ if(!node) return
179
181
  while (i < col.ref.length) {
180
182
  const step = col.ref[i]
181
183
  const { where, args } = step
@@ -197,7 +199,7 @@ class JoinTree {
197
199
  }
198
200
  const child = new Node($refLink, node, where, args)
199
201
  if (child.$refLink.definition.isAssociation) {
200
- if (child.where || col.inline) {
202
+ if (child.where || child.$refLink.definition.on || col.inline) {
201
203
  // filter is always join relevant
202
204
  // if the column ends up in an `inline` -> each assoc step is join relevant
203
205
  child.$refLink.onlyForeignKeyAccess = false
@@ -210,9 +212,11 @@ class JoinTree {
210
212
  const elements =
211
213
  node.$refLink?.definition.isAssociation &&
212
214
  (node.$refLink.definition.elements || node.$refLink.definition.foreignKeys)
213
- if (node.$refLink && (!elements || !(child.$refLink.definition.name in elements)))
214
- // foreign key access
215
+ if (node.$refLink && (!elements || !(child.$refLink.definition.name in elements))) {
216
+ // no foreign key access
215
217
  node.$refLink.onlyForeignKeyAccess = false
218
+ col.$refLinks[i - 1] = node.$refLink
219
+ }
216
220
 
217
221
  node.children.set(id, child)
218
222
  node = child
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.8.0",
3
+ "version": "1.9.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": {
@@ -29,7 +29,7 @@
29
29
  "test": "jest --silent"
30
30
  },
31
31
  "peerDependencies": {
32
- "@sap/cds": ">=7.6"
32
+ "@sap/cds": ">=7.9"
33
33
  },
34
34
  "license": "SEE LICENSE"
35
35
  }