@cap-js/db-service 1.12.1 → 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,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
+ ## [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
+
7
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)
8
22
 
9
23
 
package/lib/cqn2sql.js CHANGED
@@ -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
 
@@ -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,
@@ -1067,13 +1065,12 @@ function cqn4sql(originalQuery, model) {
1067
1065
  */
1068
1066
  function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
1069
1067
  const wildcardColumns = []
1070
- Object.keys(inferred.$combinedElements)
1071
- .filter(k => !exclude.includes(k))
1072
- .forEach(k => {
1068
+ for (const k of Object.keys(inferred.$combinedElements)) {
1069
+ if (!exclude.includes(k)) {
1073
1070
  const { index, tableAlias } = inferred.$combinedElements[k][0]
1074
1071
  const element = tableAlias.elements[k]
1075
1072
  // ignore FK for odata csn / ignore blobs from wildcard expansion
1076
- if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') return
1073
+ if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
1077
1074
  // for wildcard on subquery in from, just reference the elements
1078
1075
  if (tableAlias.SELECT && !element.elements && !element.target) {
1079
1076
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -1089,7 +1086,8 @@ function cqn4sql(originalQuery, model) {
1089
1086
  )
1090
1087
  wildcardColumns.push(...flatColumns)
1091
1088
  }
1092
- })
1089
+ }
1090
+ }
1093
1091
  return wildcardColumns
1094
1092
 
1095
1093
  /**
@@ -1383,6 +1381,8 @@ function cqn4sql(originalQuery, model) {
1383
1381
  }”`,
1384
1382
  )
1385
1383
  }
1384
+ const { definition: fkSource } = next
1385
+ ensureValidForeignKeys(fkSource, ref)
1386
1386
  whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1387
1387
  }
1388
1388
 
@@ -1419,12 +1419,12 @@ function cqn4sql(originalQuery, model) {
1419
1419
  const keys = def.keys // use key aspect on entity
1420
1420
  const keyValComparisons = []
1421
1421
  const flatKeys = []
1422
- Object.values(keys)
1423
- // up__ID already part of inner where exists, no need to add it explicitly here
1424
- .filter(k => k !== backlinkFor($baseLink.definition)?.[0])
1425
- .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
1426
1425
  flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1427
- })
1426
+ }
1427
+ }
1428
1428
  if (flatKeys.length > 1)
1429
1429
  throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1430
1430
  flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
@@ -1584,10 +1584,7 @@ function cqn4sql(originalQuery, model) {
1584
1584
  if (!def.$refLinks) return def
1585
1585
  const leaf = def.$refLinks[def.$refLinks.length - 1]
1586
1586
  const first = def.$refLinks[0]
1587
- const tableAlias = getTableAlias(
1588
- def,
1589
- def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
1590
- )
1587
+ const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
1591
1588
  if (leaf.definition.parent.kind !== 'entity')
1592
1589
  // we need the base name
1593
1590
  return getFlatColumnsFor(leaf.definition, {
@@ -1671,23 +1668,23 @@ function cqn4sql(originalQuery, model) {
1671
1668
  const refReverse = [...from.ref].reverse()
1672
1669
  const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
1673
1670
  for (let i = 0; i < refReverse.length; i += 1) {
1674
- const stepLink = $refLinksReverse[i]
1671
+ const current = $refLinksReverse[i]
1675
1672
 
1676
- let nextStepLink = $refLinksReverse[i + 1]
1673
+ let next = $refLinksReverse[i + 1]
1677
1674
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1678
1675
 
1679
- if (stepLink.definition.target && nextStepLink) {
1676
+ if (current.definition.target && next) {
1680
1677
  const { where, args } = nextStep
1681
- if (isStructured(nextStepLink.definition)) {
1678
+ if (isStructured(next.definition)) {
1682
1679
  // find next association / entity in the ref because this is actually our real nextStep
1683
1680
  const nextStepIndex =
1684
1681
  2 +
1685
1682
  $refLinksReverse
1686
1683
  .slice(i + 2)
1687
1684
  .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1688
- nextStepLink = $refLinksReverse[nextStepIndex]
1685
+ next = $refLinksReverse[nextStepIndex]
1689
1686
  }
1690
- let as = getLastStringSegment(nextStepLink.alias)
1687
+ let as = getLastStringSegment(next.alias)
1691
1688
  /**
1692
1689
  * for an `expand` subquery, we do not need to add
1693
1690
  * the table alias of the `expand` host to the join tree
@@ -1697,8 +1694,10 @@ function cqn4sql(originalQuery, model) {
1697
1694
  if (!(inferred.SELECT?.expand === true)) {
1698
1695
  as = getNextAvailableTableAlias(as)
1699
1696
  }
1700
- nextStepLink.alias = as
1701
- 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))
1702
1701
  }
1703
1702
  }
1704
1703
 
@@ -1743,6 +1742,14 @@ function cqn4sql(originalQuery, model) {
1743
1742
  }
1744
1743
  }
1745
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
+
1746
1753
  function whereExistsSubqueries(whereExistsSubSelects) {
1747
1754
  if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
1748
1755
  whereExistsSubSelects.reduce((prev, cur) => {
@@ -1819,7 +1826,6 @@ function cqn4sql(originalQuery, model) {
1819
1826
  const { on, keys } = assocRefLink.definition
1820
1827
  const target = getDefinition(assocRefLink.definition.target)
1821
1828
  let res
1822
- // technically we could have multiple backlinks
1823
1829
  if (keys) {
1824
1830
  const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
1825
1831
  const transformedOn = []
@@ -1853,7 +1859,7 @@ function cqn4sql(originalQuery, model) {
1853
1859
  result[i] = asXpr(xpr)
1854
1860
  continue
1855
1861
  }
1856
- if(lhs.args) {
1862
+ if (lhs.args) {
1857
1863
  const args = calculateOnCondition(lhs.args)
1858
1864
  result[i] = { ...lhs, args }
1859
1865
  continue
@@ -1952,6 +1958,12 @@ function cqn4sql(originalQuery, model) {
1952
1958
  }
1953
1959
  })
1954
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
+ }
1955
1967
  // managed backlink -> calculate fk-pk pairs
1956
1968
  const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
1957
1969
  fkPkPairs.forEach((pair, j) => {
@@ -2270,13 +2282,6 @@ function cqn4sql(originalQuery, model) {
2270
2282
  }
2271
2283
  }
2272
2284
 
2273
- module.exports = Object.assign(cqn4sql, {
2274
- // for own tests only:
2275
- eqOps,
2276
- notEqOps,
2277
- notSupportedOps,
2278
- })
2279
-
2280
2285
  function calculateElementName(token) {
2281
2286
  const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
2282
2287
  let name
@@ -2364,3 +2369,10 @@ const refWithConditions = step => {
2364
2369
  return appendix ? step.id + appendix : step
2365
2370
  }
2366
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
+ })
@@ -61,7 +61,7 @@ async function onDeep(req, next) {
61
61
  ...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })),
62
62
  ])
63
63
 
64
- return beforeData.length ?? rootResult
64
+ return rootResult ?? beforeData.length
65
65
  }
66
66
 
67
67
  const hasDeep = (q, target) => {
@@ -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.1",
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": {