@cap-js/db-service 1.12.1 → 1.14.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,33 @@
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.14.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.13.0...db-service-v1.14.0) (2024-10-15)
8
+
9
+
10
+ ### Added
11
+
12
+ * assoc-like calc elements after exists predicate ([#831](https://github.com/cap-js/cds-dbs/issues/831)) ([05f7d75](https://github.com/cap-js/cds-dbs/commit/05f7d75837495d58cc4f72ad628077bdebb0acf6))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * Improved behavioral consistency between the database services ([#837](https://github.com/cap-js/cds-dbs/issues/837)) ([b6f7187](https://github.com/cap-js/cds-dbs/commit/b6f718701e48dfb1c4c3d98ee016ec45930f8e7b))
18
+ * Treat assoc-like calculated elements as unmanaged assocs ([#830](https://github.com/cap-js/cds-dbs/issues/830)) ([cbe0df7](https://github.com/cap-js/cds-dbs/commit/cbe0df7a66fec0d421947767adc8621ed8bf236c))
19
+
20
+ ## [1.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
21
+
22
+
23
+ ### Added
24
+
25
+ * Add quoted mode support ([#681](https://github.com/cap-js/cds-dbs/issues/681)) ([43c7a6c](https://github.com/cap-js/cds-dbs/commit/43c7a6c1bed836a1210eb9c2ff5c7ffc0e498d76))
26
+
27
+
28
+ ### Fixed
29
+
30
+ * **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))
31
+ * 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))
32
+ * 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))
33
+
7
34
  ## [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
35
 
9
36
 
package/lib/SQLService.js CHANGED
@@ -466,6 +466,10 @@ const sqls = new (class extends SQLService {
466
466
  get factory() {
467
467
  return null
468
468
  }
469
+
470
+ get model() {
471
+ return cds.model
472
+ }
469
473
  })()
470
474
  cds.extend(cds.ql.Query).with(
471
475
  class {
@@ -1,3 +1,5 @@
1
+ const cds = require("@sap/cds")
2
+
1
3
  const StandardFunctions = {
2
4
  // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
3
5
 
@@ -59,7 +61,7 @@ const StandardFunctions = {
59
61
  * @param {string} x
60
62
  * @returns {string}
61
63
  */
62
- countdistinct: x => `count(distinct ${x || '*'})`,
64
+ countdistinct: x => `count(distinct ${x || cds.error`countdistinct requires a ref to be counted`})`,
63
65
  /**
64
66
  * Generates SQL statement that produces the index of the first occurrence of the second string in the first string
65
67
  * @param {string} x
package/lib/cqn2sql.js CHANGED
@@ -18,8 +18,8 @@ const DEBUG = (() => {
18
18
  return cds.debug('sql|sqlite')
19
19
  //if (DEBUG) {
20
20
  // return DEBUG
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
- // FIXME: looses closing ) on INSERT queries
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
+ // FIXME: looses closing ) on INSERT queries
23
23
  //}
24
24
  })()
25
25
 
@@ -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) {
@@ -82,6 +88,7 @@ class CQN2SQLRenderer {
82
88
  this.values = [] // prepare values, filled in by subroutines
83
89
  this[kind]((this.cqn = q)) // actual sql rendering happens here
84
90
  if (vars?.length && !this.values?.length) this.values = vars
91
+ if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
85
92
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
86
93
  DEBUG?.(
87
94
  this.sql,
@@ -110,8 +117,13 @@ class CQN2SQLRenderer {
110
117
  * @param {import('./infer/cqn').CREATE} q
111
118
  */
112
119
  CREATE(q) {
113
- const { target } = q,
114
- { query } = target
120
+ let { target } = q
121
+ let query = target?.query || q.CREATE.as
122
+ if (!target || target._unresolved) {
123
+ const entity = q.CREATE.entity
124
+ target = typeof entity === 'string' ? { name: entity } : q.CREATE.entity
125
+ }
126
+
115
127
  const name = this.name(target.name)
116
128
  // Don't allow place holders inside views
117
129
  delete this.values
@@ -197,8 +209,9 @@ class CQN2SQLRenderer {
197
209
  */
198
210
  DROP(q) {
199
211
  const { target } = q
200
- const isView = target.query || target.projection
201
- return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(target.name))}`)
212
+ const isView = target?.query || target?.projection || q.DROP.view
213
+ const name = target?.name || q.DROP.table?.ref?.[0] || q.DROP.view?.ref?.[0]
214
+ return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(name))}`)
202
215
  }
203
216
 
204
217
  // SELECT Statements ------------------------------------------------
@@ -217,11 +230,11 @@ class CQN2SQLRenderer {
217
230
 
218
231
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
219
232
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
220
- let columns = this.SELECT_columns(q)
233
+ const columns = this.SELECT_columns(q)
221
234
  let sql = `SELECT`
222
235
  if (distinct) sql += ` DISTINCT`
223
236
  if (!_empty(columns)) sql += ` ${columns}`
224
- if (!_empty(from)) sql += ` FROM ${this.from(from)}`
237
+ if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
225
238
  else sql += this.from_dummy()
226
239
  if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
227
240
  if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
@@ -311,8 +324,8 @@ class CQN2SQLRenderer {
311
324
  */
312
325
  column_expr(x, q) {
313
326
  if (x === '*') return '*'
314
-
315
- let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
327
+
328
+ let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }) : this.expr(x)
316
329
  let alias = this.column_alias4(x, q)
317
330
  if (alias) sql += ' as ' + this.quote(alias)
318
331
  return sql
@@ -332,11 +345,16 @@ class CQN2SQLRenderer {
332
345
  * @param {import('./infer/cqn').source} from
333
346
  * @returns {string} SQL
334
347
  */
335
- from(from) {
348
+ from(from, q) {
336
349
  const { ref, as } = from
337
350
  const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
338
351
  if (ref) {
339
- const z = ref[0]
352
+ let z = ref[0]
353
+ if (cds.env.sql.names === 'quoted') {
354
+ // use SELECT.from to infer query, cds.infer also expects a query
355
+ const { target } = q || SELECT.from(from)
356
+ z = target?.['@cds.persistence.name'] || ref[0]
357
+ }
340
358
  if (z.args) {
341
359
  return _aliased(`${this.quote(this.name(z))}${this.from_args(z.args)}`)
342
360
  }
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, isCalculatedOnRead, isCalculatedElement } = 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
 
@@ -317,10 +317,6 @@ function cqn4sql(originalQuery, model) {
317
317
  }
318
318
  }
319
319
 
320
- function isCalculatedOnRead(def) {
321
- return def?.value && !def.value.stored
322
- }
323
-
324
320
  /**
325
321
  * Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
326
322
  *
@@ -763,6 +759,9 @@ function cqn4sql(originalQuery, model) {
763
759
  function expandColumn(column) {
764
760
  let outerAlias
765
761
  let subqueryFromRef
762
+ const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
763
+ const assoc = column.$refLinks.at(-1)
764
+ ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
766
765
  if (column.isJoinRelevant) {
767
766
  // all n-1 steps of the expand column are already transformed into joins
768
767
  // find the last join relevant association. That is the n-1 assoc in the ref path.
@@ -782,24 +781,17 @@ function cqn4sql(originalQuery, model) {
782
781
  outerAlias = transformedQuery.SELECT.from.as
783
782
  subqueryFromRef = [
784
783
  ...(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),
784
+ ...ref,
786
785
  ]
787
786
  }
788
787
 
789
788
  // 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('_'))
789
+ const columnAlias = column.as || ref.map(idOnly).join('_')
795
790
 
796
791
  // if there is a group by on the main query, all
797
792
  // columns of the expand must be in the groupBy
798
793
  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
794
+ const baseRef = column.$refLinks[0].definition.SELECT || ref
803
795
 
804
796
  return _subqueryForGroupBy(column, baseRef, columnAlias)
805
797
  }
@@ -810,10 +802,12 @@ function cqn4sql(originalQuery, model) {
810
802
 
811
803
  // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
812
804
  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
- )
805
+ const subqueryBase = {}
806
+ for (const [key, value] of Object.entries(column)) {
807
+ if (!(key in { ref: true, expand: true })) {
808
+ subqueryBase[key] = value
809
+ }
810
+ }
817
811
  const subquery = {
818
812
  SELECT: {
819
813
  ...subqueryBase,
@@ -1067,13 +1061,12 @@ function cqn4sql(originalQuery, model) {
1067
1061
  */
1068
1062
  function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
1069
1063
  const wildcardColumns = []
1070
- Object.keys(inferred.$combinedElements)
1071
- .filter(k => !exclude.includes(k))
1072
- .forEach(k => {
1064
+ for (const k of Object.keys(inferred.$combinedElements)) {
1065
+ if (!exclude.includes(k)) {
1073
1066
  const { index, tableAlias } = inferred.$combinedElements[k][0]
1074
1067
  const element = tableAlias.elements[k]
1075
1068
  // ignore FK for odata csn / ignore blobs from wildcard expansion
1076
- if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') return
1069
+ if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
1077
1070
  // for wildcard on subquery in from, just reference the elements
1078
1071
  if (tableAlias.SELECT && !element.elements && !element.target) {
1079
1072
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -1089,7 +1082,8 @@ function cqn4sql(originalQuery, model) {
1089
1082
  )
1090
1083
  wildcardColumns.push(...flatColumns)
1091
1084
  }
1092
- })
1085
+ }
1086
+ }
1093
1087
  return wildcardColumns
1094
1088
 
1095
1089
  /**
@@ -1367,22 +1361,21 @@ function cqn4sql(originalQuery, model) {
1367
1361
 
1368
1362
  const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
1369
1363
  next.alias = as
1370
- if (next.definition.value) {
1371
- throw new Error(
1372
- `Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
1373
- .map(idOnly)
1374
- .join('.')}”`,
1375
- )
1376
- }
1377
1364
  if (!next.definition.target) {
1365
+ let type = next.definition.type
1366
+ if (isCalculatedElement(next.definition)) {
1367
+ // try to infer the type at the leaf for better error message
1368
+ const { $refLinks } = next.definition.value
1369
+ type = $refLinks?.at(-1).definition.type || 'expression'
1370
+ }
1378
1371
  throw new Error(
1379
1372
  `Expecting path “${tokenStream[i + 1].ref
1380
1373
  .map(idOnly)
1381
- .join('.')}” following “EXISTS” predicate to end with association/composition, found “${
1382
- next.definition.type
1383
- }”`,
1374
+ .join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
1384
1375
  )
1385
1376
  }
1377
+ const { definition: fkSource } = next
1378
+ ensureValidForeignKeys(fkSource, ref)
1386
1379
  whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
1387
1380
  }
1388
1381
 
@@ -1419,12 +1412,12 @@ function cqn4sql(originalQuery, model) {
1419
1412
  const keys = def.keys // use key aspect on entity
1420
1413
  const keyValComparisons = []
1421
1414
  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 => {
1415
+ for (const v of Object.values(keys)) {
1416
+ if (v !== backlinkFor($baseLink.definition)?.[0]) {
1417
+ // up__ID already part of inner where exists, no need to add it explicitly here
1426
1418
  flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1427
- })
1419
+ }
1420
+ }
1428
1421
  if (flatKeys.length > 1)
1429
1422
  throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1430
1423
  flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
@@ -1584,10 +1577,7 @@ function cqn4sql(originalQuery, model) {
1584
1577
  if (!def.$refLinks) return def
1585
1578
  const leaf = def.$refLinks[def.$refLinks.length - 1]
1586
1579
  const first = def.$refLinks[0]
1587
- const tableAlias = getTableAlias(
1588
- def,
1589
- def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
1590
- )
1580
+ const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
1591
1581
  if (leaf.definition.parent.kind !== 'entity')
1592
1582
  // we need the base name
1593
1583
  return getFlatColumnsFor(leaf.definition, {
@@ -1671,23 +1661,23 @@ function cqn4sql(originalQuery, model) {
1671
1661
  const refReverse = [...from.ref].reverse()
1672
1662
  const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
1673
1663
  for (let i = 0; i < refReverse.length; i += 1) {
1674
- const stepLink = $refLinksReverse[i]
1664
+ const current = $refLinksReverse[i]
1675
1665
 
1676
- let nextStepLink = $refLinksReverse[i + 1]
1666
+ let next = $refLinksReverse[i + 1]
1677
1667
  const nextStep = refReverse[i + 1] // only because we want the filter condition
1678
1668
 
1679
- if (stepLink.definition.target && nextStepLink) {
1669
+ if (current.definition.target && next) {
1680
1670
  const { where, args } = nextStep
1681
- if (isStructured(nextStepLink.definition)) {
1671
+ if (isStructured(next.definition)) {
1682
1672
  // find next association / entity in the ref because this is actually our real nextStep
1683
1673
  const nextStepIndex =
1684
1674
  2 +
1685
1675
  $refLinksReverse
1686
1676
  .slice(i + 2)
1687
1677
  .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1688
- nextStepLink = $refLinksReverse[nextStepIndex]
1678
+ next = $refLinksReverse[nextStepIndex]
1689
1679
  }
1690
- let as = getLastStringSegment(nextStepLink.alias)
1680
+ let as = getLastStringSegment(next.alias)
1691
1681
  /**
1692
1682
  * for an `expand` subquery, we do not need to add
1693
1683
  * the table alias of the `expand` host to the join tree
@@ -1697,8 +1687,10 @@ function cqn4sql(originalQuery, model) {
1697
1687
  if (!(inferred.SELECT?.expand === true)) {
1698
1688
  as = getNextAvailableTableAlias(as)
1699
1689
  }
1700
- nextStepLink.alias = as
1701
- whereExistsSubSelects.push(getWhereExistsSubquery(stepLink, nextStepLink, where, false, args))
1690
+ next.alias = as
1691
+ const { definition: fkSource } = current
1692
+ ensureValidForeignKeys(fkSource, from.ref)
1693
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, false, args))
1702
1694
  }
1703
1695
  }
1704
1696
 
@@ -1743,6 +1735,14 @@ function cqn4sql(originalQuery, model) {
1743
1735
  }
1744
1736
  }
1745
1737
 
1738
+ function ensureValidForeignKeys(fkSource, ref, kind = null) {
1739
+ if (fkSource.keys && fkSource.keys.length === 0) {
1740
+ const path = prettyPrintRef(ref, model)
1741
+ if (kind === 'expand') throw new Error(`Can't expand “${fkSource.name}” as it has no foreign keys`)
1742
+ throw new Error(`Path step “${fkSource.name}” of “${path}” has no foreign keys`)
1743
+ }
1744
+ }
1745
+
1746
1746
  function whereExistsSubqueries(whereExistsSubSelects) {
1747
1747
  if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
1748
1748
  whereExistsSubSelects.reduce((prev, cur) => {
@@ -1819,7 +1819,6 @@ function cqn4sql(originalQuery, model) {
1819
1819
  const { on, keys } = assocRefLink.definition
1820
1820
  const target = getDefinition(assocRefLink.definition.target)
1821
1821
  let res
1822
- // technically we could have multiple backlinks
1823
1822
  if (keys) {
1824
1823
  const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
1825
1824
  const transformedOn = []
@@ -1853,7 +1852,7 @@ function cqn4sql(originalQuery, model) {
1853
1852
  result[i] = asXpr(xpr)
1854
1853
  continue
1855
1854
  }
1856
- if(lhs.args) {
1855
+ if (lhs.args) {
1857
1856
  const args = calculateOnCondition(lhs.args)
1858
1857
  result[i] = { ...lhs, args }
1859
1858
  continue
@@ -1952,6 +1951,12 @@ function cqn4sql(originalQuery, model) {
1952
1951
  }
1953
1952
  })
1954
1953
  } else if (backlink.keys) {
1954
+ // sanity check: error out if we can't produce a join
1955
+ if (backlink.keys.length === 0) {
1956
+ throw new Error(
1957
+ `Path step “${assocRefLink.alias}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
1958
+ )
1959
+ }
1955
1960
  // managed backlink -> calculate fk-pk pairs
1956
1961
  const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
1957
1962
  fkPkPairs.forEach((pair, j) => {
@@ -2270,13 +2275,6 @@ function cqn4sql(originalQuery, model) {
2270
2275
  }
2271
2276
  }
2272
2277
 
2273
- module.exports = Object.assign(cqn4sql, {
2274
- // for own tests only:
2275
- eqOps,
2276
- notEqOps,
2277
- notSupportedOps,
2278
- })
2279
-
2280
2278
  function calculateElementName(token) {
2281
2279
  const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
2282
2280
  let name
@@ -2364,3 +2362,10 @@ const refWithConditions = step => {
2364
2362
  return appendix ? step.id + appendix : step
2365
2363
  }
2366
2364
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
2365
+
2366
+ module.exports = Object.assign(cqn4sql, {
2367
+ // for own tests only:
2368
+ eqOps,
2369
+ notEqOps,
2370
+ notSupportedOps,
2371
+ })
@@ -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) => {
@@ -4,6 +4,7 @@ const cds = require('@sap/cds')
4
4
 
5
5
  const JoinTree = require('./join-tree')
6
6
  const { pseudos } = require('./pseudos')
7
+ const { isCalculatedOnRead } = require('../utils')
7
8
  const cdsTypes = cds.linked({
8
9
  definitions: {
9
10
  Timestamp: { type: 'cds.Timestamp' },
@@ -170,7 +171,8 @@ function infer(originalQuery, model) {
170
171
  if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
171
172
  if (!ref) return
172
173
  init$refLinks(arg)
173
- ref.forEach((step, i) => {
174
+ let i = 0
175
+ for (const step of ref) {
174
176
  const id = step.id || step
175
177
  if (i === 0) {
176
178
  // infix filter never have table alias
@@ -231,7 +233,8 @@ function infer(originalQuery, model) {
231
233
  step.where.forEach(walkTokenStream)
232
234
  } else throw new Error('A filter can only be provided when navigating along associations')
233
235
  }
234
- })
236
+ i += 1
237
+ }
235
238
  const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
236
239
  if (definition.value) {
237
240
  // nested calculated element
@@ -744,7 +747,7 @@ function infer(originalQuery, model) {
744
747
  joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
745
748
  }
746
749
  }
747
- if (leafArt.value && !leafArt.value.stored) {
750
+ if (isCalculatedOnRead(leafArt)) {
748
751
  linkCalculatedElement(column, $baseLink, baseColumn)
749
752
  }
750
753
 
@@ -1046,15 +1049,17 @@ function infer(originalQuery, model) {
1046
1049
  if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
1047
1050
  const { elements } = getDefinitionFromSources(sources, aliases[0])
1048
1051
  // only one query source and no overwritten columns
1049
- Object.keys(elements)
1050
- .filter(k => !exclude(k))
1051
- .forEach(k => {
1052
+ for (const k of Object.keys(elements)) {
1053
+ if (!exclude(k)) {
1052
1054
  const element = elements[k]
1053
- if (element.type !== 'cds.LargeBinary') queryElements[k] = element
1054
- if (element.value) {
1055
+ if (element.type !== 'cds.LargeBinary') {
1056
+ queryElements[k] = element
1057
+ }
1058
+ if (isCalculatedOnRead(element)) {
1055
1059
  linkCalculatedElement(element)
1056
1060
  }
1057
- })
1061
+ }
1062
+ }
1058
1063
  return
1059
1064
  }
1060
1065
 
@@ -1067,7 +1072,7 @@ function infer(originalQuery, model) {
1067
1072
  if (exclude(name) || name in queryElements) return true
1068
1073
  const element = tableAliases[0].tableAlias.elements[name]
1069
1074
  if (element.type !== 'cds.LargeBinary') queryElements[name] = element
1070
- if (element.value) {
1075
+ if (isCalculatedOnRead(element)) {
1071
1076
  linkCalculatedElement(element)
1072
1077
  }
1073
1078
  })
@@ -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,46 @@
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
+ /**
25
+ * Determines if a definition is calculated on read.
26
+ * - Stored calculated elements are not unfolded
27
+ * - Association like calculated elements have been re-written by the compiler
28
+ * they essentially behave like unmanaged associations as their calculations
29
+ * have been incorporated into an on-condition which is handled elsewhere
30
+ *
31
+ * @param {Object} def - The definition to check.
32
+ * @returns {boolean} - Returns true if the definition is calculated on read, otherwise false.
33
+ */
34
+ function isCalculatedOnRead(def) {
35
+ return isCalculatedElement(def) && !def.value.stored && !def.on
36
+ }
37
+ function isCalculatedElement(def) {
38
+ return def?.value
39
+ }
40
+
41
+ // export the function to be used in other modules
42
+ module.exports = {
43
+ prettyPrintRef,
44
+ isCalculatedOnRead,
45
+ isCalculatedElement
46
+ }
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.14.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": {
@@ -22,7 +22,7 @@
22
22
  "CHANGELOG.md"
23
23
  ],
24
24
  "scripts": {
25
- "test": "jest --silent"
25
+ "test": "cds-test"
26
26
  },
27
27
  "dependencies": {
28
28
  "generic-pool": "^3.9.0"