@cap-js/db-service 1.12.0 → 1.13.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,19 +4,44 @@
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.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
8
+
9
+
10
+ ### Added
11
+
12
+ * Add quoted mode support ([#681](https://github.com/cap-js/cds-dbs/issues/681)) ([43c7a6c](https://github.com/cap-js/cds-dbs/commit/43c7a6c1bed836a1210eb9c2ff5c7ffc0e498d76))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * **deep-queries:** properly return insert result ([#803](https://github.com/cap-js/cds-dbs/issues/803)) ([8d800e2](https://github.com/cap-js/cds-dbs/commit/8d800e2858f02663b8ed3845909e5891af390517))
18
+ * dont use virtual key for `UPDATE … where (&lt;key&gt;) in <subquery>` ([#800](https://github.com/cap-js/cds-dbs/issues/800)) ([d25af70](https://github.com/cap-js/cds-dbs/commit/d25af70b23688cd22c7b87f20a7848c9653ae9a9))
19
+ * reject all path expressions w/o foreign keys ([#806](https://github.com/cap-js/cds-dbs/issues/806)) ([cd271a8](https://github.com/cap-js/cds-dbs/commit/cd271a8aa973afe0926ac1fa956cc64260140b98))
20
+
21
+ ## [1.12.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.0...db-service-v1.12.1) (2024-09-03)
22
+
23
+
24
+ ### Fixed
25
+
26
+ * deep `groupby` expand queries ([#768](https://github.com/cap-js/cds-dbs/issues/768)) ([5423cf3](https://github.com/cap-js/cds-dbs/commit/5423cf38574962c09b94febab95f2e3dc118d2c9))
27
+ * **deep:** prevent false unique constraint errors and combine delete queries ([#781](https://github.com/cap-js/cds-dbs/issues/781)) ([01de95f](https://github.com/cap-js/cds-dbs/commit/01de95f5050a1d3325459ccb78a4e9a1e0dbcfde))
28
+ * **logging:** from changes in @sap/cds ([#791](https://github.com/cap-js/cds-dbs/issues/791)) ([1e8bf06](https://github.com/cap-js/cds-dbs/commit/1e8bf06c9ae92ba55d13fe9e3297d6a54c4fc8fe))
29
+ * prepend aliases to refs within function args in on conditions ([#795](https://github.com/cap-js/cds-dbs/issues/795)) ([9b34314](https://github.com/cap-js/cds-dbs/commit/9b34314d1ef8c6fd7e77451fe9bf0abdc12c27ea)), closes [#779](https://github.com/cap-js/cds-dbs/issues/779)
30
+ * prevent $search queries from throwing ([#772](https://github.com/cap-js/cds-dbs/issues/772)) ([cdf4d37](https://github.com/cap-js/cds-dbs/commit/cdf4d37590c2949cdfd6c6533370bc96cd8fd0fc))
31
+
7
32
  ## [1.12.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.11.0...db-service-v1.12.0) (2024-07-25)
8
33
 
9
34
 
10
35
  ### Fixed
11
36
 
12
- *** add placeholder for string values ([#733](https://github.com/cap-js/cds-dbs/issues/733)) ([8136a45](https://github.com/cap-js/cds-dbs/commit/8136a4526f596b67932908b8ab1336cb052100f3))
13
- *** for aggregated `expand` always set explicit alias ([#739](https://github.com/cap-js/cds-dbs/issues/739)) ([53a8075](https://github.com/cap-js/cds-dbs/commit/53a8075a609666a896296401a28b6183ff5aa487)), closes [#708](https://github.com/cap-js/cds-dbs/issues/708)
14
- *** quotations in vals ([#754](https://github.com/cap-js/cds-dbs/issues/754)) ([94d8e97](https://github.com/cap-js/cds-dbs/commit/94d8e977ed00776ff494287ce505d6b7e8017d2e))
37
+ * add placeholder for string values ([#733](https://github.com/cap-js/cds-dbs/issues/733)) ([8136a45](https://github.com/cap-js/cds-dbs/commit/8136a4526f596b67932908b8ab1336cb052100f3))
38
+ * for aggregated `expand` always set explicit alias ([#739](https://github.com/cap-js/cds-dbs/issues/739)) ([53a8075](https://github.com/cap-js/cds-dbs/commit/53a8075a609666a896296401a28b6183ff5aa487)), closes [#708](https://github.com/cap-js/cds-dbs/issues/708)
39
+ * quotations in vals ([#754](https://github.com/cap-js/cds-dbs/issues/754)) ([94d8e97](https://github.com/cap-js/cds-dbs/commit/94d8e977ed00776ff494287ce505d6b7e8017d2e))
15
40
 
16
41
 
17
42
  ### Changed
18
43
 
19
- *** generic-pool as real dep ([#750](https://github.com/cap-js/cds-dbs/issues/750)) ([b50c907](https://github.com/cap-js/cds-dbs/commit/b50c907880455a41a73826a736bc17ca17e5b9ae))
44
+ * generic-pool as real dep ([#750](https://github.com/cap-js/cds-dbs/issues/750)) ([b50c907](https://github.com/cap-js/cds-dbs/commit/b50c907880455a41a73826a736bc17ca17e5b9ae))
20
45
 
21
46
 
22
47
  ## [1.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
@@ -23,10 +23,16 @@ const StandardFunctions = {
23
23
  search: function (ref, arg) {
24
24
  if (!('val' in arg)) throw new Error(`Only single value arguments are allowed for $search`)
25
25
  // only apply first search term, rest is ignored
26
- const sub= /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
27
- arg.val = arg.__proto__.val = (sub[2] ? JSON.parse(sub[2]) : sub[3]) || ''
28
- const refs = ref.list || [ref],
29
- { toString } = ref
26
+ const sub = /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
27
+ let val
28
+ try {
29
+ val = (sub[2] ? JSON.parse(sub[2]) : sub[3]) || ''
30
+ } catch {
31
+ val = sub[2] || sub[3] || ''
32
+ }
33
+ arg.val = arg.__proto__.val = val
34
+ const refs = ref.list || [ref]
35
+ const { toString } = ref
30
36
  return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
31
37
  },
32
38
  /**
@@ -159,8 +165,8 @@ const StandardFunctions = {
159
165
  * Generates SQL statement that produces current point in time (date and time with time zone)
160
166
  * @returns {string}
161
167
  */
162
- now: function() {
163
- return this.session_context({val: '$now'})
168
+ now: function () {
169
+ return this.session_context({ val: '$now' })
164
170
  },
165
171
  /**
166
172
  * Generates SQL statement that produces the year of a given timestamp
package/lib/cqn2sql.js CHANGED
@@ -13,14 +13,14 @@ const BINARY_TYPES = {
13
13
  const { Readable } = require('stream')
14
14
 
15
15
  const DEBUG = (() => {
16
- let DEBUG = cds.debug('sql-json')
17
- if (DEBUG) return DEBUG
18
- else DEBUG = cds.debug('sql|sqlite')
19
- if (DEBUG) {
20
- return DEBUG
16
+ const LOG = cds.log('sql-json')
17
+ if (LOG._debug) return cds.debug('sql-json')
18
+ return cds.debug('sql|sqlite')
19
+ //if (DEBUG) {
20
+ // return DEBUG
21
21
  // (sql, ...more) => DEBUG (sql.replace(/(?:SELECT[\n\r\s]+(json_group_array\()?[\n\r\s]*json_insert\((\n|\r|.)*?\)[\n\r\s]*\)?[\n\r\s]+as[\n\r\s]+_json_[\n\r\s]+FROM[\n\r\s]*\(|\)[\n\r\s]*(\)[\n\r\s]+AS )|\)$)/gim,(a,b,c,d) => d || ''), ...more)
22
22
  // FIXME: looses closing ) on INSERT queries
23
- }
23
+ //}
24
24
  })()
25
25
 
26
26
  class CQN2SQLRenderer {
@@ -34,6 +34,12 @@ class CQN2SQLRenderer {
34
34
  this.class = new.target // for IntelliSense
35
35
  this.class._init() // is a noop for subsequent calls
36
36
  this.model = srv?.model
37
+
38
+ // Overwrite smart quoting
39
+ if (cds.env.sql.names === 'quoted') {
40
+ this.class.prototype.name = (name) => name.id || name
41
+ this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
42
+ }
37
43
  }
38
44
 
39
45
  static _add_mixins(aspect, mixins) {
@@ -221,7 +227,7 @@ class CQN2SQLRenderer {
221
227
  let sql = `SELECT`
222
228
  if (distinct) sql += ` DISTINCT`
223
229
  if (!_empty(columns)) sql += ` ${columns}`
224
- if (!_empty(from)) sql += ` FROM ${this.from(from)}`
230
+ if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
225
231
  else sql += this.from_dummy()
226
232
  if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
227
233
  if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
@@ -311,8 +317,8 @@ class CQN2SQLRenderer {
311
317
  */
312
318
  column_expr(x, q) {
313
319
  if (x === '*') return '*'
314
-
315
- let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
320
+
321
+ let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }) : this.expr(x)
316
322
  let alias = this.column_alias4(x, q)
317
323
  if (alias) sql += ' as ' + this.quote(alias)
318
324
  return sql
@@ -332,11 +338,16 @@ class CQN2SQLRenderer {
332
338
  * @param {import('./infer/cqn').source} from
333
339
  * @returns {string} SQL
334
340
  */
335
- from(from) {
341
+ from(from, q) {
336
342
  const { ref, as } = from
337
343
  const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
338
344
  if (ref) {
339
- const z = ref[0]
345
+ let z = ref[0]
346
+ if (cds.env.sql.names === 'quoted') {
347
+ // use SELECT.from to infer query, cds.infer also expects a query
348
+ const { target } = q || SELECT.from(from)
349
+ z = target?.['@cds.persistence.name'] || ref[0]
350
+ }
340
351
  if (z.args) {
341
352
  return _aliased(`${this.quote(this.name(z))}${this.from_args(z.args)}`)
342
353
  }
package/lib/cqn4sql.js CHANGED
@@ -4,6 +4,7 @@ const cds = require('@sap/cds')
4
4
 
5
5
  const infer = require('./infer')
6
6
  const { computeColumnsToBeSearched } = require('./search')
7
+ const { prettyPrintRef } = require('./utils')
7
8
 
8
9
  /**
9
10
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -130,15 +131,14 @@ function cqn4sql(originalQuery, model) {
130
131
  // calculate the primary keys of the target entity, there is always exactly
131
132
  // one query source for UPDATE / DELETE
132
133
  const queryTarget = Object.values(inferred.sources)[0].definition
133
- const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
134
134
  const primaryKey = { list: [] }
135
- keys.forEach(k => {
136
- // cqn4sql will add the table alias to the column later, no need to add it here
137
- subquery.SELECT.columns.push({ ref: [k.name] })
138
-
139
- // add the alias of the main query to the list of primary key references
140
- primaryKey.list.push({ ref: [transformedFrom.as, k.name] })
141
- })
135
+ for (const k of Object.keys(queryTarget.elements)) {
136
+ const e = queryTarget.elements[k]
137
+ if (e.key === true && !e.virtual) {
138
+ subquery.SELECT.columns.push({ ref: [e.name] })
139
+ primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
140
+ }
141
+ }
142
142
 
143
143
  const transformedSubquery = cqn4sql(subquery, model)
144
144
 
@@ -530,7 +530,7 @@ function cqn4sql(originalQuery, model) {
530
530
  res = getTransformedTokenStream([value], baseLink)[0]
531
531
  } else if (xpr) {
532
532
  res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
533
- } else if (val) {
533
+ } else if (val !== undefined) {
534
534
  res = { val }
535
535
  } else if (func) {
536
536
  res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
@@ -763,6 +763,9 @@ function cqn4sql(originalQuery, model) {
763
763
  function expandColumn(column) {
764
764
  let outerAlias
765
765
  let subqueryFromRef
766
+ const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
767
+ const assoc = column.$refLinks.at(-1)
768
+ ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
766
769
  if (column.isJoinRelevant) {
767
770
  // all n-1 steps of the expand column are already transformed into joins
768
771
  // find the last join relevant association. That is the n-1 assoc in the ref path.
@@ -782,24 +785,17 @@ function cqn4sql(originalQuery, model) {
782
785
  outerAlias = transformedQuery.SELECT.from.as
783
786
  subqueryFromRef = [
784
787
  ...(transformedQuery.SELECT.from.ref || /* subq in from */ [transformedQuery.SELECT.from.target.name]),
785
- ...(column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref),
788
+ ...ref,
786
789
  ]
787
790
  }
788
791
 
789
792
  // this is the alias of the column which holds the correlated subquery
790
- const columnAlias =
791
- column.as ||
792
- (column.$refLinks[0].definition.kind === 'entity'
793
- ? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
794
- : column.ref.map(idOnly).join('_'))
793
+ const columnAlias = column.as || ref.map(idOnly).join('_')
795
794
 
796
795
  // if there is a group by on the main query, all
797
796
  // columns of the expand must be in the groupBy
798
797
  if (transformedQuery.SELECT.groupBy) {
799
- const baseRef =
800
- column.$refLinks[0].definition.SELECT || column.$refLinks[0].definition.kind === 'entity'
801
- ? column.ref.slice(1)
802
- : column.ref
798
+ const baseRef = column.$refLinks[0].definition.SELECT || ref
803
799
 
804
800
  return _subqueryForGroupBy(column, baseRef, columnAlias)
805
801
  }
@@ -810,10 +806,12 @@ function cqn4sql(originalQuery, model) {
810
806
 
811
807
  // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
812
808
  const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
813
- const subqueryBase = Object.fromEntries(
814
- // preserve all props on subquery (`limit`, `order by`, …) but `expand` and `ref`
815
- Object.entries(column).filter(([key]) => !(key in { ref: true, expand: true })),
816
- )
809
+ const subqueryBase = {}
810
+ for (const [key, value] of Object.entries(column)) {
811
+ if (!(key in { ref: true, expand: true })) {
812
+ subqueryBase[key] = value;
813
+ }
814
+ }
817
815
  const subquery = {
818
816
  SELECT: {
819
817
  ...subqueryBase,
@@ -884,6 +882,7 @@ function cqn4sql(originalQuery, model) {
884
882
 
885
883
  if (expand.expand) {
886
884
  const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
885
+ setElementOnColumns(nested, expand.element)
887
886
  elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
888
887
  return nested
889
888
  }
@@ -1066,13 +1065,12 @@ function cqn4sql(originalQuery, model) {
1066
1065
  */
1067
1066
  function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
1068
1067
  const wildcardColumns = []
1069
- Object.keys(inferred.$combinedElements)
1070
- .filter(k => !exclude.includes(k))
1071
- .forEach(k => {
1068
+ for (const k of Object.keys(inferred.$combinedElements)) {
1069
+ if (!exclude.includes(k)) {
1072
1070
  const { index, tableAlias } = inferred.$combinedElements[k][0]
1073
1071
  const element = tableAlias.elements[k]
1074
1072
  // ignore FK for odata csn / ignore blobs from wildcard expansion
1075
- if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') return
1073
+ if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
1076
1074
  // for wildcard on subquery in from, just reference the elements
1077
1075
  if (tableAlias.SELECT && !element.elements && !element.target) {
1078
1076
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -1088,7 +1086,8 @@ function cqn4sql(originalQuery, model) {
1088
1086
  )
1089
1087
  wildcardColumns.push(...flatColumns)
1090
1088
  }
1091
- })
1089
+ }
1090
+ }
1092
1091
  return wildcardColumns
1093
1092
 
1094
1093
  /**
@@ -1382,6 +1381,8 @@ function cqn4sql(originalQuery, model) {
1382
1381
  }”`,
1383
1382
  )
1384
1383
  }
1384
+ const { definition: fkSource } = next
1385
+ ensureValidForeignKeys(fkSource, ref)
1385
1386
  whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1386
1387
  }
1387
1388
 
@@ -1418,12 +1419,12 @@ function cqn4sql(originalQuery, model) {
1418
1419
  const keys = def.keys // use key aspect on entity
1419
1420
  const keyValComparisons = []
1420
1421
  const flatKeys = []
1421
- Object.values(keys)
1422
- // up__ID already part of inner where exists, no need to add it explicitly here
1423
- .filter(k => k !== backlinkFor($baseLink.definition)?.[0])
1424
- .forEach(v => {
1422
+ for (const v of Object.values(keys)) {
1423
+ if (v !== backlinkFor($baseLink.definition)?.[0]) {
1424
+ // up__ID already part of inner where exists, no need to add it explicitly here
1425
1425
  flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1426
- })
1426
+ }
1427
+ }
1427
1428
  if (flatKeys.length > 1)
1428
1429
  throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1429
1430
  flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
@@ -1583,10 +1584,7 @@ function cqn4sql(originalQuery, model) {
1583
1584
  if (!def.$refLinks) return def
1584
1585
  const leaf = def.$refLinks[def.$refLinks.length - 1]
1585
1586
  const first = def.$refLinks[0]
1586
- const tableAlias = getTableAlias(
1587
- def,
1588
- def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
1589
- )
1587
+ const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
1590
1588
  if (leaf.definition.parent.kind !== 'entity')
1591
1589
  // we need the base name
1592
1590
  return getFlatColumnsFor(leaf.definition, {
@@ -1670,23 +1668,23 @@ function cqn4sql(originalQuery, model) {
1670
1668
  const refReverse = [...from.ref].reverse()
1671
1669
  const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
1672
1670
  for (let i = 0; i < refReverse.length; i += 1) {
1673
- const stepLink = $refLinksReverse[i]
1671
+ const current = $refLinksReverse[i]
1674
1672
 
1675
- let nextStepLink = $refLinksReverse[i + 1]
1673
+ let next = $refLinksReverse[i + 1]
1676
1674
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1677
1675
 
1678
- if (stepLink.definition.target && nextStepLink) {
1676
+ if (current.definition.target && next) {
1679
1677
  const { where, args } = nextStep
1680
- if (isStructured(nextStepLink.definition)) {
1678
+ if (isStructured(next.definition)) {
1681
1679
  // find next association / entity in the ref because this is actually our real nextStep
1682
1680
  const nextStepIndex =
1683
1681
  2 +
1684
1682
  $refLinksReverse
1685
1683
  .slice(i + 2)
1686
1684
  .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1687
- nextStepLink = $refLinksReverse[nextStepIndex]
1685
+ next = $refLinksReverse[nextStepIndex]
1688
1686
  }
1689
- let as = getLastStringSegment(nextStepLink.alias)
1687
+ let as = getLastStringSegment(next.alias)
1690
1688
  /**
1691
1689
  * for an `expand` subquery, we do not need to add
1692
1690
  * the table alias of the `expand` host to the join tree
@@ -1696,8 +1694,10 @@ function cqn4sql(originalQuery, model) {
1696
1694
  if (!(inferred.SELECT?.expand === true)) {
1697
1695
  as = getNextAvailableTableAlias(as)
1698
1696
  }
1699
- nextStepLink.alias = as
1700
- whereExistsSubSelects.push(getWhereExistsSubquery(stepLink, nextStepLink, where, false, args))
1697
+ next.alias = as
1698
+ const { definition: fkSource } = current
1699
+ ensureValidForeignKeys(fkSource, from.ref)
1700
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, false, args))
1701
1701
  }
1702
1702
  }
1703
1703
 
@@ -1742,6 +1742,14 @@ function cqn4sql(originalQuery, model) {
1742
1742
  }
1743
1743
  }
1744
1744
 
1745
+ function ensureValidForeignKeys(fkSource, ref, kind = null) {
1746
+ if (fkSource.keys && fkSource.keys.length === 0) {
1747
+ const path = prettyPrintRef(ref, model)
1748
+ if (kind === 'expand') throw new Error(`Can't expand “${fkSource.name}” as it has no foreign keys`)
1749
+ throw new Error(`Path step “${fkSource.name}” of “${path}” has no foreign keys`)
1750
+ }
1751
+ }
1752
+
1745
1753
  function whereExistsSubqueries(whereExistsSubSelects) {
1746
1754
  if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
1747
1755
  whereExistsSubSelects.reduce((prev, cur) => {
@@ -1818,7 +1826,6 @@ function cqn4sql(originalQuery, model) {
1818
1826
  const { on, keys } = assocRefLink.definition
1819
1827
  const target = getDefinition(assocRefLink.definition.target)
1820
1828
  let res
1821
- // technically we could have multiple backlinks
1822
1829
  if (keys) {
1823
1830
  const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
1824
1831
  const transformedOn = []
@@ -1852,6 +1859,11 @@ function cqn4sql(originalQuery, model) {
1852
1859
  result[i] = asXpr(xpr)
1853
1860
  continue
1854
1861
  }
1862
+ if (lhs.args) {
1863
+ const args = calculateOnCondition(lhs.args)
1864
+ result[i] = { ...lhs, args }
1865
+ continue
1866
+ }
1855
1867
  const rhs = result[i + 2]
1856
1868
  if (rhs?.ref || lhs.ref) {
1857
1869
  // if we have refs on each side of the comparison, we might need to perform tuple expansion
@@ -1946,6 +1958,12 @@ function cqn4sql(originalQuery, model) {
1946
1958
  }
1947
1959
  })
1948
1960
  } else if (backlink.keys) {
1961
+ // sanity check: error out if we can't produce a join
1962
+ if (backlink.keys.length === 0) {
1963
+ throw new Error(
1964
+ `Path step “${assocRefLink.alias}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
1965
+ )
1966
+ }
1949
1967
  // managed backlink -> calculate fk-pk pairs
1950
1968
  const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
1951
1969
  fkPkPairs.forEach((pair, j) => {
@@ -2264,13 +2282,6 @@ function cqn4sql(originalQuery, model) {
2264
2282
  }
2265
2283
  }
2266
2284
 
2267
- module.exports = Object.assign(cqn4sql, {
2268
- // for own tests only:
2269
- eqOps,
2270
- notEqOps,
2271
- notSupportedOps,
2272
- })
2273
-
2274
2285
  function calculateElementName(token) {
2275
2286
  const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
2276
2287
  let name
@@ -2358,3 +2369,10 @@ const refWithConditions = step => {
2358
2369
  return appendix ? step.id + appendix : step
2359
2370
  }
2360
2371
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
2372
+
2373
+ module.exports = Object.assign(cqn4sql, {
2374
+ // for own tests only:
2375
+ eqOps,
2376
+ notEqOps,
2377
+ notSupportedOps,
2378
+ })
@@ -1,6 +1,7 @@
1
1
  const cds = require('@sap/cds')
2
2
  const { _target_name4 } = require('./SQLService')
3
- const InsertResult = require('../lib/InsertResults')
3
+
4
+ const ROOT = Symbol('root')
4
5
 
5
6
  // REVISIT: remove old path with cds^8
6
7
  let _compareJson
@@ -45,20 +46,22 @@ async function onDeep(req, next) {
45
46
  if (query.UPDATE && !beforeData.length) return 0
46
47
 
47
48
  const queries = getDeepQueries(query, beforeData, target)
48
- const res = await Promise.all(queries.map(query => {
49
- if (query.INSERT) return this.onINSERT({ query })
50
- if (query.UPDATE) return this.onUPDATE({ query })
51
- if (query.DELETE) return this.onSIMPLE({ query })
52
- }))
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
- )
49
+
50
+ // first delete, then update, then insert because of potential unique constraints:
51
+ // - deletes never trigger unique constraints, but can prevent them -> execute first
52
+ // - updates can trigger and prevent unique constraints -> execute second
53
+ // - inserts can only trigger unique constraints -> execute last
54
+ await Promise.all(Array.from(queries.deletes.values()).map(query => this.onSIMPLE({ query })))
55
+ await Promise.all(queries.updates.map(query => this.onUPDATE({ query })))
56
+
57
+ const rootQuery = queries.inserts.get(ROOT)
58
+ queries.inserts.delete(ROOT)
59
+ const [rootResult] = await Promise.all([
60
+ rootQuery && this.onINSERT({ query: rootQuery }),
61
+ ...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })),
62
+ ])
63
+
64
+ return rootResult ?? beforeData.length
62
65
  }
63
66
 
64
67
  const hasDeep = (q, target) => {
@@ -195,7 +198,7 @@ const getDeepQueries = (query, dbData, target) => {
195
198
  diff = [diff]
196
199
  }
197
200
 
198
- return _getDeepQueries(diff, target, true)
201
+ return _getDeepQueries(diff, target)
199
202
  }
200
203
 
201
204
  const _hasManagedElements = target => {
@@ -205,16 +208,19 @@ const _hasManagedElements = target => {
205
208
  /**
206
209
  * @param {unknown[]} diff
207
210
  * @param {import('@sap/cds/apis/csn').Definition} target
208
- * @param {boolean} [root=false]
209
- * @returns {import('@sap/cds/apis/cqn').Query[]}
211
+ * @param {Map<String, Object>} deletes
212
+ * @param {Map<String, Object>} inserts
213
+ * @param {Object[]} updates
214
+ * @param {boolean} [root=true]
215
+ * @returns {Object|Boolean}
210
216
  */
211
- const _getDeepQueries = (diff, target, root = false) => {
212
- const queries = []
213
-
217
+ const _getDeepQueries = (diff, target, deletes = new Map(), inserts = new Map(), updates = [], root = true) => {
218
+ // flag to determine if queries were created
219
+ let dirty = false
214
220
  for (const diffEntry of diff) {
215
221
  if (diffEntry === undefined) continue
216
- const subQueries = []
217
222
 
223
+ let childrenDirty = false
218
224
  for (const prop in diffEntry) {
219
225
  // handle deep operations
220
226
 
@@ -224,9 +230,12 @@ const _getDeepQueries = (diff, target, root = false) => {
224
230
  delete diffEntry[prop]
225
231
  } else if (target.compositions?.[prop]) {
226
232
  const arrayed = Array.isArray(propData) ? propData : [propData]
227
- arrayed.forEach(subEntry => {
228
- subQueries.push(..._getDeepQueries([subEntry], target.elements[prop]._target))
229
- })
233
+ childrenDirty =
234
+ arrayed
235
+ .map(subEntry =>
236
+ _getDeepQueries([subEntry], target.elements[prop]._target, deletes, inserts, updates, false),
237
+ )
238
+ .some(a => a) || childrenDirty
230
239
  delete diffEntry[prop]
231
240
  } else if (diffEntry[prop] === undefined) {
232
241
  // restore current behavior, if property is undefined, not part of payload
@@ -242,12 +251,32 @@ const _getDeepQueries = (diff, target, root = false) => {
242
251
  delete diffEntry._old
243
252
  }
244
253
 
245
- // first calculate subqueries and rm their properties, then build root query
246
254
  if (op === 'create') {
247
- queries.push(INSERT.into(target).entries(diffEntry))
255
+ dirty = true
256
+ const id = root ? ROOT : target.name
257
+ const insert = inserts.get(id)
258
+ if (insert) {
259
+ insert.INSERT.entries.push(diffEntry)
260
+ } else {
261
+ const q = INSERT.into(target).entries(diffEntry)
262
+ inserts.set(id, q)
263
+ }
248
264
  } else if (op === 'delete') {
249
- queries.push(DELETE.from(target).where(diffEntry))
250
- } else if (op === 'update' || (op === undefined && (root || subQueries.length) && _hasManagedElements(target))) {
265
+ dirty = true
266
+ const keys = cds.utils
267
+ .Object_keys(target.keys)
268
+ .filter(key => !target.keys[key].virtual && !target.keys[key].isAssociation)
269
+
270
+ const keyVals = keys.map(k => ({ val: diffEntry[k] }))
271
+ const currDelete = deletes.get(target.name)
272
+ if (currDelete) currDelete.DELETE.where[2].list.push({ list: keyVals })
273
+ else {
274
+ const left = { list: keys.map(k => ({ ref: [k] })) }
275
+ const right = { list: [{ list: keyVals }] }
276
+ deletes.set(target.name, DELETE.from(target).where([left, 'in', right]))
277
+ }
278
+ } else if (op === 'update' || (op === undefined && (root || childrenDirty) && _hasManagedElements(target))) {
279
+ dirty = true
251
280
  // TODO do we need the where here?
252
281
  const keys = target.keys
253
282
  const cqn = UPDATE(target).with(diffEntry)
@@ -259,34 +288,16 @@ const _getDeepQueries = (diff, target, root = false) => {
259
288
  delete diffEntry[key]
260
289
  }
261
290
  cqn.with(diffEntry)
262
- queries.push(cqn)
291
+ updates.push(cqn)
263
292
  }
264
-
265
- for (const q of subQueries) queries.push(q)
266
293
  }
267
294
 
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
- }
281
- Object.defineProperty(q, handledDeep, { value: true })
282
- return q
283
- })
284
- .filter(a => a)
295
+ return root ? { updates, inserts, deletes } : dirty
285
296
  }
286
297
 
287
298
  module.exports = {
288
299
  onDeep,
289
- getDeepQueries,
290
- getExpandForDeep,
291
300
  hasDeep,
301
+ getDeepQueries, // only for testing
302
+ getExpandForDeep, // only for testing
292
303
  }
@@ -170,7 +170,8 @@ function infer(originalQuery, model) {
170
170
  if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
171
171
  if (!ref) return
172
172
  init$refLinks(arg)
173
- ref.forEach((step, i) => {
173
+ let i = 0
174
+ for (const step of ref) {
174
175
  const id = step.id || step
175
176
  if (i === 0) {
176
177
  // infix filter never have table alias
@@ -231,7 +232,8 @@ function infer(originalQuery, model) {
231
232
  step.where.forEach(walkTokenStream)
232
233
  } else throw new Error('A filter can only be provided when navigating along associations')
233
234
  }
234
- })
235
+ i += 1
236
+ }
235
237
  const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
236
238
  if (definition.value) {
237
239
  // nested calculated element
@@ -1046,15 +1048,17 @@ function infer(originalQuery, model) {
1046
1048
  if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
1047
1049
  const { elements } = getDefinitionFromSources(sources, aliases[0])
1048
1050
  // only one query source and no overwritten columns
1049
- Object.keys(elements)
1050
- .filter(k => !exclude(k))
1051
- .forEach(k => {
1051
+ for (const k of Object.keys(elements)) {
1052
+ if (!exclude(k)) {
1052
1053
  const element = elements[k]
1053
- if (element.type !== 'cds.LargeBinary') queryElements[k] = element
1054
+ if (element.type !== 'cds.LargeBinary') {
1055
+ queryElements[k] = element
1056
+ }
1054
1057
  if (element.value) {
1055
1058
  linkCalculatedElement(element)
1056
1059
  }
1057
- })
1060
+ }
1061
+ }
1058
1062
  return
1059
1063
  }
1060
1064
 
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { prettyPrintRef } = require('../utils')
4
+
3
5
  // REVISIT: define following unknown types
4
6
 
5
7
  /**
@@ -177,13 +179,19 @@ class JoinTree {
177
179
  }
178
180
 
179
181
  // if no root node was found, the column is selected from a subquery
180
- if(!node) return
182
+ if (!node) return
181
183
  while (i < col.ref.length) {
182
184
  const step = col.ref[i]
183
185
  const { where, args } = step
184
186
  const id = joinId(step, args, where)
185
187
  const next = node.children.get(id)
186
188
  const $refLink = col.$refLinks[i]
189
+ // sanity check: error out if we can't produce a join
190
+ if ($refLink.definition.keys && $refLink.definition.keys.length === 0) {
191
+ const path = prettyPrintRef(col.ref)
192
+ throw new Error(`Path step “${$refLink.alias}” of “${path}” has no foreign keys`)
193
+ }
194
+
187
195
  if (next) {
188
196
  // step already seen before
189
197
  node = next
@@ -208,7 +216,6 @@ class JoinTree {
208
216
  }
209
217
  child.$refLink.alias = this.addNextAvailableTableAlias($refLink.alias, outerQueries)
210
218
  }
211
- //> REVISIT: remove fallback once UCSN is standard
212
219
  const elements =
213
220
  node.$refLink?.definition.isAssociation &&
214
221
  (node.$refLink.definition.elements || node.$refLink.definition.foreignKeys)
package/lib/utils.js ADDED
@@ -0,0 +1,27 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Formats a ref array into a string representation.
5
+ * If the first step is an entity, the separator is a colon, otherwise a dot.
6
+ *
7
+ * @param {Array} ref - The reference array to be formatted.
8
+ * @param {Object} model - The model object containing definitions.
9
+ * @returns {string} The formatted string representation of the reference.
10
+ */
11
+ function prettyPrintRef(ref, model = null) {
12
+ return ref.reduce((acc, curr, j) => {
13
+ if (j > 0) {
14
+ if (j === 1 && model?.definitions[ref[0]]?.kind === 'entity') {
15
+ acc += ':'
16
+ } else {
17
+ acc += '.'
18
+ }
19
+ }
20
+ return acc + `${curr.id ? curr.id + '[…]' : curr}`
21
+ }, '')
22
+ }
23
+
24
+ // export the function to be used in other modules
25
+ module.exports = {
26
+ prettyPrintRef,
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.12.0",
3
+ "version": "1.13.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": {