@cap-js/db-service 1.2.1 → 1.3.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,20 @@
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
+ ## Version 1.3.0 - 2023-10-06
8
+
9
+ ### Changed
10
+
11
+ - `INSERT.into(...).rows/values()` is not allowed anymore without specifying `.columns(...)`. #209
12
+ - Deep deletion uses correlated subqueries instead of materializing the to be deleted object before. #212
13
+
14
+ ### Fixed
15
+
16
+ - Various fixes for calculated elements on read. #220 #223 #233
17
+ - Don't release to pool connections twice. #243
18
+ - Syntax error in `matchesPattern` function. #237
19
+ - SELECTs with more than 50 columns does not return `null` values. #238 #261
20
+
7
21
  ## Version 1.2.1 - 2023-09-08
8
22
 
9
23
  ### Fixed
package/README.md CHANGED
@@ -2,4 +2,20 @@
2
2
 
3
3
  Welcome to the new base database service for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js, based on new, streamlined database architecture.
4
4
 
5
- Find documentation at https://cap.cloud.sap/docs/guides/databases.
5
+ Find documentation at https://cap.cloud.sap/docs/guides/databases
6
+
7
+ ## Support
8
+
9
+ This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/cds-dbs/issues).
10
+
11
+ ## Contribution
12
+
13
+ Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
14
+
15
+ ## Code of Conduct
16
+
17
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
18
+
19
+ ## Licensing
20
+
21
+ Copyright 2023 SAP SE or an SAP affiliate company and cds-dbs contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/cds-dbs).
package/lib/SQLService.js CHANGED
@@ -4,16 +4,12 @@ const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView
4
4
  const DatabaseService = require('./common/DatabaseService')
5
5
  const cqn4sql = require('./cqn4sql')
6
6
 
7
- /**
8
- * @callback next
9
- * @param {Error} param0
10
- * @returns {Promise<unknown>}
11
- */
7
+ /** @typedef {import('@sap/cds/apis/services').Request} Request */
12
8
 
13
9
  /**
14
10
  * @callback Handler
15
- * @param {import('@sap/cds/apis/services').Request} param0
16
- * @param {next} param1
11
+ * @param {Request} req
12
+ * @param {(err? : Error) => {Promise<unknown>}} next
17
13
  * @returns {Promise<unknown>}
18
14
  */
19
15
 
@@ -22,13 +18,14 @@ class SQLService extends DatabaseService {
22
18
  init() {
23
19
  this.on(['SELECT'], this.transformStreamFromCQN)
24
20
  this.on(['UPDATE'], this.transformStreamIntoCQN)
25
- this.on(['INSERT', 'UPSERT', 'UPDATE', 'DELETE'], require('./fill-in-keys')) // REVISIT should be replaced by correct input processing eventually
26
- this.on(['INSERT', 'UPSERT', 'UPDATE', 'DELETE'], require('./deep-queries').onDeep)
21
+ this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT should be replaced by correct input processing eventually
22
+ this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
27
23
  this.on(['SELECT'], this.onSELECT)
28
24
  this.on(['INSERT'], this.onINSERT)
29
25
  this.on(['UPSERT'], this.onUPSERT)
30
26
  this.on(['UPDATE'], this.onUPDATE)
31
- this.on(['DELETE', 'CREATE ENTITY', 'DROP ENTITY'], this.onSIMPLE)
27
+ this.on(['DELETE'], this.onDELETE)
28
+ this.on(['CREATE ENTITY', 'DROP ENTITY'], this.onSIMPLE)
32
29
  this.on(['BEGIN', 'COMMIT', 'ROLLBACK'], this.onEVENT)
33
30
  this.on(['STREAM'], this.onSTREAM)
34
31
  this.on(['*'], this.onPlainSQL)
@@ -152,6 +149,39 @@ class SQLService extends DatabaseService {
152
149
  return (await ps.run(values)).changes
153
150
  }
154
151
 
152
+ get onDELETE() {
153
+ return super.onDELETE = cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : deep_delete
154
+ async function deep_delete(/** @type {Request} */ req) {
155
+ let { compositions } = req.target
156
+ if (compositions) {
157
+ // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
158
+ let { from, where } = req.query.DELETE
159
+ if (typeof from === 'string') from = { ref: [from] }
160
+ if (where) {
161
+ let last = from.ref.at(-1)
162
+ if (last.where) [ last, where ] = [ last.id, [ { xpr: last.where }, 'and', { xpr: where } ] ]
163
+ from = {ref:[ ...from.ref.slice(0,-1), { id: last, where }]}
164
+ }
165
+ // Process child compositions depth-first
166
+ let { depth=0, visited=[] } = req
167
+ visited.push (req.target.name)
168
+ await Promise.all (Object.values(compositions).map(c => {
169
+ if (c._target['@cds.persistence.skip'] === true) return
170
+ if (c._target === req.target) { // the Genre.children case
171
+ if (++depth > (c['@depth'] || 3)) return
172
+ } else if (visited.includes(c._target.name)) throw new Error(
173
+ `Transitive circular composition detected: \n\n`+
174
+ ` ${visited.join(' > ')} > ${c._target.name} \n\n`+
175
+ `These are not supported by deep delete.`)
176
+ // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
177
+ const query = DELETE.from({ref:[ ...from.ref, c.name ]})
178
+ return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
179
+ }))
180
+ }
181
+ return this.onSIMPLE(req)
182
+ }
183
+ }
184
+
155
185
  /**
156
186
  * Handler for BEGIN, COMMIT, ROLLBACK, which don't have any CQN
157
187
  * @type {Handler}
@@ -167,11 +197,6 @@ class SQLService extends DatabaseService {
167
197
  */
168
198
  async onPlainSQL({ query, data }, next) {
169
199
  if (typeof query === 'string') {
170
- // REVISIT: this is a hack the target of $now might not be a timestamp or date time
171
- // Add input converter to CURRENT_TIMESTAMP inside views using $now
172
- if(/^CREATE VIEW.* CURRENT_TIMESTAMP[( ]/is.test(query)) {
173
- query = query.replace(/CURRENT_TIMESTAMP/gi, 'ISO(CURRENT_TIMESTAMP)')
174
- }
175
200
  DEBUG?.(query, data)
176
201
  const ps = await this.prepare(query)
177
202
  const exec = this.hasResults(query) ? d => ps.all(d) : d => ps.run(d)
@@ -81,12 +81,11 @@ class DatabaseService extends cds.Service {
81
81
  */
82
82
  async rollback() {
83
83
  if (!this.dbc) return
84
- else
85
- try {
86
- await this.send('ROLLBACK')
87
- } finally {
88
- this.release()
89
- }
84
+ try {
85
+ await this.send('ROLLBACK')
86
+ } finally {
87
+ this.release()
88
+ }
90
89
  }
91
90
 
92
91
  /**
@@ -102,7 +101,9 @@ class DatabaseService extends cds.Service {
102
101
  * This is for subclasses to intercept, if required.
103
102
  */
104
103
  async release() {
105
- return this.pool.release(this.dbc)
104
+ if (!this.dbc) return
105
+ await this.pool.release(this.dbc)
106
+ this.dbc = undefined
106
107
  }
107
108
 
108
109
  // REVISIT: should happen automatically after a configurable time
@@ -98,7 +98,7 @@ const StandardFunctions = {
98
98
  * @param {string} y
99
99
  * @returns {string}
100
100
  */
101
- matchesPattern: (x, y) => `${x} regexp ${y})`,
101
+ matchesPattern: (x, y) => `(${x} regexp ${y})`,
102
102
  /**
103
103
  * Generates SQL statement that produces the lower case value of a given string
104
104
  * @param {string} x
@@ -341,7 +341,7 @@ const HANAFunctions = {
341
341
  */
342
342
  years_between(x, y) {
343
343
  return `floor(${this.months_between(x, y)} / 12)`
344
- },
344
+ }
345
345
  }
346
346
 
347
347
  for (let each in HANAFunctions) HANAFunctions[each.toUpperCase()] = HANAFunctions[each]
package/lib/cqn2sql.js CHANGED
@@ -233,6 +233,7 @@ class CQN2SQLRenderer {
233
233
  SELECT_expand({ SELECT, elements }, sql) {
234
234
  if (!SELECT.columns) return sql
235
235
  if (!elements) return sql // REVISIT: Above we say this is an error condition, but here we say it's ok?
236
+
236
237
  let cols = SELECT.columns.map(x => {
237
238
  const name = this.column_name(x)
238
239
  let col = `'${name}',${this.output_converter4(x.element, this.quote(name))}`
@@ -246,11 +247,19 @@ class CQN2SQLRenderer {
246
247
 
247
248
  // Prevent SQLite from hitting function argument limit of 100
248
249
  let obj = ''
249
- for (let i = 0; i < cols.length; i += 50) {
250
- const n = `json_object(${cols.slice(i, i + 50)})`
251
- obj = obj ? `json_patch(${obj},${n})` : n
252
- }
253
- return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj})`} as _json_ FROM (${sql})`
250
+
251
+ if(cols.length < 50) obj = `json_object(${cols.slice(0, 50)})`
252
+ else {
253
+ const chunks = []
254
+ for (let i = 0; i < cols.length; i += 50) {
255
+ chunks.push(`json_object(${cols.slice(i, i + 50)})`)
256
+ }
257
+ // REVISIT: json_merge is a user defined function, bad performance!
258
+ obj = `json_merge(${chunks})`
259
+ }
260
+
261
+
262
+ return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj.includes('json_merge') ? `json_insert(${obj})` : obj})`} as _json_ FROM (${sql})`
254
263
  }
255
264
 
256
265
  /**
@@ -432,20 +441,18 @@ class CQN2SQLRenderer {
432
441
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
433
442
  const alias = INSERT.into.as
434
443
  const elements = q.elements || q.target?.elements
435
- if (!INSERT.columns && !elements) {
436
- throw cds.error`Cannot insert rows without columns or elements`
437
- }
438
- let columns = INSERT.columns || (elements && ObjectKeys(elements).filter(c => !elements[c].virtual && !elements[c].isAssociation))
439
- this.columns = columns.map(c => this.quote(c))
444
+ const columns = INSERT.columns
445
+ || cds.error`Cannot insert rows without columns or elements`
440
446
 
441
- const inputConverterKey = this.class._convertInput
447
+ const inputConverter = this.class._convertInput
442
448
  const extraction = columns.map((c,i) => {
443
- const element = elements?.[c] || {}
444
449
  const extract = `value->>'$[${i}]'`
445
- const converter = element[inputConverterKey] || (e => e)
446
- return converter(extract, element)
450
+ const element = elements?.[c]
451
+ const converter = element?.[inputConverter]
452
+ return converter?.(extract,element) || extract
447
453
  })
448
454
 
455
+ this.columns = columns.map(c => this.quote(c))
449
456
  this.entries = [[JSON.stringify(INSERT.rows)]]
450
457
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
451
458
  this.columns
package/lib/cqn4sql.js CHANGED
@@ -310,8 +310,11 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
310
310
  const col = columns[i]
311
311
 
312
312
  if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
313
- const calcElement = resolveCalculatedElement(col)
314
- transformedColumns.push(calcElement)
313
+ const name = getName(col)
314
+ if (!transformedColumns.some(inserted => getName(inserted) === name)) {
315
+ const calcElement = resolveCalculatedElement(col)
316
+ transformedColumns.push(calcElement)
317
+ }
315
318
  } else if (col.expand) {
316
319
  if (col.ref?.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
317
320
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -433,7 +436,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
433
436
 
434
437
  if (col.$refLinks.some(link => link.definition._target?.['@cds.persistence.skip'] === true)) return
435
438
 
436
- const getName = col => col.as || col.ref?.at(-1)
437
439
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
438
440
  flatColumns.forEach(flatColumn => {
439
441
  const name = getName(flatColumn)
@@ -480,7 +482,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
480
482
 
481
483
  function handleEmptyColumns(columns) {
482
484
  if (columns.some(c => c.$refLinks?.[c.$refLinks.length - 1].definition.type === 'cds.Composition')) return
483
- throw new cds.error('Queries must have at least one non-virtual column')
485
+ throw new Error('Queries must have at least one non-virtual column')
484
486
  }
485
487
  }
486
488
 
@@ -509,7 +511,9 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
509
511
  res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
510
512
  } else if (val) {
511
513
  res = { val }
512
- } else if (func) res = { args: getTransformedTokenStream(value.args, baseLink), func: value.func }
514
+ } else if (func) {
515
+ res = { args: getTransformedTokenStream(value.args, baseLink), func: value.func }
516
+ }
513
517
  if (!omitAlias) res.as = column.as || column.name || column.flatName
514
518
  return res
515
519
  }
@@ -664,7 +668,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
664
668
  // everything after the wildcard, is a potential replacement
665
669
  // in the wildcard expansion
666
670
  const replace = []
667
- // we need to absolutefy the refs
671
+ // we need to make the refs absolute
668
672
  col[prop].slice(wildcardIndex + 1).forEach(c => {
669
673
  const fakeColumn = { ...c }
670
674
  if (fakeColumn.ref) {
@@ -852,7 +856,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
852
856
  * the result.
853
857
  */
854
858
  if (inOrderBy && flatColumns.length > 1)
855
- cds.error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
859
+ throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
856
860
  if (col.nulls) flatColumns[0].nulls = col.nulls
857
861
  if (col.sort) flatColumns[0].sort = col.sort
858
862
  res.push(...flatColumns)
@@ -910,21 +914,23 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
910
914
  */
911
915
  function getColumnsForWildcard(exclude = [], replace = []) {
912
916
  const wildcardColumns = []
913
- Object.keys(inferred.$combinedElements).forEach(k => {
914
- const { index, tableAlias } = inferred.$combinedElements[k][0]
915
- const element = tableAlias.elements[k]
916
- // ignore FK for odata csn / ignore blobs from wildcard expansion
917
- if (isManagedAssocInFlatMode(element) || (element['@Core.MediaType'] && !element['@Core.IsURL'])) return
918
- // for wildcard on subquery in from, just reference the elements
919
- if (tableAlias.SELECT && !element.elements && !element.target) {
920
- wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
921
- } else if (isCalculatedOnRead(element)) {
922
- wildcardColumns.push(resolveCalculatedElement(element))
923
- } else {
924
- const flatColumns = getFlatColumnsFor(element, { tableAlias: index }, [], { exclude, replace }, true)
925
- wildcardColumns.push(...flatColumns)
926
- }
927
- })
917
+ Object.keys(inferred.$combinedElements)
918
+ .filter(k => !exclude.includes(k))
919
+ .forEach(k => {
920
+ const { index, tableAlias } = inferred.$combinedElements[k][0]
921
+ const element = tableAlias.elements[k]
922
+ // ignore FK for odata csn / ignore blobs from wildcard expansion
923
+ if (isManagedAssocInFlatMode(element) || (element['@Core.MediaType'] && !element['@Core.IsURL'])) return
924
+ // for wildcard on subquery in from, just reference the elements
925
+ if (tableAlias.SELECT && !element.elements && !element.target) {
926
+ wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
927
+ } else if (isCalculatedOnRead(element)) {
928
+ wildcardColumns.push(resolveCalculatedElement(replace.find(r => r.as === k) || element))
929
+ } else {
930
+ const flatColumns = getFlatColumnsFor(element, { tableAlias: index }, [], { exclude, replace }, true)
931
+ wildcardColumns.push(...flatColumns)
932
+ }
933
+ })
928
934
  return wildcardColumns
929
935
 
930
936
  /**
@@ -1143,7 +1149,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1143
1149
  const { ref, $refLinks } = tokenStream[i + 1]
1144
1150
  if (!ref) continue
1145
1151
  if (ref[0] in { $self: true, $projection: true })
1146
- cds.error(`Unexpected "${ref[0]}" following "exists", remove it or add a table alias instead`)
1152
+ throw new Error(`Unexpected "${ref[0]}" following "exists", remove it or add a table alias instead`)
1147
1153
  const firstStepIsTableAlias = ref.length > 1 && ref[0] in inferred.sources
1148
1154
  for (let j = 0; j < ref.length; j += 1) {
1149
1155
  let current, next
@@ -1176,6 +1182,22 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1176
1182
 
1177
1183
  const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
1178
1184
  next.alias = as
1185
+ if (next.definition.value) {
1186
+ throw new Error(
1187
+ `Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
1188
+ .map(idOnly)
1189
+ .join('.')}”`,
1190
+ )
1191
+ }
1192
+ if (!next.definition.target) {
1193
+ throw new Error(
1194
+ `Expecting path “${tokenStream[i + 1].ref
1195
+ .map(idOnly)
1196
+ .join('.')}” following “EXISTS” predicate to end with association/composition, found “${
1197
+ next.definition.type
1198
+ }”`,
1199
+ )
1200
+ }
1179
1201
  whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true))
1180
1202
  }
1181
1203
 
@@ -1244,7 +1266,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1244
1266
  rhs === 'null'
1245
1267
  ) {
1246
1268
  if (notSupportedOps.some(([firstOp]) => firstOp === next))
1247
- cds.error(`The operator "${next}" is not supported for structure comparison`)
1269
+ throw new Error(`The operator "${next}" is not supported for structure comparison`)
1248
1270
  const newTokens = expandComparison(token, ops, rhs, $baseLink)
1249
1271
  const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1250
1272
  transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
@@ -1269,8 +1291,15 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1269
1291
  transformedTokenStream.push(...getTransformedTokenStream(dollarSelfReplacement))
1270
1292
  continue
1271
1293
  }
1272
- const tableAlias = getQuerySourceName(token, $baseLink)
1273
- if (!$baseLink && token.isJoinRelevant) {
1294
+ // if we have e.g. a calculated element like `books.authorLastName,`
1295
+ // we have effectively a ref ['books', 'author', 'lastName']
1296
+ // in that case, we have a baseLink `books` which we need to resolve the following steps
1297
+ // however, the correct table alias has been assigned to the `author` step
1298
+ // hence we need to ignore the alias of the `$baseLink`
1299
+ const refHasOwnAssoc =
1300
+ token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1301
+ const tableAlias = getQuerySourceName(token, refHasOwnAssoc || $baseLink)
1302
+ if ((!$baseLink || refHasOwnAssoc) && token.isJoinRelevant) {
1274
1303
  result.ref = [tableAlias, getFullName(token.$refLinks[token.$refLinks.length - 1].definition)]
1275
1304
  } else if (tableAlias) {
1276
1305
  result.ref = [tableAlias, token.flatName]
@@ -1327,7 +1356,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1327
1356
  // --> calculate error message or exit early? See test "proper error if structures cannot be compared / too many elements on lhs"
1328
1357
  if (flatRhs.length !== flatLhs.length)
1329
1358
  // make sure we can compare both structures
1330
- cds.error(
1359
+ throw new Error(
1331
1360
  `Can't compare "${definition.name}" with "${
1332
1361
  value.$refLinks[value.$refLinks.length - 1].definition.name
1333
1362
  }": the operands must have the same structure`,
@@ -1355,13 +1384,13 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1355
1384
  // if we still have elements in flatRhs -> those were not found in lhs
1356
1385
  const lhsPath = token.ref.join('.') // original path of the comparison, used in error message
1357
1386
  flatRhs.forEach(t => pathNotFoundErr.push(`Path "${t._csnPath.slice(1).join('.')}" not found in "${lhsPath}"`))
1358
- cds.error(`Can't compare "${lhsPath}" with "${rhsPath}": ${pathNotFoundErr.join(', ')}`)
1387
+ throw new Error(`Can't compare "${lhsPath}" with "${rhsPath}": ${pathNotFoundErr.join(', ')}`)
1359
1388
  }
1360
1389
  } else {
1361
1390
  // compare with value
1362
1391
  const flatLhs = flattenWithBaseName(token)
1363
1392
  if (flatLhs.length > 1 && value.val !== null && value !== 'null')
1364
- cds.error(`Can't compare structure "${token.ref.join('.')}" with value "${value.val}"`)
1393
+ throw new Error(`Can't compare structure "${token.ref.join('.')}" with value "${value.val}"`)
1365
1394
  const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1366
1395
  flatLhs.forEach((column, i) => {
1367
1396
  result.push(column, ...operator, value)
@@ -1397,10 +1426,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1397
1426
  rejectStructInExpression()
1398
1427
 
1399
1428
  function rejectAssocInExpression() {
1400
- throw new Error(/An association can't be used as a value in an expression/)
1429
+ throw new Error("An association can't be used as a value in an expression")
1401
1430
  }
1402
1431
  function rejectStructInExpression() {
1403
- throw new Error(/A structured element can't be used as a value in an expression/)
1432
+ throw new Error("A structured element can't be used as a value in an expression")
1404
1433
  }
1405
1434
  }
1406
1435
 
@@ -1946,7 +1975,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1946
1975
  if ($baseLink) {
1947
1976
  return getBaseLinkAlias($baseLink)
1948
1977
  }
1949
-
1950
1978
  if (node.isJoinRelevant) {
1951
1979
  return getJoinRelevantAlias(node)
1952
1980
  }
@@ -2048,6 +2076,6 @@ function setElementOnColumns(col, element) {
2048
2076
  writable: true,
2049
2077
  })
2050
2078
  }
2051
-
2079
+ const getName = col => col.as || col.ref?.at(-1)
2052
2080
  const idOnly = ref => ref.id || ref
2053
2081
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
@@ -17,38 +17,29 @@ const handledDeep = Symbol('handledDeep')
17
17
  */
18
18
  async function onDeep(req, next) {
19
19
  const { query } = req
20
+ if (handledDeep in query) return next()
21
+
20
22
  // REVISIT: req.target does not match the query.INSERT target for path insert
21
23
  // const target = query.sources[Object.keys(query.sources)[0]]
22
- if (!this.model?.definitions[_target_name4(req.query)]) {
23
- return next()
24
- }
24
+ if (!this.model?.definitions[_target_name4(req.query)]) return next()
25
+
25
26
  const { target } = this.infer(query)
26
27
  if (!hasDeep(query, target)) return next()
27
- const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true))
28
28
 
29
- if (query.UPDATE && !beforeData.length) {
30
- return 0
31
- }
29
+ const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true))
30
+ if (query.UPDATE && !beforeData.length) return 0
32
31
 
33
32
  const queries = getDeepQueries(query, beforeData, target)
34
- const res = await Promise.all(
35
- queries.map(query => {
36
- if (query.INSERT) return this.onINSERT({ query })
37
- if (query.UPDATE) return this.onUPDATE({ query })
38
- if (query.DELETE) return this.onSIMPLE({ query })
39
- }),
40
- )
33
+ const res = await Promise.all(queries.map(query => {
34
+ if (query.INSERT) return this.onINSERT({ query })
35
+ if (query.UPDATE) return this.onUPDATE({ query })
36
+ if (query.DELETE) return this.onSIMPLE({ query })
37
+ }))
41
38
  return res[0] ?? 0 // TODO what todo with multiple result responses?
42
39
  }
43
40
 
44
- const hasDeep = (query, target) => {
45
- if (handledDeep in query) return
46
- if (query.DELETE) {
47
- for (let c in target?.compositions) return true
48
- return false
49
- }
50
- const data =
51
- query.INSERT?.entries || (query.UPDATE?.data && [query.UPDATE.data]) || (query.UPDATE?.with && [query.UPDATE.with])
41
+ const hasDeep = (q, target) => {
42
+ const data = q.INSERT?.entries || (q.UPDATE?.data && [q.UPDATE.data]) || (q.UPDATE?.with && [q.UPDATE.with])
52
43
  if (data)
53
44
  for (const c in target.compositions) {
54
45
  for (const row of data) if (row[c] !== undefined) return true
@@ -110,7 +101,7 @@ const _calculateExpandColumns = (target, data, expandColumns = [], elementMap =
110
101
  const seen = elementMap.get(fqn)
111
102
  if (seen && seen >= DEEP_DELETE_MAX_RECURSION_DEPTH) {
112
103
  // recursion -> abort
113
- return
104
+ return expandColumns
114
105
  }
115
106
 
116
107
  let expandColumn = expandColumns.find(expandColumn => expandColumn.ref[0] === composition.name)
@@ -145,6 +136,7 @@ const _calculateExpandColumns = (target, data, expandColumns = [], elementMap =
145
136
  _calculateExpandColumns(composition._target, compositionData, expandColumn.expand, newElementMap)
146
137
  }
147
138
  }
139
+ return expandColumns
148
140
  }
149
141
 
150
142
  /**
@@ -152,18 +144,9 @@ const _calculateExpandColumns = (target, data, expandColumns = [], elementMap =
152
144
  * @param {import('@sap/cds/apis/csn').Definition} target
153
145
  */
154
146
  const getExpandForDeep = (query, target) => {
155
- const from = query.DELETE?.from || query.UPDATE?.entity
156
- const data = query.UPDATE?.data || null
157
- const where = query.DELETE?.where || query.UPDATE?.where
158
-
159
- /** @type {import("@sap/cds/apis/ql").SELECT<unknown>} */
160
- const cqn = SELECT.from(from)
161
- if (where) cqn.SELECT.where = where
162
-
163
- const columns = []
164
- _calculateExpandColumns(target, data, columns)
165
- cqn.columns(columns)
166
- return cqn
147
+ const { entity, data = null, where } = query.UPDATE
148
+ const columns = _calculateExpandColumns(target, data)
149
+ return SELECT(columns).from(entity).where(where)
167
150
  }
168
151
 
169
152
  /**
@@ -62,7 +62,7 @@ module.exports = async function fill_in_keys(req, next) {
62
62
  }
63
63
 
64
64
  // REVISIT no input processing for INPUT with rows/values
65
- if (req.event !== 'DELETE' && !(req.query.INSERT?.rows || req.query.INSERT?.values)) {
65
+ if (!(req.query.INSERT?.rows || req.query.INSERT?.values)) {
66
66
  if (Array.isArray(req.data)) {
67
67
  for (const d of req.data) {
68
68
  generateUUIDandPropagateKeys(req.target, d, req.event)
@@ -23,12 +23,12 @@ for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
23
23
  * @returns {import('./cqn').Query} = q with .target and .elements
24
24
  */
25
25
  function infer(originalQuery, model = cds.context?.model || cds.model) {
26
- if (!model) cds.error('Please specify a model')
26
+ if (!model) throw new Error('Please specify a model')
27
27
  const inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
28
28
 
29
29
  // REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
30
30
  // e.g. there's a lot of overhead for infer( SELECT.from(Books) )
31
- if (originalQuery.SET) cds.error('”UNION” based queries are not supported')
31
+ if (originalQuery.SET) throw new Error('”UNION” based queries are not supported')
32
32
  const _ =
33
33
  inferred.SELECT ||
34
34
  inferred.INSERT ||
@@ -97,16 +97,16 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
97
97
  if (ref) {
98
98
  const first = ref[0].id || ref[0]
99
99
  let target = getDefinition(first, model)
100
- if (!target) cds.error(`"${first}" not found in the definitions of your model`)
100
+ if (!target) throw new Error(`"${first}" not found in the definitions of your model`)
101
101
  if (ref.length > 1) {
102
102
  target = from.ref.slice(1).reduce((d, r) => {
103
103
  const next = d.elements[r.id || r]?.elements ? d.elements[r.id || r] : d.elements[r.id || r]?._target
104
- if (!next) cds.error(`No association "${r.id || r}" in ${d.kind} "${d.name}": ${d}`)
104
+ if (!next) throw new Error(`No association “${r.id || r} in ${d.kind} “${d.name}”`)
105
105
  return next
106
106
  }, target)
107
107
  }
108
108
  if (target.kind !== 'entity' && !target._isAssociation)
109
- throw new Error(/Query source must be a an entity or an association/)
109
+ throw new Error('Query source must be a an entity or an association')
110
110
 
111
111
  attachRefLinksToArg(from) // REVISIT: remove
112
112
  const alias =
@@ -154,8 +154,9 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
154
154
  * @returns {void} This function does not return a value; it mutates the 'arg' object directly.
155
155
  */
156
156
  function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
157
- const { ref, xpr } = arg
157
+ const { ref, xpr, args } = arg
158
158
  if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
159
+ if (args) args.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
159
160
  if (!ref) return
160
161
  init$refLinks(arg)
161
162
  ref.forEach((step, i) => {
@@ -172,9 +173,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
172
173
  const nextStep = ref[1]?.id || ref[1]
173
174
  // no unmanaged assoc in infix filter path
174
175
  if (!expandOrExists && e.on)
175
- throw new Error(
176
- `"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
177
- )
176
+ throw new Error(`"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`)
178
177
  // no non-fk traversal in infix filter
179
178
  if (!expandOrExists && nextStep && !(nextStep in e.foreignKeys))
180
179
  throw new Error(`Only foreign keys of "${e.name}" can be accessed in infix filter`)
@@ -288,8 +287,8 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
288
287
  wildcardSelect = true
289
288
  } else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
290
289
  const as = col.as || col.func || col.val
291
- if (as === undefined) throw cds.error`Expecting expression to have an alias name`
292
- if (queryElements[as]) throw cds.error`Duplicate definition of element “${as}”`
290
+ if (as === undefined) cds.error`Expecting expression to have an alias name`
291
+ if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
293
292
  if (col.xpr || col.SELECT) {
294
293
  queryElements[as] = getElementForXprOrSubquery(col)
295
294
  } else if (col.func) {
@@ -313,7 +312,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
313
312
  } else if (col.expand) {
314
313
  inferQueryElement(col)
315
314
  } else {
316
- throw cds.error`Not supported: ${JSON.stringify(col)}`
315
+ cds.error`Not supported: ${JSON.stringify(col)}`
317
316
  }
318
317
  })
319
318
 
@@ -338,34 +337,43 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
338
337
  inferQueryElement(token, false, $baseLink)
339
338
  })
340
339
  }
341
- if (where) {
340
+
341
+ // walk over all paths in other query properties
342
+ if (where)
343
+ walkTokenStream(where)
344
+ if (groupBy)
345
+ groupBy.forEach(token => inferQueryElement(token, false))
346
+ if (having)
347
+ walkTokenStream(having)
348
+ if (_.with) // consider UPDATE.with
349
+ Object.values(_.with).forEach(val => inferQueryElement(val, false))
350
+
351
+ return queryElements
352
+
353
+ /**
354
+ * Recursively drill down into a tokenStream (`where` or `having`) and pass
355
+ * on the information whether the next token is resolved within an `exists` predicates.
356
+ * If such a token has an infix filter, it is not join relevant, because the filter
357
+ * condition is applied to the generated `exists <subquery>` condition.
358
+ *
359
+ * @param {array} tokenStream
360
+ */
361
+ function walkTokenStream(tokenStream) {
342
362
  let skipJoins
343
- const walkTokenStream = token => {
344
- if (token === 'exists') {
363
+ const processToken = t => {
364
+ if (t === 'exists') {
345
365
  // no joins for infix filters along `exists <path>`
346
366
  skipJoins = true
347
- } else if (token.xpr) {
367
+ } else if (t.xpr) {
348
368
  // don't miss an exists within an expression
349
- token.xpr.forEach(walkTokenStream)
369
+ t.xpr.forEach(processToken)
350
370
  } else {
351
- inferQueryElement(token, false, null, { inExists: skipJoins, inExpr: true })
371
+ inferQueryElement(t, false, null, { inExists: skipJoins, inExpr: true })
352
372
  skipJoins = false
353
373
  }
354
374
  }
355
- where.forEach(walkTokenStream)
375
+ tokenStream.forEach(processToken)
356
376
  }
357
- if (groupBy)
358
- // link $refLinks
359
- groupBy.forEach(token => inferQueryElement(token, false))
360
- if (having)
361
- // link $refLinks
362
- having.forEach(token => inferQueryElement(token, false))
363
- if (_.with)
364
- // consider UPDATE.with
365
- Object.values(_.with).forEach(val => inferQueryElement(val, false))
366
-
367
- return queryElements
368
-
369
377
  /**
370
378
  * Processes references starting with `$self`, which are intended to target other query elements.
371
379
  * These `$self` paths must be handled after processing the "regular" columns since they are dependent on other query elements.
@@ -504,7 +512,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
504
512
  .join('.')}" must not be an unmanaged association`,
505
513
  )
506
514
  // no non-fk traversal in infix filter
507
- if (nextStep && !(nextStep in element.foreignKeys))
515
+ if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
508
516
  throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
509
517
  }
510
518
  const resolvableIn = definition.target ? definition._target : target
@@ -536,10 +544,10 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
536
544
  const element = elements?.[id]
537
545
 
538
546
  if (firstStepIsSelf && element?.isAssociation) {
539
- throw cds.error(
547
+ throw new Error(
540
548
  `Paths starting with “$self” must not contain steps of type “cds.Association”: ref: [ ${column.ref.map(
541
549
  idOnly,
542
- )} ]`,
550
+ ).join(', ')} ]`,
543
551
  )
544
552
  }
545
553
 
@@ -579,7 +587,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
579
587
  if (step.where) {
580
588
  const danglingFilter = !(column.ref[i + 1] || column.expand || column.inline || inExists)
581
589
  if (!column.$refLinks[i].definition.target || danglingFilter)
582
- throw new Error(/A filter can only be provided when navigating along associations/)
590
+ throw new Error('A filter can only be provided when navigating along associations')
583
591
  if (!column.expand) Object.defineProperty(column, 'isJoinRelevant', { value: true })
584
592
  // books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
585
593
  let skipJoinsForFilter = inExists
@@ -664,7 +672,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
664
672
  : column
665
673
  if (isColumnJoinRelevant(colWithBase)) {
666
674
  if (originalQuery.UPDATE)
667
- throw cds.error(
675
+ throw new Error(
668
676
  'Path expressions for UPDATE statements are not supported. Use “where exists” with infix filters instead.',
669
677
  )
670
678
  Object.defineProperty(column, 'isJoinRelevant', { value: true })
@@ -672,7 +680,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
672
680
  }
673
681
  }
674
682
  if (leafArt.value && !leafArt.value.stored) {
675
- resolveCalculatedElement(column, $baseLink, baseColumn)
683
+ linkCalculatedElement(column, $baseLink, baseColumn)
676
684
  }
677
685
 
678
686
  /**
@@ -752,6 +760,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
752
760
  columns: expand.filter(c => !c.inline),
753
761
  },
754
762
  }
763
+ if (col.excluding) expandSubquery.SELECT.excluding = col.excluding
755
764
  if (col.as) expandSubquery.SELECT.as = col.as
756
765
  const inferredExpandSubquery = infer(expandSubquery, model)
757
766
  const res = $leafLink.definition.is2one
@@ -798,10 +807,10 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
798
807
  // if the `elt` from a `$self.elt` path is found in the `$combinedElements` -> hint to remove `$self`
799
808
  if (step in $combinedElements)
800
809
  err.push(` did you mean ${$combinedElements[step].map(ta => `"${ta.index || ta.as}.${step}"`).join(',')}?`)
801
- throw new Error(err)
810
+ throw new Error(err.join(','))
802
811
  }
803
812
  }
804
- function resolveCalculatedElement(column, baseLink, baseColumn) {
813
+ function linkCalculatedElement(column, baseLink, baseColumn) {
805
814
  const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
806
815
  if (alreadySeenCalcElements.has(calcElement)) return
807
816
  else alreadySeenCalcElements.add(calcElement)
@@ -809,35 +818,54 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
809
818
  if (ref || xpr) {
810
819
  baseLink = baseLink || { definition: calcElement.parent, target: calcElement.parent }
811
820
  attachRefLinksToArg(calcElement.value, baseLink, true)
812
- const basePath = { $refLinks: [], ref: [] }
821
+ const basePath =
822
+ column.$refLinks?.length > 1
823
+ ? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
824
+ : { $refLinks: [], ref: [] }
813
825
  if (baseColumn) {
814
826
  basePath.$refLinks.push(...baseColumn.$refLinks)
815
827
  basePath.ref.push(...baseColumn.ref)
816
828
  }
817
- // column is now fully linked, now we need to find out if we need to merge it into the join tree
818
- // for that, we calculate all paths from a calc element and merge them into the join tree
819
829
  mergePathsIntoJoinTree(calcElement.value, basePath)
820
830
  }
821
831
  if (func)
822
- calcElement.value.args?.forEach(arg =>
823
- inferQueryElement(arg, false, { definition: calcElement.parent, target: calcElement.parent }),
824
- ) // {func}.args are optional
825
- function mergePathsIntoJoinTree(e, basePath = null) {
832
+ calcElement.value.args?.forEach(arg => {
833
+ inferQueryElement(
834
+ arg,
835
+ false,
836
+ { definition: calcElement.parent, target: calcElement.parent },
837
+ { inCalcElement: true },
838
+ )
839
+ const basePath =
840
+ column.$refLinks?.length > 1
841
+ ? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
842
+ : { $refLinks: [], ref: [] }
843
+ mergePathsIntoJoinTree(arg, basePath)
844
+ }) // {func}.args are optional
845
+
846
+ /**
847
+ * Calculates all paths from a given ref and merges them into the join tree.
848
+ * Recursively walks into refs of calculated elements.
849
+ *
850
+ * @param {object} arg with a ref and sibling $refLinks
851
+ * @param {object} basePath with a ref and sibling $refLinks, used for recursion
852
+ */
853
+ function mergePathsIntoJoinTree(arg, basePath = null) {
826
854
  basePath = basePath || { $refLinks: [], ref: [] }
827
- if (e.ref) {
828
- e.$refLinks.forEach((link, i) => {
855
+ if (arg.ref) {
856
+ arg.$refLinks.forEach((link, i) => {
829
857
  const { definition } = link
830
858
  if (!definition.value) {
831
859
  basePath.$refLinks.push(link)
832
- basePath.ref.push(e.ref[i])
860
+ basePath.ref.push(arg.ref[i])
833
861
  }
834
862
  })
835
- const leafOfCalculatedElementRef = e.$refLinks[e.$refLinks.length - 1].definition
863
+ const leafOfCalculatedElementRef = arg.$refLinks[arg.$refLinks.length - 1].definition
836
864
  if (leafOfCalculatedElementRef.value) mergePathsIntoJoinTree(leafOfCalculatedElementRef.value, basePath)
837
865
 
838
- mergePathIfNecessary(basePath, e)
839
- } else if (e.xpr) {
840
- e.xpr.forEach(step => {
866
+ mergePathIfNecessary(basePath, arg)
867
+ } else if (arg.xpr) {
868
+ arg.xpr.forEach(step => {
841
869
  if (step.ref) {
842
870
  const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
843
871
  step.$refLinks.forEach((link, i) => {
@@ -923,14 +951,17 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
923
951
  const exclude = _.excluding ? x => _.excluding.includes(x) : () => false
924
952
 
925
953
  if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
954
+ const { elements } = sources[aliases[0]]
926
955
  // only one query source and no overwritten columns
927
- Object.entries(sources[aliases[0]].elements).forEach(([name, element]) => {
928
- if (!exclude(name) && element.type !== 'cds.LargeBinary') queryElements[name] = element
929
- if (element.value) {
930
- // we might have join relevant calculated elements
931
- resolveCalculatedElement(element)
932
- }
933
- })
956
+ Object.keys(elements)
957
+ .filter(k => !exclude(k))
958
+ .forEach(k => {
959
+ const element = sources[aliases[0]].elements[k]
960
+ if (element.type !== 'cds.LargeBinary') queryElements[k] = element
961
+ if (element.value) {
962
+ linkCalculatedElement(element)
963
+ }
964
+ })
934
965
  return
935
966
  }
936
967
 
@@ -943,6 +974,9 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
943
974
  if (exclude(name) || name in queryElements) return true
944
975
  const element = tableAliases[0].tableAlias.elements[name]
945
976
  if (element.type !== 'cds.LargeBinary') queryElements[name] = element
977
+ if (element.value) {
978
+ linkCalculatedElement(element)
979
+ }
946
980
  })
947
981
 
948
982
  if (Object.keys(ambiguousElements).length > 0) throwAmbiguousWildcardError()
@@ -181,7 +181,10 @@ class JoinTree {
181
181
  if (next) {
182
182
  // step already seen before
183
183
  node = next
184
- col.$refLinks[i] = node.$refLink // re-set $refLink to point to already merged $refLink
184
+ // re-set $refLink to equal the one which got already merged
185
+ col.$refLinks[i].alias = node.$refLink.alias
186
+ col.$refLinks[i].definition = node.$refLink.definition
187
+ col.$refLinks[i].target = node.$refLink.target
185
188
  } else {
186
189
  if (col.expand && !col.ref[i + 1]) {
187
190
  node.$refLink.onlyForeignKeyAccess = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.2.1",
3
+ "version": "1.3.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": {