@cap-js/db-service 1.7.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@
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.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.8.0...db-service-v1.9.0) (2024-05-08)
8
+
9
+
10
+ ### Added
11
+
12
+ * 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))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * **`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))
18
+ * 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))
19
+ * 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))
20
+ * avoid spread operator ([#630](https://github.com/cap-js/cds-dbs/issues/630)) ([a39fb65](https://github.com/cap-js/cds-dbs/commit/a39fb65f9419fe60e0324741039d004b40082903))
21
+ * 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))
22
+ * 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))
23
+ * 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))
24
+ * multiple result responses ([#602](https://github.com/cap-js/cds-dbs/issues/602)) ([bf0bed4](https://github.com/cap-js/cds-dbs/commit/bf0bed4549fe816e35481b0c9a7547a522a5a593))
25
+ * 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))
26
+
27
+
28
+ ### Changed
29
+
30
+ * 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))
31
+
32
+ ## [1.8.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.7.0...db-service-v1.8.0) (2024-04-12)
33
+
34
+
35
+ ### Added
36
+
37
+ * Odata built-in query functions ([#558](https://github.com/cap-js/cds-dbs/issues/558)) ([6e63367](https://github.com/cap-js/cds-dbs/commit/6e6336757129c4a9dac56f93fd768bb41d071c46))
38
+ * support HANA stored procedures ([#542](https://github.com/cap-js/cds-dbs/issues/542)) ([52a00a0](https://github.com/cap-js/cds-dbs/commit/52a00a0d642ba3c58dcad97b3ea1456f1bf3b04a))
39
+
40
+
41
+ ### Fixed
42
+
43
+ * **`expand`:** Only accept on structures, assocs or table aliases ([#551](https://github.com/cap-js/cds-dbs/issues/551)) ([3248512](https://github.com/cap-js/cds-dbs/commit/32485129147cd1b376f1d2faf2ea7c7232ba3794))
44
+ * **`order by`:** for localized sorting, prepend table alias ([#546](https://github.com/cap-js/cds-dbs/issues/546)) ([a273a92](https://github.com/cap-js/cds-dbs/commit/a273a9278b2551ed3381795effe28cf8de41b1bd))
45
+ * etag with stream_compat ([#562](https://github.com/cap-js/cds-dbs/issues/562)) ([b0a3a41](https://github.com/cap-js/cds-dbs/commit/b0a3a418fbcff7eb7e7b8fa4ff031e1c0c0faac4))
46
+ * exclude `cds.LargeBinary` from wildcard expansion ([#577](https://github.com/cap-js/cds-dbs/issues/577)) ([6661d63](https://github.com/cap-js/cds-dbs/commit/6661d635b2895a13d47e42495acf6fbd7247c535))
47
+ * Reduce insert queries for deep update ([#568](https://github.com/cap-js/cds-dbs/issues/568)) ([55e5114](https://github.com/cap-js/cds-dbs/commit/55e511471743c0445d41e8297f5530abe167a270))
48
+ * Reduced count query complexity when possible ([#553](https://github.com/cap-js/cds-dbs/issues/553)) ([3331f02](https://github.com/cap-js/cds-dbs/commit/3331f0224f02bd2e6cc9c6d2cd5f1c37a36ec8dd))
49
+
7
50
  ## [1.7.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.6.4...db-service-v1.7.0) (2024-03-22)
8
51
 
9
52
 
package/lib/SQLService.js CHANGED
@@ -134,7 +134,15 @@ class SQLService extends DatabaseService {
134
134
  if (cds.env.features.stream_compat) {
135
135
  if (query._streaming) {
136
136
  this._changeToStreams(cqn.SELECT.columns, rows, true, true)
137
- return rows.length ? { value: Object.values(rows[0])[0] } : undefined
137
+ if (!rows.length) return
138
+
139
+ const result = rows[0]
140
+ // stream is always on position 0. Further properties like etag are inserted later.
141
+ let [key, val] = Object.entries(result)[0]
142
+ result.value = val
143
+ delete result[key]
144
+
145
+ return result
138
146
  }
139
147
  } else {
140
148
  this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one, false)
@@ -265,7 +273,7 @@ class SQLService extends DatabaseService {
265
273
  * @param {string} sql
266
274
  */
267
275
  hasResults(sql) {
268
- return /^(SELECT|WITH|CALL|PRAGMA table_info)/i.test(sql)
276
+ return /^\s*(SELECT|WITH|CALL|PRAGMA table_info)/i.test(sql)
269
277
  }
270
278
 
271
279
  /**
@@ -281,17 +289,23 @@ class SQLService extends DatabaseService {
281
289
  const [max, offset = 0] = one ? [1] : _ ? [_.rows?.val, _.offset?.val] : []
282
290
  if (max === undefined || (n < max && (n || !offset))) return n + offset
283
291
  }
284
- // REVISIT: made uppercase count because of HANA reserved word quoting
285
- const cq = SELECT.one([{ func: 'count', as: 'COUNT' }]).from(
292
+
293
+ // Keep original query columns when potentially used insde conditions
294
+ const { having, groupBy } = query.SELECT
295
+ const columns = (having?.length || groupBy?.length)
296
+ ? query.SELECT.columns.filter(c => !c.expand)
297
+ : [{ val: 1 }]
298
+ const cq = SELECT.one([{ func: 'count' }]).from(
286
299
  cds.ql.clone(query, {
300
+ columns,
287
301
  localized: false,
288
302
  expand: false,
289
303
  limit: undefined,
290
304
  orderBy: undefined,
291
305
  }),
292
306
  )
293
- const { count, COUNT } = await this.onSELECT({ query: cq })
294
- return count ?? COUNT
307
+ const { count } = await this.onSELECT({ query: cq })
308
+ return count
295
309
  }
296
310
 
297
311
  /**
@@ -152,6 +152,13 @@ const StandardFunctions = {
152
152
  current_time: p => (p ? `current_time(${p})` : 'current_time'),
153
153
  current_timestamp: p => (p ? `current_timestamp(${p})` : 'current_timestamp'),
154
154
 
155
+ /**
156
+ * Generates SQL statement that produces current point in time (date and time with time zone)
157
+ * @returns {string}
158
+ */
159
+ now: function() {
160
+ return this.session_context({val: '$now'})
161
+ },
155
162
  /**
156
163
  * Generates SQL statement that produces the year of a given timestamp
157
164
  * @param {string} x
@@ -189,38 +196,24 @@ const StandardFunctions = {
189
196
  * /
190
197
  second: x => `cast( strftime('%S',${x}) as Integer )`,
191
198
 
199
+ // REVISIT: make precision configurable
192
200
  /**
193
201
  * Generates SQL statement that produces the fractional seconds of a given timestamp
194
202
  * @param {string} x
195
203
  * @returns {string}
196
204
  */
197
- fractionalseconds: x => `cast( strftime('%f0000',${x}) as Integer )`,
205
+ fractionalseconds: x => `cast( substr( strftime('%f', ${x}), length(strftime('%f', ${x})) - 3) as REAL)`,
198
206
 
199
207
  /**
200
208
  * maximum date time value
201
209
  * @returns {string}
202
210
  */
203
- maxdatetime: () => '9999-12-31 23:59:59.999',
211
+ maxdatetime: () => "'9999-12-31T23:59:59.999Z'",
204
212
  /**
205
213
  * minimum date time value
206
214
  * @returns {string}
207
215
  */
208
- mindatetime: () => '0001-01-01 00:00:00.000',
209
-
210
- // odata spec defines the date time offset type as a normal ISO time stamp
211
- // Where the timezone can either be 'Z' (for UTC) or [+|-]xx:xx for the time offset
212
- // sqlite understands this so by splitting the timezone from the actual date
213
- // prefixing it with 1970 it allows sqlite to give back the number of seconds
214
- // which can be divided by 60 back to minutes
215
- /**
216
- * Generates SQL statement that produces the offset in minutes of a given date time offset string
217
- * @param {string} x
218
- * @returns {string}
219
- */
220
- totaloffsetminutes: x => `case
221
- when substr(${x}, length(${x})) = 'z' then 0
222
- else strftime('%s', '1970-01-01T00:00:00' || substr(${x}, length(${x}) - 5)) / 60
223
- end`,
216
+ mindatetime: () => "'0001-01-01T00:00:00.000Z'",
224
217
 
225
218
  // odata spec defines the value format for totalseconds as a duration like: P12DT23H59M59.999999999999S
226
219
  // P -> duration indicator
package/lib/cqn2sql.js CHANGED
@@ -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
@@ -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
@@ -854,7 +856,7 @@ function cqn4sql(originalQuery, model) {
854
856
  function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
855
857
  const res = []
856
858
  for (let i = 0; i < columns.length; i++) {
857
- const col = columns[i]
859
+ let col = columns[i]
858
860
  if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
859
861
  const calcElement = resolveCalculatedElement(col, true)
860
862
  res.push(calcElement)
@@ -877,8 +879,37 @@ function cqn4sql(originalQuery, model) {
877
879
  res.push(...getTransformedOrderByGroupBy([dollarSelfReplacement], inOrderBy))
878
880
  continue
879
881
  }
880
- const { target } = col.$refLinks[0]
881
- const tableAlias = target.SELECT ? null : getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
882
+ const { target, definition } = col.$refLinks[0]
883
+ let tableAlias = null
884
+ if (target.SELECT?.columns && inOrderBy) {
885
+ // usually TA is omitted if order by ref is a column
886
+ // if a localized sorting is requested, we add `COLLATE`s
887
+ // later on, which transforms the simple name to an expression
888
+ // --> in an expression, only source elements can be addressed, hence we must add TA
889
+ if (target.SELECT.localized && definition.type === 'cds.String') {
890
+ const referredCol = target.SELECT.columns.find(c => {
891
+ return c.as === col.ref[0] || c.ref?.at(-1) === col.ref[0]
892
+ })
893
+ if (referredCol) {
894
+ // keep sort and nulls properties
895
+ referredCol.sort = col.sort
896
+ referredCol.nulls = col.nulls
897
+ col = referredCol
898
+ if (definition.kind === 'element') {
899
+ tableAlias = getQuerySourceName(col)
900
+ } else {
901
+ // we must replace the reference with the underlying expression
902
+ const { val, func, args, xpr } = col
903
+ if (val) res.push({ val })
904
+ if (func) res.push({ func, args })
905
+ if (xpr) res.push({ xpr })
906
+ continue
907
+ }
908
+ }
909
+ }
910
+ } else {
911
+ tableAlias = getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
912
+ }
882
913
  const leaf = col.$refLinks[col.$refLinks.length - 1].definition
883
914
  if (leaf.virtual === true) continue // already in getFlatColumnForElement
884
915
  let baseName
@@ -902,7 +933,7 @@ function cqn4sql(originalQuery, model) {
902
933
  let transformedColumn
903
934
  if (col.SELECT) transformedColumn = transformSubquery(col)
904
935
  else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
905
- 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 }
906
937
  // val
907
938
  else transformedColumn = copy(col)
908
939
  if (col.sort) transformedColumn.sort = col.sort
@@ -972,7 +1003,7 @@ function cqn4sql(originalQuery, model) {
972
1003
  const { index, tableAlias } = inferred.$combinedElements[k][0]
973
1004
  const element = tableAlias.elements[k]
974
1005
  // ignore FK for odata csn / ignore blobs from wildcard expansion
975
- if (isManagedAssocInFlatMode(element) || (element['@Core.MediaType'] && !element['@Core.IsURL'])) return
1006
+ if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') return
976
1007
  // for wildcard on subquery in from, just reference the elements
977
1008
  if (tableAlias.SELECT && !element.elements && !element.target) {
978
1009
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -1398,15 +1429,13 @@ function cqn4sql(originalQuery, model) {
1398
1429
  }
1399
1430
  } else if (token.SELECT) {
1400
1431
  result = transformSubquery(token)
1401
- } else if (token.xpr) {
1402
- result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1403
- } else if (token.func && token.args) {
1404
- result.args = token.args.map(t => {
1405
- if (!t.val)
1406
- // this must not be touched
1407
- return getTransformedTokenStream([t], $baseLink)[0]
1408
- return t
1409
- })
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
+ }
1410
1439
  }
1411
1440
 
1412
1441
  transformedTokenStream.push(result)
@@ -1547,9 +1576,13 @@ function cqn4sql(originalQuery, model) {
1547
1576
  return { transformedFrom }
1548
1577
  } else if (from.SELECT) {
1549
1578
  transformedFrom = transformSubquery(from)
1550
- if (from.as)
1579
+ if (from.as) {
1551
1580
  // preserve explicit TA
1552
1581
  transformedFrom.as = from.as
1582
+ } else {
1583
+ // select from anonymous query, use artificial alias
1584
+ transformedFrom.as = Object.keys(originalQuery.sources)[0]
1585
+ }
1553
1586
  return { transformedFrom }
1554
1587
  } else {
1555
1588
  return _transformFrom()
@@ -2109,6 +2142,27 @@ function cqn4sql(originalQuery, model) {
2109
2142
  return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index)
2110
2143
  }
2111
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
+ }
2112
2166
  }
2113
2167
 
2114
2168
  module.exports = Object.assign(cqn4sql, {
@@ -2193,6 +2247,7 @@ function setElementOnColumns(col, element) {
2193
2247
  writable: true,
2194
2248
  })
2195
2249
  }
2250
+
2196
2251
  const getName = col => col.as || col.ref?.at(-1)
2197
2252
  const idOnly = ref => ref.id || ref
2198
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,17 +262,31 @@ 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
- queries.forEach(q => {
268
+ const insertQueries = new Map()
269
+
270
+ return queries.map(q => {
271
+ // Merge all INSERT statements for each target
272
+ if (q.INSERT) {
273
+ const target = q.target
274
+ if (insertQueries.has(target)) {
275
+ insertQueries.get(target).INSERT.entries.push(...q.INSERT.entries)
276
+ return
277
+ } else {
278
+ insertQueries.set(target, q)
279
+ }
280
+ }
246
281
  Object.defineProperty(q, handledDeep, { value: true })
282
+ return q
247
283
  })
248
- return queries
284
+ .filter(a => a)
249
285
  }
250
286
 
251
287
  module.exports = {
252
288
  onDeep,
253
289
  getDeepQueries,
254
290
  getExpandForDeep,
291
+ hasDeep,
255
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
@@ -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,9 +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]
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
+ )
773
+ }
730
774
  let elements = {}
731
775
  inline.forEach(inlineCol => {
732
- inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, inNestedProjection: true, baseColumn: col })
776
+ inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, baseColumn: col })
733
777
  if (inlineCol === '*') {
734
778
  const wildCardElements = {}
735
779
  // either the `.elements´ of the struct or the `.elements` of the assoc target
@@ -780,6 +824,11 @@ function infer(originalQuery, model) {
780
824
  function resolveExpand(col) {
781
825
  const { expand, $refLinks } = col
782
826
  const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
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
+ )
831
+ }
783
832
  const target = getDefinition($leafLink.definition.target)
784
833
  if (target) {
785
834
  const expandSubquery = {
@@ -795,19 +844,20 @@ function infer(originalQuery, model) {
795
844
  ? new cds.struct({ elements: inferredExpandSubquery.elements })
796
845
  : new cds.array({ items: new cds.struct({ elements: inferredExpandSubquery.elements }) })
797
846
  return Object.defineProperty(res, '$assocExpand', { value: true })
798
- } // struct
799
- let elements = {}
800
- expand.forEach(e => {
801
- if (e === '*') {
802
- elements = { ...elements, ...$leafLink.definition.elements }
803
- } else {
804
- inferQueryElement(e, false, $leafLink, { inExpr: true, inNestedProjection: true })
805
- if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
806
- if (e.inline) elements = { ...elements, ...resolveInline(e) }
807
- else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
808
- }
809
- })
810
- return new cds.struct({ elements })
847
+ } else if ($leafLink.definition.elements) {
848
+ let elements = {}
849
+ expand.forEach(e => {
850
+ if (e === '*') {
851
+ elements = { ...elements, ...$leafLink.definition.elements }
852
+ } else {
853
+ inferQueryElement(e, false, $leafLink, { inExpr: true })
854
+ if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
855
+ if (e.inline) elements = { ...elements, ...resolveInline(e) }
856
+ else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
857
+ }
858
+ })
859
+ return new cds.struct({ elements })
860
+ }
811
861
  }
812
862
 
813
863
  function stepNotFoundInPredecessor(step, def) {
@@ -843,7 +893,7 @@ function infer(originalQuery, model) {
843
893
  const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
844
894
  if (alreadySeenCalcElements.has(calcElement)) return
845
895
  else alreadySeenCalcElements.add(calcElement)
846
- const { ref, xpr, func } = calcElement.value
896
+ const { ref, xpr } = calcElement.value
847
897
  if (ref || xpr) {
848
898
  baseLink = baseLink || { definition: calcElement.parent, target: calcElement.parent }
849
899
  attachRefLinksToArg(calcElement.value, baseLink, true)
@@ -857,8 +907,9 @@ function infer(originalQuery, model) {
857
907
  }
858
908
  mergePathsIntoJoinTree(calcElement.value, basePath)
859
909
  }
860
- if (func)
861
- calcElement.value.args?.forEach(arg => {
910
+
911
+ if (calcElement.value.args) {
912
+ const processArgument = (arg, calcElement, column) => {
862
913
  inferQueryElement(
863
914
  arg,
864
915
  false,
@@ -870,7 +921,12 @@ function infer(originalQuery, model) {
870
921
  ? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
871
922
  : { $refLinks: [], ref: [] }
872
923
  mergePathsIntoJoinTree(arg, basePath)
873
- }) // {func}.args are optional
924
+ }
925
+
926
+ if (calcElement.value.args) {
927
+ applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column])
928
+ }
929
+ }
874
930
 
875
931
  /**
876
932
  * Calculates all paths from a given ref and merges them into the join tree.
@@ -1160,4 +1216,9 @@ function isForeignKeyOf(e, assoc) {
1160
1216
  }
1161
1217
  const idOnly = ref => ref.id || ref
1162
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
+
1163
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
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
  }