@cap-js/db-service 1.14.0 → 1.15.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,28 @@
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.15.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.1...db-service-v1.15.0) (2024-11-14)
8
+
9
+
10
+ ### Added
11
+
12
+ * enable path expressions in infix filter after `exists` predicate ([#875](https://github.com/cap-js/cds-dbs/issues/875)) ([7e50359](https://github.com/cap-js/cds-dbs/commit/7e5035932ac3bf39f052aa67e1565567e9d6b1ad))
13
+
14
+
15
+ ### Fixed
16
+
17
+ * **`search`:** ignore invalid path expressions inside `@cds.search` ([#849](https://github.com/cap-js/cds-dbs/issues/849)) ([250edd5](https://github.com/cap-js/cds-dbs/commit/250edd5ec9f7ba1d8e40e1330e4b4f9ad9e599b0))
18
+ * nested exists wrapped in xpr ([7e50359](https://github.com/cap-js/cds-dbs/commit/7e5035932ac3bf39f052aa67e1565567e9d6b1ad))
19
+
20
+ ## [1.14.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.0...db-service-v1.14.1) (2024-10-28)
21
+
22
+
23
+ ### Fixed
24
+
25
+ * deep delete for views ([#496](https://github.com/cap-js/cds-dbs/issues/496)) ([82154ef](https://github.com/cap-js/cds-dbs/commit/82154ef8b837f17e81e2516056e03ff215f1dff8))
26
+ * properly support `default`, `cds.on.insert` and `cds.on.update` for `UPSERT` queries ([#425](https://github.com/cap-js/cds-dbs/issues/425)) ([338e9f5](https://github.com/cap-js/cds-dbs/commit/338e9f5de9109d36013208547fc648c17ce8c7b0))
27
+ * SELECT cds.hana.BINARY ([#870](https://github.com/cap-js/cds-dbs/issues/870)) ([33c3ebe](https://github.com/cap-js/cds-dbs/commit/33c3ebe84be4c0181b1c230d5f2d332332201ce0))
28
+
7
29
  ## [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
30
 
9
31
 
package/lib/SQLService.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const cds = require('@sap/cds'),
2
2
  DEBUG = cds.debug('sql|db')
3
3
  const { Readable } = require('stream')
4
- const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
4
+ const { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
5
5
  const DatabaseService = require('./common/DatabaseService')
6
6
  const cqn4sql = require('./cqn4sql')
7
7
 
@@ -25,14 +25,16 @@ class SQLService extends DatabaseService {
25
25
  this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
26
26
  if (cds.env.features.db_strict) {
27
27
  this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => {
28
- const elements = query.target?.elements; if (!elements) return
28
+ const elements = query.target?.elements
29
+ if (!elements) return
29
30
  const kind = query.kind || Object.keys(query)[0]
30
31
  const operation = query[kind]
31
32
  if (!operation.columns && !operation.entries && !operation.data) return
32
33
  const columns =
33
34
  operation.columns ||
34
35
  Object.keys(
35
- operation.data || operation.entries?.reduce((acc, obj) => {
36
+ operation.data ||
37
+ operation.entries?.reduce((acc, obj) => {
36
38
  return Object.assign(acc, obj)
37
39
  }, {}),
38
40
  )
@@ -214,7 +216,31 @@ class SQLService extends DatabaseService {
214
216
  // REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
215
217
  return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
216
218
  async function deep_delete(/** @type {Request} */ req) {
217
- let { compositions } = req.target
219
+ const transitions = getTransition(req.target, this, false, req.query.cmd || 'DELETE')
220
+ if (transitions.target !== transitions.queryTarget) {
221
+ const keys = []
222
+ const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
223
+ for (const key in transitionsTarget) {
224
+ const exists = e => e && !e.virtual && !e.value && !e.isAssociation
225
+ if (exists(transitionsTarget[key])) keys.push(key)
226
+ }
227
+ const matchedKeys = keys.filter(key => transitions.mapping.has(key)).map(k => ({ ref: [k] }))
228
+ const query = DELETE.from({
229
+ ref: [
230
+ {
231
+ id: transitions.target.name,
232
+ where: [
233
+ { list: matchedKeys.map(k => transitions.mapping.get(k.ref[0])) },
234
+ 'in',
235
+ SELECT.from(req.query.DELETE.from).columns(matchedKeys).where(req.query.DELETE.where),
236
+ ],
237
+ },
238
+ ],
239
+ })
240
+ return this.onDELETE({ query, target: transitions.target })
241
+ }
242
+ const table = getDBTable(req.target)
243
+ const { compositions } = table
218
244
  if (compositions) {
219
245
  // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
220
246
  let { from, where } = req.query.DELETE
@@ -241,6 +267,7 @@ class SQLService extends DatabaseService {
241
267
  )
242
268
  // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
243
269
  const query = DELETE.from({ ref: [...from.ref, c.name] })
270
+ query.target = c._target
244
271
  return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
245
272
  }),
246
273
  )
package/lib/cqn2sql.js CHANGED
@@ -4,12 +4,6 @@ const cqn4sql = require('./cqn4sql')
4
4
  const _simple_queries = cds.env.features.sql_simple_queries
5
5
  const _strict_booleans = _simple_queries < 2
6
6
 
7
- const BINARY_TYPES = {
8
- 'cds.Binary': 1,
9
- 'cds.LargeBinary': 1,
10
- 'cds.hana.BINARY': 1,
11
- }
12
-
13
7
  const { Readable } = require('stream')
14
8
 
15
9
  const DEBUG = (() => {
@@ -42,6 +36,12 @@ class CQN2SQLRenderer {
42
36
  }
43
37
  }
44
38
 
39
+ BINARY_TYPES = {
40
+ 'cds.Binary': 1,
41
+ 'cds.LargeBinary': 1,
42
+ 'cds.hana.BINARY': 1,
43
+ }
44
+
45
45
  static _add_mixins(aspect, mixins) {
46
46
  const fqn = this.name + aspect
47
47
  const types = cds.builtin.types
@@ -142,13 +142,15 @@ class CQN2SQLRenderer {
142
142
  */
143
143
  CREATE_elements(elements) {
144
144
  let sql = ''
145
+ let keys = ''
145
146
  for (let e in elements) {
146
147
  const definition = elements[e]
147
148
  if (definition.isAssociation) continue
149
+ if (definition.key) keys = `${keys}, ${this.quote(definition.name)}`
148
150
  const s = this.CREATE_element(definition)
149
- if (s) sql += `${s}, `
151
+ if (s) sql += `, ${s}`
150
152
  }
151
- return sql.slice(0, -2)
153
+ return `${sql.slice(2)}${keys && `, PRIMARY KEY(${keys.slice(2)})`}`
152
154
  }
153
155
 
154
156
  /**
@@ -491,8 +493,6 @@ class CQN2SQLRenderer {
491
493
  */
492
494
  INSERT_entries(q) {
493
495
  const { INSERT } = q
494
- const entity = this.name(q.target?.name || INSERT.into.ref[0])
495
- const alias = INSERT.into.as
496
496
  const elements = q.elements || q.target?.elements
497
497
  if (!elements && !INSERT.entries?.length) {
498
498
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
@@ -504,19 +504,14 @@ class CQN2SQLRenderer {
504
504
  /** @type {string[]} */
505
505
  this.columns = columns
506
506
 
507
+ const alias = INSERT.into.as
508
+ const entity = this.name(q.target?.name || INSERT.into.ref[0])
507
509
  if (!elements) {
508
510
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
509
511
  const param = this.param.bind(this, { ref: ['?'] })
510
512
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
511
513
  }
512
514
 
513
- const extractions = this.managed(
514
- columns.map(c => ({ name: c })),
515
- elements,
516
- !!q.UPSERT,
517
- )
518
- const extraction = extractions.map(c => c.sql)
519
-
520
515
  // Include this.values for placeholders
521
516
  /** @type {unknown[][]} */
522
517
  this.entries = []
@@ -530,8 +525,9 @@ class CQN2SQLRenderer {
530
525
  this.entries = [[...this.values, stream]]
531
526
  }
532
527
 
528
+ const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
533
529
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
534
- }) SELECT ${extraction} FROM json_each(?)`)
530
+ }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
535
531
  }
536
532
 
537
533
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -569,7 +565,7 @@ class CQN2SQLRenderer {
569
565
 
570
566
  buffer += '"'
571
567
  } else {
572
- if (elements[key]?.type in BINARY_TYPES) {
568
+ if (elements[key]?.type in this.BINARY_TYPES) {
573
569
  val = transformBase64(val)
574
570
  }
575
571
  buffer += `${keyJSON}${JSON.stringify(val)}`
@@ -617,7 +613,7 @@ class CQN2SQLRenderer {
617
613
 
618
614
  buffer += '"'
619
615
  } else {
620
- if (elements[this.columns[key]]?.type in BINARY_TYPES) {
616
+ if (elements[this.columns[key]]?.type in this.BINARY_TYPES) {
621
617
  val = transformBase64(val)
622
618
  }
623
619
  buffer += `${sepsub}${val === undefined ? 'null' : JSON.stringify(val)}`
@@ -646,18 +642,7 @@ class CQN2SQLRenderer {
646
642
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
647
643
  const alias = INSERT.into.as
648
644
  const elements = q.elements || q.target?.elements
649
- const columns = INSERT.columns
650
- || cds.error`Cannot insert rows without columns or elements`
651
-
652
- const inputConverter = this.class._convertInput
653
- const extraction = columns.map((c, i) => {
654
- const extract = `value->>'$[${i}]'`
655
- const element = elements?.[c]
656
- const converter = element?.[inputConverter]
657
- return converter?.(extract, element) || extract
658
- })
659
-
660
- this.columns = columns
645
+ const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
661
646
 
662
647
  if (!elements) {
663
648
  this.entries = INSERT.rows
@@ -675,6 +660,10 @@ class CQN2SQLRenderer {
675
660
  this.entries = [[...this.values, stream]]
676
661
  }
677
662
 
663
+ const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
664
+ .slice(0, columns.length)
665
+ .map(c => c.converter(c.extract))
666
+
678
667
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
679
668
  }) SELECT ${extraction} FROM json_each(?)`)
680
669
  }
@@ -686,7 +675,7 @@ class CQN2SQLRenderer {
686
675
  */
687
676
  INSERT_values(q) {
688
677
  let { columns, values } = q.INSERT
689
- return this.INSERT_rows({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
678
+ return this.render({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
690
679
  }
691
680
 
692
681
  /**
@@ -737,14 +726,37 @@ class CQN2SQLRenderer {
737
726
  */
738
727
  UPSERT(q) {
739
728
  const { UPSERT } = q
740
- const elements = q.target?.elements || {}
729
+
741
730
  let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
742
- let keys = q.target?.keys
743
- if (!keys) return this.sql = sql
744
- keys = Object.keys(keys).filter(k => !keys[k].isAssociation && !keys[k].virtual)
731
+ if (!q.target?.keys) return sql
732
+ const keys = []
733
+ for (const k of ObjectKeys(q.target?.keys)) {
734
+ const element = q.target.keys[k]
735
+ if (element.isAssociation || element.virtual) continue
736
+ keys.push(k)
737
+ }
738
+
739
+ const elements = q.target?.elements || {}
740
+ // temporal data
741
+ for (const k of ObjectKeys(elements)) {
742
+ if (elements[k]['@cds.valid.from']) keys.push(k)
743
+ }
744
+
745
+ const keyCompare = keys
746
+ .map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
747
+ .join(' AND ')
748
+
749
+ const columns = this.columns // this.columns is computed as part of this.INSERT
750
+ const managed = this._managed.slice(0, columns.length)
751
+
752
+ const extractkeys = managed
753
+ .filter(c => keys.includes(c.name))
754
+ .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
745
755
 
746
- let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
747
- updateColumns = updateColumns.filter(c => {
756
+ const entity = this.name(q.target?.name || UPSERT.into.ref[0])
757
+ sql = `SELECT ${managed.map(c => c.upsert)} FROM (SELECT value, ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
758
+
759
+ const updateColumns = columns.filter(c => {
748
760
  if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
749
761
  let e = elements[c]
750
762
  if (!e) return true //> pass through to native SQL columns not in CDS model
@@ -754,14 +766,8 @@ class CQN2SQLRenderer {
754
766
  else return true
755
767
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
756
768
 
757
- // temporal data
758
- keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
759
-
760
- keys = keys.map(k => this.quote(k))
761
- const conflict = updateColumns.length
762
- ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
763
- : `ON CONFLICT(${keys}) DO NOTHING`
764
- return (this.sql = `${sql} WHERE true ${conflict}`)
769
+ return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
770
+ } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
765
771
  }
766
772
 
767
773
  // UPDATE Statements ------------------------------------------------
@@ -790,7 +796,9 @@ class CQN2SQLRenderer {
790
796
  }
791
797
  }
792
798
 
793
- const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
799
+ const extraction = this.managed(columns, elements)
800
+ .filter((c, i) => columns[i] || c.onUpdate)
801
+ .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
794
802
 
795
803
  sql += ` SET ${extraction}`
796
804
  if (where) sql += ` WHERE ${this.where(where)}`
@@ -1042,56 +1050,104 @@ class CQN2SQLRenderer {
1042
1050
  }
1043
1051
 
1044
1052
  /**
1045
- * Convers the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
1053
+ * Converts the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
1046
1054
  * @param {object[]} columns
1047
1055
  * @param {import('./infer/cqn').elements} elements
1048
1056
  * @param {Boolean} isUpdate
1049
1057
  * @returns {string[]} Array of SQL expressions for processing input JSON data
1050
1058
  */
1051
- managed(columns, elements, isUpdate = false) {
1052
- const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
1059
+ managed(columns, elements) {
1060
+ const cdsOnInsert = '@cds.on.insert'
1061
+ const cdsOnUpdate = '@cds.on.update'
1062
+
1053
1063
  const { _convertInput } = this.class
1054
1064
  // Ensure that missing managed columns are added
1055
1065
  const requiredColumns = !elements
1056
1066
  ? []
1057
- : Object.keys(elements)
1058
- .filter(
1059
- e =>
1060
- (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
1061
- !columns.find(c => c.name === e),
1062
- )
1067
+ : ObjectKeys(elements)
1068
+ .filter(e => {
1069
+ const element = elements[e]
1070
+ // Actual mandatory check
1071
+ if (!(element.default || element[cdsOnInsert] || element[cdsOnUpdate])) return false
1072
+ // Physical column check
1073
+ if (!element || element.virtual || element.isAssociation) return false
1074
+ // Existence check
1075
+ if (columns.find(c => c.name === e)) return false
1076
+ return true
1077
+ })
1063
1078
  .map(name => ({ name, sql: 'NULL' }))
1064
1079
 
1080
+ const keys = ObjectKeys(elements).filter(e => elements[e].key && !elements[e].isAssociation)
1081
+ const keyZero = keys[0] && this.quote(keys[0])
1082
+
1065
1083
  return [...columns, ...requiredColumns].map(({ name, sql }) => {
1066
- let element = elements?.[name] || {}
1067
- if (!sql) sql = `value->>'$."${name}"'`
1068
-
1069
- let converter = element[_convertInput]
1070
- if (converter && sql[0] !== '$') sql = converter(sql, element)
1071
-
1072
- let val = _managed[element[annotation]?.['=']]
1073
- if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val, param: false }] })})`
1074
- else if (!isUpdate && element.default) {
1075
- const d = element.default
1076
- if (d.val !== undefined || d.ref?.[0] === '$now') {
1077
- // REVISIT: d.ref is not used afterwards
1078
- sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
1079
- } ELSE ${sql} END)`
1080
- }
1084
+ const element = elements?.[name] || {}
1085
+
1086
+ const converter = a => element[_convertInput]?.(a, element) || a
1087
+ let extract
1088
+ if (!sql) {
1089
+ ({ sql, extract } = this.managed_extract(name, element, converter))
1090
+ } else {
1091
+ extract = sql = converter(sql)
1081
1092
  }
1093
+ // if (sql[0] !== '$') sql = converter(sql, element)
1094
+
1095
+ let onInsert = this.managed_session_context(element[cdsOnInsert]?.['='])
1096
+ || this.managed_session_context(element.default?.ref?.[0])
1097
+ || (element.default?.val !== undefined && { val: element.default.val, param: false })
1098
+ let onUpdate = this.managed_session_context(element[cdsOnUpdate]?.['='])
1099
+
1100
+ if (onInsert) onInsert = this.expr(onInsert)
1101
+ if (onUpdate) onUpdate = this.expr(onUpdate)
1102
+
1103
+ const qname = this.quote(name)
1104
+
1105
+ const insert = onInsert ? this.managed_default(name, converter(onInsert), sql) : sql
1106
+ const update = onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql
1107
+ const upsert = keyZero && (
1108
+ // upsert requires the keys to be provided for the existance join (default values optional)
1109
+ element.key
1110
+ // If both insert and update have the same managed definition exclude the old value check
1111
+ || (onInsert && onUpdate && insert === update)
1112
+ ? `${insert} as ${qname}`
1113
+ : `CASE WHEN OLD.${keyZero} IS NULL THEN ${
1114
+ // If key of old is null execute insert
1115
+ insert
1116
+ } ELSE ${
1117
+ // Else execute managed update or keep old if no new data if provided
1118
+ onUpdate ? update : this.managed_default(name, `OLD.${qname}`, update)
1119
+ } END as ${qname}`
1120
+ )
1082
1121
 
1083
- return { name, sql }
1122
+ return {
1123
+ name, // Element name
1124
+ sql, // Reference SQL
1125
+ extract, // Source SQL
1126
+ converter, // Converter logic
1127
+ // action specific full logic
1128
+ insert, update, upsert,
1129
+ // action specific isolated logic
1130
+ onInsert, onUpdate
1131
+ }
1084
1132
  })
1085
1133
  }
1086
1134
 
1087
- /**
1088
- * Returns the default value
1089
- * @param {string} defaultValue
1090
- * @returns {string}
1091
- */
1092
- // REVISIT: This is a strange method, also overridden inconsistently in postgres
1093
- defaultValue(defaultValue = this.context.timestamp.toISOString()) {
1094
- return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
1135
+ managed_extract(name, element, converter) {
1136
+ const { UPSERT, INSERT } = this.cqn
1137
+ const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)
1138
+ ? `value->>'$[${this.columns.indexOf(name)}]'`
1139
+ : `value->>'$."${name.replace(/"/g, '""')}"'`
1140
+ const sql = converter?.(extract) || extract
1141
+ return { extract, sql }
1142
+ }
1143
+
1144
+ managed_session_context(src) {
1145
+ const val = _managed[src]
1146
+ return val && { func: 'session_context', args: [{ val, param: false }] }
1147
+ }
1148
+
1149
+ managed_default(name, managed, src) {
1150
+ return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)`
1095
1151
  }
1096
1152
  }
1097
1153
 
package/lib/cqn4sql.js CHANGED
@@ -266,10 +266,10 @@ function cqn4sql(originalQuery, model) {
266
266
  const id = localized(r.queryArtifact)
267
267
  args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
268
268
  }
269
- from = { join: 'left', args, on: [] }
269
+ from = { join: r.join || 'left', args, on: [] }
270
270
  r.children.forEach(c => {
271
271
  from = joinForBranch(from, c)
272
- from = { join: 'left', args: [from], on: [] }
272
+ from = { join: c.join || 'left', args: [from], on: [] }
273
273
  })
274
274
  })
275
275
  return from.args.length > 1 ? from : from.args[0]
@@ -309,7 +309,7 @@ function cqn4sql(originalQuery, model) {
309
309
  }
310
310
  if (node.children) {
311
311
  node.children.forEach(c => {
312
- lhs = { join: 'left', args: [lhs], on: [] }
312
+ lhs = { join: c.join || 'left', args: [lhs], on: [] }
313
313
  lhs = joinForBranch(lhs, c)
314
314
  })
315
315
  }
@@ -2093,11 +2093,6 @@ function cqn4sql(originalQuery, model) {
2093
2093
  const unmanagedOn = onCondFor(inWhere ? next : current, inWhere ? current : next, inWhere)
2094
2094
  on.push(...(customWhere && hasLogicalOr(unmanagedOn) ? [asXpr(unmanagedOn)] : unmanagedOn))
2095
2095
  }
2096
- // infix filter conditions are wrapped in `xpr` when added to the on-condition
2097
- if (customWhere) {
2098
- const filter = getTransformedTokenStream(customWhere, next)
2099
- on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2100
- }
2101
2096
 
2102
2097
  const subquerySource = assocTarget(nextDefinition) || nextDefinition
2103
2098
  const id = localized(subquerySource)
@@ -2115,6 +2110,26 @@ function cqn4sql(originalQuery, model) {
2115
2110
  ],
2116
2111
  where: on,
2117
2112
  }
2113
+ if (next.pathExpressionInsideFilter) {
2114
+ SELECT.where = customWhere
2115
+ const transformedExists = transformSubquery({ SELECT })
2116
+ // infix filter conditions are wrapped in `xpr` when added to the on-condition
2117
+ if (transformedExists.SELECT.where) {
2118
+ on.push(
2119
+ ...[
2120
+ 'and',
2121
+ ...(hasLogicalOr(transformedExists.SELECT.where)
2122
+ ? [asXpr(transformedExists.SELECT.where)]
2123
+ : transformedExists.SELECT.where),
2124
+ ],
2125
+ )
2126
+ }
2127
+ transformedExists.SELECT.where = on
2128
+ return transformedExists.SELECT
2129
+ } else if (customWhere) {
2130
+ const filter = getTransformedTokenStream(customWhere, next)
2131
+ on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2132
+ }
2118
2133
  return SELECT
2119
2134
  }
2120
2135
 
@@ -184,16 +184,13 @@ function infer(originalQuery, model) {
184
184
  if (e.target) {
185
185
  // only fk access in infix filter
186
186
  const nextStep = ref[1]?.id || ref[1]
187
- // no unmanaged assoc in infix filter path
188
- if (!expandOrExists && e.on) {
189
- const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”`
190
- throw new Error(err)
187
+ if (isNonForeignKeyNavigation(e, nextStep)) {
188
+ if (expandOrExists) {
189
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
190
+ } else {
191
+ rejectNonFkNavigation(e, e.on ? $baseLink.definition.name : nextStep)
192
+ }
191
193
  }
192
- // no non-fk traversal in infix filter
193
- if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e))
194
- throw new Error(
195
- `Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`,
196
- )
197
194
  }
198
195
  arg.$refLinks.push({ definition: e, target: definition })
199
196
  // filter paths are flattened
@@ -226,7 +223,7 @@ function infer(originalQuery, model) {
226
223
  // don't miss an exists within an expression
227
224
  token.xpr.forEach(walkTokenStream)
228
225
  } else {
229
- attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate)
226
+ attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate || expandOrExists)
230
227
  existsPredicate = false
231
228
  }
232
229
  }
@@ -235,6 +232,7 @@ function infer(originalQuery, model) {
235
232
  }
236
233
  i += 1
237
234
  }
235
+ if ($baseLink?.pathExpressionInsideFilter) Object.defineProperty(arg, 'join', { value: 'inner' })
238
236
  const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
239
237
  if (definition.value) {
240
238
  // nested calculated element
@@ -542,9 +540,19 @@ function infer(originalQuery, model) {
542
540
  const elements = getDefinition(definition.target)?.elements || definition.elements
543
541
  if (elements && id in elements) {
544
542
  const element = elements[id]
545
- rejectNonFkAccess(element)
543
+ if (inInfixFilter) {
544
+ const nextStep = column.ref[1]?.id || column.ref[1]
545
+ if (isNonForeignKeyNavigation(element, nextStep)) {
546
+ if (inExists) {
547
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
548
+ } else {
549
+ rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
550
+ }
551
+ }
552
+ }
546
553
  const resolvableIn = getDefinition(definition.target) || target
547
- column.$refLinks.push({ definition: elements[id], target: resolvableIn })
554
+ const $refLink = { definition: elements[id], target: resolvableIn }
555
+ column.$refLinks.push($refLink)
548
556
  } else {
549
557
  stepNotFoundInPredecessor(id, definition.name)
550
558
  }
@@ -593,7 +601,16 @@ function infer(originalQuery, model) {
593
601
 
594
602
  const target = getDefinition(definition.target) || column.$refLinks[i - 1].target
595
603
  if (element) {
596
- if ($baseLink) rejectNonFkAccess(element)
604
+ if ($baseLink && inInfixFilter) {
605
+ const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
606
+ if (isNonForeignKeyNavigation(element, nextStep)) {
607
+ if (inExists) {
608
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
609
+ } else {
610
+ rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
611
+ }
612
+ }
613
+ }
597
614
  const $refLink = { definition: elements[id], target }
598
615
  column.$refLinks.push($refLink)
599
616
  } else if (firstStepIsSelf) {
@@ -637,7 +654,7 @@ function infer(originalQuery, model) {
637
654
  skipJoinsForFilter = true
638
655
  } else if (token.ref || token.xpr) {
639
656
  inferQueryElement(token, false, column.$refLinks[i], {
640
- inExists: skipJoinsForFilter,
657
+ inExists: skipJoinsForFilter || inExists,
641
658
  inExpr: !!token.xpr,
642
659
  inInfixFilter: true,
643
660
  })
@@ -646,7 +663,7 @@ function infer(originalQuery, model) {
646
663
  applyToFunctionArgs(token.args, inferQueryElement, [
647
664
  false,
648
665
  column.$refLinks[i],
649
- { inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: true },
666
+ { inExists: skipJoinsForFilter || inExists, inExpr: true, inInfixFilter: true },
650
667
  ])
651
668
  }
652
669
  }
@@ -700,31 +717,11 @@ function infer(originalQuery, model) {
700
717
  }
701
718
  }
702
719
  }
703
-
704
- /**
705
- * Check if the next step in the ref is foreign key of `assoc`
706
- * if not, an error is thrown.
707
- *
708
- * @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element.
709
- */
710
- function rejectNonFkAccess(assoc) {
711
- if (inInfixFilter && assoc.target) {
712
- // only fk access in infix filter
713
- const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
714
- // no unmanaged assoc in infix filter path
715
- if (!inExists && assoc.on) {
716
- const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”`
717
- throw new Error(err)
718
- }
719
- // no non-fk traversal in infix filter in non-exists path
720
- if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc))
721
- throw new Error(
722
- `Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`,
723
- )
724
- }
725
- }
726
720
  })
727
721
 
722
+ // we need inner joins for the path expressions inside filter expressions after exists predicate
723
+ if ($baseLink?.pathExpressionInsideFilter) Object.defineProperty(column, 'join', { value: 'inner' })
724
+
728
725
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
729
726
  if (column.expand) {
730
727
  const { $refLinks } = column
@@ -1214,6 +1211,26 @@ function infer(originalQuery, model) {
1214
1211
  }
1215
1212
  }
1216
1213
 
1214
+ /**
1215
+ * Determines if a given association is a non-foreign key navigation.
1216
+ *
1217
+ * @param {Object} assoc - The association.
1218
+ * @param {Object} nextStep - The next step in the navigation path.
1219
+ * @returns {boolean} - Returns true if the next step is a non-foreign key navigation, otherwise false.
1220
+ */
1221
+ function isNonForeignKeyNavigation(assoc, nextStep) {
1222
+ if (!nextStep || !assoc.target) return false
1223
+
1224
+ return assoc.on || !isForeignKeyOf(nextStep, assoc)
1225
+ }
1226
+
1227
+ function rejectNonFkNavigation(assoc, additionalInfo) {
1228
+ if (assoc.on) {
1229
+ throw new Error(`Unexpected unmanaged association “${assoc.name}” in filter expression of “${additionalInfo}”`)
1230
+ }
1231
+ throw new Error(`Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${additionalInfo}”`)
1232
+ }
1233
+
1217
1234
  /**
1218
1235
  * Returns true if e is a foreign key of assoc.
1219
1236
  * this function is also compatible with unfolded csn (UCSN),
@@ -181,6 +181,7 @@ class JoinTree {
181
181
  // if no root node was found, the column is selected from a subquery
182
182
  if (!node) return
183
183
  while (i < col.ref.length) {
184
+ if(col.join === 'inner') node.join = 'inner'
184
185
  const step = col.ref[i]
185
186
  const { where, args } = step
186
187
  const id = joinId(step, args, where)
package/lib/search.js CHANGED
@@ -114,7 +114,7 @@ const _getSearchableColumns = entity => {
114
114
  deepSearchCandidates.forEach(c => {
115
115
  const element = c.ref.reduce((resolveIn, curr, i) => {
116
116
  const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
117
- if (next.isAssociation && !c.ref[i + 1]) {
117
+ if (next?.isAssociation && !c.ref[i + 1]) {
118
118
  const searchInTarget = _getSearchableColumns(next._target)
119
119
  searchInTarget.forEach(elementRefInTarget => {
120
120
  searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.14.0",
3
+ "version": "1.15.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": {