@cap-js/db-service 2.5.0 → 2.6.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,27 @@
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
+ ## [2.6.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.1...db-service-v2.6.0) (2025-10-23)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`flattening`:** allow flattening of `n`-ary structures in `list` ([#1337](https://github.com/cap-js/cds-dbs/issues/1337)) ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
13
+ * **`flattening`:** allow flattening of structures with exactly one leaf ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`@cds.search`:** properly exclude an association from being searched ([#1385](https://github.com/cap-js/cds-dbs/issues/1385)) ([9ed4245](https://github.com/cap-js/cds-dbs/commit/9ed42458417c15dc33409befd0ef40889f04a69f))
19
+ * tree table with expand ([#1363](https://github.com/cap-js/cds-dbs/issues/1363)) ([bdad412](https://github.com/cap-js/cds-dbs/commit/bdad412f0362165b532ce35261773e5ecc7c696a))
20
+
21
+ ## [2.5.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.0...db-service-v2.5.1) (2025-09-30)
22
+
23
+
24
+ ### Fixed
25
+
26
+ * revert own resolve ([#1366](https://github.com/cap-js/cds-dbs/issues/1366)) ([9037570](https://github.com/cap-js/cds-dbs/commit/9037570c5dda08eb8bc168c0a68045ef9fc85a9f))
27
+
7
28
  ## [2.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.4.0...db-service-v2.5.0) (2025-09-30)
8
29
 
9
30
 
package/lib/SQLService.js CHANGED
@@ -2,7 +2,7 @@ const cds = require('@sap/cds'),
2
2
  DEBUG = cds.debug('sql|db')
3
3
  const { Readable, Transform } = require('stream')
4
4
  const { pipeline } = require('stream/promises')
5
- const { getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
5
+ const { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
6
6
  const DatabaseService = require('./common/DatabaseService')
7
7
  const cqn4sql = require('./cqn4sql')
8
8
 
@@ -230,9 +230,7 @@ class SQLService extends DatabaseService {
230
230
  // REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
231
231
  return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
232
232
  async function deep_delete(/** @type {Request} */ req) {
233
- const resolve = this.resolve
234
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
235
- const transitions = resolve?.transitions4db ? resolve.transitions4db(req.query, false) : getTransition(req.target, this, false, req.query.cmd || 'DELETE')
233
+ const transitions = getTransition(req.target, this, false, req.query.cmd || 'DELETE')
236
234
  if (transitions.target !== transitions.queryTarget) {
237
235
  const keys = []
238
236
  const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
@@ -255,8 +253,7 @@ class SQLService extends DatabaseService {
255
253
  })
256
254
  return this.onDELETE({ query, target: transitions.target })
257
255
  }
258
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
259
- const table = resolve?.table ? resolve.table(req.target) : getDBTable(req.target)
256
+ const table = getDBTable(req.target)
260
257
  const { compositions } = table
261
258
  if (compositions) {
262
259
  // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
@@ -407,6 +404,10 @@ class SQLService extends DatabaseService {
407
404
  */
408
405
  cqn2sql(query, values) {
409
406
  let q = this.cqn4sql(query)
407
+ let kind = q.kind || Object.keys(q)[0]
408
+ if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
409
+ q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
410
+ }
410
411
  let cqn2sql = new this.class.CQN2SQL(this)
411
412
  return cqn2sql.render(q, values)
412
413
  }
package/lib/cqn2sql.js CHANGED
@@ -1,7 +1,6 @@
1
1
  const cds = require('@sap/cds')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
- const { getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
5
4
  const _simple_queries = cds.env.features.sql_simple_queries
6
5
  const _strict_booleans = _simple_queries < 2
7
6
 
@@ -27,7 +26,7 @@ class CQN2SQLRenderer {
27
26
  if (cds.env.sql.names === 'quoted') {
28
27
  this.class.prototype.name = (name, query) => {
29
28
  const e = name.id || name
30
- return (this.model?.definitions[e])?.['@cds.persistence.name'] || e || query?._target
29
+ return (query?._target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
31
30
  }
32
31
  this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
33
32
  }
@@ -91,6 +90,7 @@ class CQN2SQLRenderer {
91
90
  if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
92
91
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
93
92
 
93
+
94
94
  if (DEBUG && (LOG_SQL._debug || LOG_SQLITE._debug)) {
95
95
  let values = sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []
96
96
  if (values && !Array.isArray(values)) {
@@ -350,7 +350,13 @@ class CQN2SQLRenderer {
350
350
  if (element['@Core.Computed'] && name in availableComputedColumns) continue
351
351
  if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
352
352
  columnsIn.push(ref)
353
- if (from.args || columnsFiltered.find(c => this.column_name(c) === name)) {
353
+ const foreignkey4 = element._foreignKey4
354
+ if (
355
+ from.args ||
356
+ columnsFiltered.find(c => this.column_name(c) === name) ||
357
+ // foreignkey needs to be included when the association is expanded
358
+ (foreignkey4 && q.SELECT.columns.some(c => c.element?.isAssociation && c.element.name === foreignkey4))
359
+ ) {
354
360
  columnsOut.push(ref.as ? { ref: [ref.as], as: name } : ref)
355
361
  }
356
362
  }
@@ -722,49 +728,6 @@ class CQN2SQLRenderer {
722
728
  return this.xpr({ xpr })
723
729
  }
724
730
 
725
- /**
726
- * Renders a transformed where clause that maps the query target view to the source table
727
- * @param {import('./infer/cqn').source} from
728
- * @param {import('./infer/cqn').predicate} where
729
- * @param {import('./infer/cqn').query} q
730
- * @returns SQL
731
- */
732
- where_resolved(from, where, q) {
733
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
734
- const kind = q.kind || (
735
- q.SELECT ? 'SELECT' :
736
- q.INSERT ? 'INSERT' :
737
- q.UPSERT ? 'UPSERT' :
738
- q.UPDATE ? 'UPDATE' :
739
- q.DELETE ? 'DELETE' :
740
- undefined
741
- )
742
- const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, kind)
743
- if (transitions.target === transitions.queryTarget) return this.where(where)
744
-
745
- // view and table column refs to be matched
746
- const viewCols = []
747
- const tableCols = []
748
-
749
- // Only match key columns when possible
750
- const elements = q._target.keys || q._target.elements
751
- for (const c in elements) {
752
- if (
753
- c in elements
754
- && transitions.mapping.has(c)
755
- && !elements[c].virtual
756
- && !elements[c].value
757
- && !elements[c].isAssociation
758
- ) {
759
- viewCols.push({ ref: [c] })
760
- tableCols.push(transitions.mapping.get(c))
761
- }
762
- }
763
- return tableCols.length > 0
764
- ? this.where([{ list: tableCols }, 'in', SELECT.from(from).columns(viewCols).where(where)])
765
- : this.where(where)
766
- }
767
-
768
731
  /**
769
732
  * Renders a HAVING clause into generic SQL
770
733
  * @param {import('./infer/cqn').predicate} xpr
@@ -870,22 +833,15 @@ class CQN2SQLRenderer {
870
833
  if (!elements && !INSERT.entries?.length) {
871
834
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
872
835
  }
873
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
874
- const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'INSERT')
875
836
  const columns = elements
876
- ? ObjectKeys(elements).filter(c => (c = transitions.mapping.get(c)?.ref?.[0] || c)
877
- && c in transitions.target.elements
878
- && !transitions.target.elements[c].virtual
879
- && !transitions.target.elements[c].value
880
- && !transitions.target.elements[c].isAssociation
881
- )
837
+ ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation)
882
838
  : ObjectKeys(INSERT.entries[0])
883
839
 
884
840
  /** @type {string[]} */
885
841
  this.columns = columns
886
842
 
887
843
  const alias = INSERT.into.as
888
- const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
844
+ const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
889
845
  if (!elements) {
890
846
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
891
847
  const param = this.param.bind(this, { ref: ['?'] })
@@ -907,8 +863,8 @@ class CQN2SQLRenderer {
907
863
  }
908
864
 
909
865
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
910
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
911
- }) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`)
866
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
867
+ }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
912
868
  }
913
869
 
914
870
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -1014,7 +970,7 @@ class CQN2SQLRenderer {
1014
970
  */
1015
971
  INSERT_rows(q) {
1016
972
  const { INSERT } = q
1017
- const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
973
+ const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
1018
974
  const alias = INSERT.into.as
1019
975
  const elements = q.elements || q._target?.elements
1020
976
  const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
@@ -1038,10 +994,8 @@ class CQN2SQLRenderer {
1038
994
  const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
1039
995
  .slice(0, columns.length)
1040
996
  .map(c => c.converter(c.extract))
1041
-
1042
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
1043
- const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'INSERT')
1044
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
997
+
998
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
1045
999
  }) SELECT ${extraction} FROM json_each(?)`)
1046
1000
  }
1047
1001
 
@@ -1062,26 +1016,15 @@ class CQN2SQLRenderer {
1062
1016
  */
1063
1017
  INSERT_select(q) {
1064
1018
  const { INSERT } = q
1065
- const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1019
+ const entity = this.name(q._target.name, q)
1066
1020
  const alias = INSERT.into.as
1067
- const src = this.cqn4sql(INSERT.from)
1068
1021
  const elements = q.elements || q._target?.elements || {}
1069
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
1070
- const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q, this.srv) : getTransition(q._target, this.srv, false, 'INSERT')
1071
- let columns = (this.columns = (INSERT.columns || src.SELECT.columns?.map(c => this.column_name(c)) || ObjectKeys(src.elements) || ObjectKeys(elements))
1072
- .filter(c => (c = transitions.mapping.get(c)?.ref?.[0] || c)
1073
- && c in transitions.target.elements
1074
- && !transitions.target.elements[c].virtual
1075
- && !transitions.target.elements[c].value
1076
- && !transitions.target.elements[c].isAssociation
1077
- ))
1078
-
1079
- const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements)
1080
- const sql = extractions.length > columns.length
1081
- ? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW`
1082
- : this.SELECT(src)
1083
- if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
1084
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql}`
1022
+ const columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1023
+ c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
1024
+ ))
1025
+ this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${this.SELECT(
1026
+ this.cqn4sql(INSERT.from || INSERT.as),
1027
+ )}`
1085
1028
  this.entries = [this.values]
1086
1029
  return this.sql
1087
1030
  }
@@ -1141,7 +1084,7 @@ class CQN2SQLRenderer {
1141
1084
  .filter(c => keys.includes(c.name))
1142
1085
  .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
1143
1086
 
1144
- const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1087
+ const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1145
1088
  sql = `SELECT ${managed.map(c => c.upsert
1146
1089
  .replace(/value->/g, '"$$$$value$$$$"->')
1147
1090
  .replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
@@ -1157,9 +1100,7 @@ class CQN2SQLRenderer {
1157
1100
  else return true
1158
1101
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
1159
1102
 
1160
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
1161
- const transitions = this.srv.resolve.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'UPDATE')
1162
- return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql
1103
+ return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
1163
1104
  } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
1164
1105
  }
1165
1106
 
@@ -1172,37 +1113,29 @@ class CQN2SQLRenderer {
1172
1113
  */
1173
1114
  UPDATE(q) {
1174
1115
  const { entity, with: _with, data, where } = q.UPDATE
1175
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
1176
- const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'UPDATE')
1177
- const elements = q._target?.elements
1178
- let sql = `UPDATE ${this.quote(this.table_name(q))}`
1116
+ const elements = q._target?.elements
1117
+ let sql = `UPDATE ${this.quote(this.name(entity.ref?.[0] || entity, q))}`
1179
1118
  if (entity.as) sql += ` AS ${this.quote(entity.as)}`
1180
1119
 
1181
1120
  let columns = []
1182
1121
  if (data) _add(data, val => this.val({ val }))
1183
1122
  if (_with) _add(_with, x => this.expr(x))
1184
1123
  function _add(data, sql4) {
1185
- for (let col in data) {
1186
- const c = transitions.mapping.get(col)?.ref?.[0] || col
1187
- const columnExistsInDatabase = elements
1188
- && c in transitions.target.elements
1189
- && !transitions.target.elements[c].virtual
1190
- && !transitions.target.elements[c].value
1191
- && !transitions.target.elements[c].isAssociation
1124
+ for (let c in data) {
1125
+ const columnExistsInDatabase =
1126
+ elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
1192
1127
  if (!elements || columnExistsInDatabase) {
1193
- columns.push({ name: c, sql: sql4(data[col], col) })
1128
+ columns.push({ name: c, sql: sql4(data[c]) })
1194
1129
  }
1195
1130
  }
1196
1131
  }
1197
1132
 
1198
1133
  const extraction = this.managed(columns, elements)
1199
- .filter((c, i) => {
1200
- if(transitions.mapping.get(c.name)?.ref?.length > 1) return false
1201
- return columns[i] || c.onUpdate
1202
- }).map((c, i) => `${this.quote(transitions.mapping.get(c.name)?.ref?.[0] || c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1134
+ .filter((c, i) => columns[i] || c.onUpdate)
1135
+ .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1203
1136
 
1204
1137
  sql += ` SET ${extraction}`
1205
- if (where) sql += ` WHERE ${this.where_resolved(entity, where, q)}`
1138
+ if (where) sql += ` WHERE ${this.where(where)}`
1206
1139
  return (this.sql = sql)
1207
1140
  }
1208
1141
 
@@ -1214,9 +1147,8 @@ class CQN2SQLRenderer {
1214
1147
  * @returns {string} SQL
1215
1148
  */
1216
1149
  DELETE(q) {
1217
- const { DELETE: { where, from } } = q
1218
- let sql = `DELETE FROM ${this.quote(this.table_name(q))}`
1219
- if (from.as) sql += ` AS ${this.quote(from.as)}`
1150
+ const { DELETE: { from, where } } = q
1151
+ let sql = `DELETE FROM ${this.from(from, q)}`
1220
1152
  if (where) sql += ` WHERE ${this.where(where)}`
1221
1153
  return (this.sql = sql)
1222
1154
  }
@@ -1429,17 +1361,6 @@ class CQN2SQLRenderer {
1429
1361
  return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
1430
1362
  }
1431
1363
 
1432
- /**
1433
- * Calculates the Database table name of the query target
1434
- * @param {import('./infer/cqn').Query} query
1435
- * @returns {string} Database table name
1436
- */
1437
- table_name(q) {
1438
- // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
1439
- const table = cds.db.resolve?.table ? cds.db.resolve.table(q._target) : getDBTable(q._target)
1440
- return this.name(table.name, q)
1441
- }
1442
-
1443
1364
  /**
1444
1365
  * Calculates the Database name of the given name
1445
1366
  * @param {string|import('./infer/cqn').ref} name
package/lib/cqn4sql.js CHANGED
@@ -96,7 +96,7 @@ function cqn4sql(originalQuery, model) {
96
96
 
97
97
  // Transform the existing where, prepend table aliases, and so on...
98
98
  if (where) {
99
- transformedProp.where = getTransformedTokenStream(where)
99
+ transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
100
100
  }
101
101
 
102
102
  // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
@@ -191,7 +191,7 @@ function cqn4sql(originalQuery, model) {
191
191
 
192
192
  // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
193
193
  if (having) {
194
- transformedQuery.SELECT.having = getTransformedTokenStream(having)
194
+ transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
195
195
  }
196
196
 
197
197
  if (groupBy) {
@@ -314,7 +314,7 @@ function cqn4sql(originalQuery, model) {
314
314
  lhs.args.push(arg)
315
315
  alreadySeen.set(nextAssoc.$refLink.alias, true)
316
316
  if (nextAssoc.where) {
317
- const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
317
+ const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
318
318
  lhs.on = [
319
319
  ...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
320
320
  'and',
@@ -521,14 +521,14 @@ function cqn4sql(originalQuery, model) {
521
521
  }
522
522
  }
523
523
 
524
- function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
524
+ function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
525
525
  let value
526
526
 
527
527
  if (column.$refLinks) {
528
528
  const { $refLinks } = column
529
529
  value = $refLinks[$refLinks.length - 1].definition.value
530
530
  if (column.$refLinks.length > 1) {
531
- baseLink =
531
+ $baseLink =
532
532
  [...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
533
533
  // if there is no association in the path, the table alias is the base link
534
534
  // TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
@@ -541,13 +541,13 @@ function cqn4sql(originalQuery, model) {
541
541
 
542
542
  let res
543
543
  if (ref) {
544
- res = getTransformedTokenStream([value], baseLink)[0]
544
+ res = getTransformedTokenStream([value], { $baseLink })[0]
545
545
  } else if (xpr) {
546
- res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
546
+ res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
547
547
  } else if (val !== undefined) {
548
548
  res = { val }
549
549
  } else if (func) {
550
- res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
550
+ res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
551
551
  }
552
552
  if (!omitAlias) res.as = column.as || column.name || column.flatName
553
553
  return res
@@ -1018,7 +1018,7 @@ function cqn4sql(originalQuery, model) {
1018
1018
  * the result.
1019
1019
  */
1020
1020
  if (inOrderBy && flatColumns.length > 1)
1021
- throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
1021
+ throw new Error(`Structured element “${getFullName(leaf)} expands to multiple fields and can't be used in order by`)
1022
1022
  flatColumns.forEach(fc => {
1023
1023
  if (col.nulls) fc.nulls = col.nulls
1024
1024
  if (col.sort) fc.sort = col.sort
@@ -1182,7 +1182,7 @@ function cqn4sql(originalQuery, model) {
1182
1182
  if (column.val || column.func || column.SELECT) return [column]
1183
1183
 
1184
1184
  const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1185
- let { baseName, columnAlias = column.as, tableAlias } = names
1185
+ let { baseName, columnAlias = column.as, tableAlias } = names || {}
1186
1186
  const { exclude, replace } = excludeAndReplace || {}
1187
1187
  const { $refLinks, flatName, isJoinRelevant } = column
1188
1188
  let firstNonJoinRelevantAssoc, stepAfterAssoc
@@ -1360,14 +1360,18 @@ function cqn4sql(originalQuery, model) {
1360
1360
  * @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
1361
1361
  * object representing a CQN construct such as a column, an operator,
1362
1362
  * or a subquery.
1363
- * @param {object} [$baseLink=null] - The context in which the `ref`s in the token stream are resolvable.
1364
- * It serves as the reference point for resolving associations in
1365
- * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1366
- * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1363
+ * @param {object} [context] - Optional context object.
1364
+ * @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
1365
+ * It serves as the reference point for resolving associations in
1366
+ * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1367
+ * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1368
+ * @param {string} [context.prop] - The query property which holds the token stream which shall be
1369
+ * transformed by this function, e.g. "where".
1367
1370
  * @returns {object[]} - The transformed token stream.
1368
1371
  */
1369
- function getTransformedTokenStream(tokenStream, $baseLink = null) {
1372
+ function getTransformedTokenStream(tokenStream, context = {}) {
1370
1373
  const transformedTokenStream = []
1374
+ const { $baseLink, /* prop */ } = context
1371
1375
  for (let i = 0; i < tokenStream.length; i++) {
1372
1376
  const token = tokenStream[i]
1373
1377
  if (token === 'exists') {
@@ -1453,7 +1457,7 @@ function cqn4sql(originalQuery, model) {
1453
1457
  if (list.every(e => e.val))
1454
1458
  // no need for transformation
1455
1459
  transformedTokenStream.push({ list })
1456
- else transformedTokenStream.push({ list: getTransformedTokenStream(list, $baseLink) })
1460
+ else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1457
1461
  }
1458
1462
  } else if (tokenStream.length === 1 && token.val && $baseLink) {
1459
1463
  // infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
@@ -1509,13 +1513,13 @@ function cqn4sql(originalQuery, model) {
1509
1513
  i = indexRhs // jump to next relevant index
1510
1514
  } else {
1511
1515
  // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1512
- assertNoStructInXpr(token, $baseLink)
1516
+ assertNoStructInXpr(token, context)
1513
1517
  // reject virtual elements in expressions as they will lead to a sql error down the line
1514
1518
  if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1515
1519
 
1516
1520
  let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1517
1521
  if (token.ref) {
1518
- const { definition } = token.$refLinks[token.$refLinks.length - 1]
1522
+ const { definition } = token.$refLinks.at(-1)
1519
1523
  // Add definition to result
1520
1524
  setElementOnColumns(result, definition)
1521
1525
  if (isCalculatedOnRead(definition)) {
@@ -1536,7 +1540,15 @@ function cqn4sql(originalQuery, model) {
1536
1540
  const lastAssoc =
1537
1541
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1538
1542
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1539
- if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1543
+ if(isAssocOrStruct(definition)) {
1544
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1545
+ if(flat.length === 0)
1546
+ throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1547
+ else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1548
+ throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
1549
+ transformedTokenStream.push(...flat)
1550
+ continue
1551
+ } else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1540
1552
  let name = calculateElementName(token)
1541
1553
  result.ref = [tableAlias, name]
1542
1554
  } else if (tableAlias) {
@@ -1549,7 +1561,7 @@ function cqn4sql(originalQuery, model) {
1549
1561
  result = transformSubquery(token)
1550
1562
  } else {
1551
1563
  if (token.xpr) {
1552
- result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1564
+ result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
1553
1565
  }
1554
1566
  if (token.func && token.args) {
1555
1567
  result.args = getTransformedFunctionArgs(token.args, $baseLink)
@@ -1661,11 +1673,15 @@ function cqn4sql(originalQuery, model) {
1661
1673
  }
1662
1674
  }
1663
1675
 
1664
- function assertNoStructInXpr(token, inInfixFilter = false) {
1665
- if (!inInfixFilter && token.$refLinks?.[token.$refLinks.length - 1].definition.target)
1666
- // REVISIT: let this through if not requested otherwise
1676
+ function assertNoStructInXpr(token, context) {
1677
+ const definition = token.$refLinks?.at(-1).definition
1678
+ if(!definition) return
1679
+ const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1680
+ // unmanaged is always forbidden
1681
+ // expanding a ref in a `where`/`having` context
1682
+ if ((rejectStructs && definition?.target) || definition?.on)
1667
1683
  rejectAssocInExpression()
1668
- if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1684
+ if (rejectStructs && isStructured(definition))
1669
1685
  // REVISIT: let this through if not requested otherwise
1670
1686
  rejectStructInExpression()
1671
1687
 
@@ -1771,7 +1787,7 @@ function cqn4sql(originalQuery, model) {
1771
1787
 
1772
1788
  // only append infix filter to outer where if it is the leaf of the from ref
1773
1789
  if (refReverse[0].where)
1774
- filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
1790
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
1775
1791
 
1776
1792
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
1777
1793
  if (whereExistsSubSelects.length > 0) {
@@ -2209,7 +2225,7 @@ function cqn4sql(originalQuery, model) {
2209
2225
  }
2210
2226
 
2211
2227
  if (customWhere) {
2212
- const filter = getTransformedTokenStream(customWhere, next)
2228
+ const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
2213
2229
  const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2214
2230
  on.push('and', ...wrappedFilter)
2215
2231
  }
@@ -2315,10 +2331,10 @@ function cqn4sql(originalQuery, model) {
2315
2331
  function getTransformedFunctionArgs(args, $baseLink = null) {
2316
2332
  let result = null
2317
2333
  if (Array.isArray(args)) {
2318
- result = args.map(t => {
2334
+ result = args.flatMap(t => {
2319
2335
  if (!t.val)
2320
2336
  // this must not be touched
2321
- return getTransformedTokenStream([t], $baseLink)[0]
2337
+ return getTransformedTokenStream([t], { $baseLink })
2322
2338
  return t
2323
2339
  })
2324
2340
  } else if (typeof args === 'object') {
@@ -2327,7 +2343,7 @@ function cqn4sql(originalQuery, model) {
2327
2343
  const t = args[prop]
2328
2344
  if (!t.val)
2329
2345
  // this must not be touched
2330
- result[prop] = getTransformedTokenStream([t], $baseLink)[0]
2346
+ result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
2331
2347
  else result[prop] = t
2332
2348
  }
2333
2349
  }
@@ -3,20 +3,7 @@ const { _target_name4 } = require('./SQLService')
3
3
 
4
4
  const ROOT = Symbol('root')
5
5
 
6
- // REVISIT: remove old path with cds^8
7
- let _compareJson
8
- const compareJson = (...args) => {
9
- if (!_compareJson) {
10
- try {
11
- // new path
12
- _compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
13
- } catch {
14
- // old path
15
- _compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
16
- }
17
- }
18
- return _compareJson(...args)
19
- }
6
+ const compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
20
7
 
21
8
  const handledDeep = Symbol('handledDeep')
22
9
 
package/lib/search.js CHANGED
@@ -79,6 +79,9 @@ const _getSearchableColumns = entity => {
79
79
  // always ignore virtual elements from search
80
80
  if(column?.virtual) continue
81
81
  if (column?.isAssociation || columnName.includes('.')) {
82
+ if(!annotationValue)
83
+ continue
84
+
82
85
  const ref = columnName.split('.')
83
86
  if(ref.length > 1) skipDefaultSearchableElements = true
84
87
  deepSearchCandidates.push({ ref })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.5.0",
3
+ "version": "2.6.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": {