@cap-js/db-service 2.4.0 → 2.5.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,21 @@
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.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.4.0...db-service-v2.5.0) (2025-09-30)
8
+
9
+
10
+ ### Added
11
+
12
+ * make hana server version accessible to sub classes ([#1263](https://github.com/cap-js/cds-dbs/issues/1263)) ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
13
+ * sets default to hana cloud if server version cannot be detected ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`@cds.search`:** no duplicates for search along `to-many` paths ([#1341](https://github.com/cap-js/cds-dbs/issues/1341)) ([5c5f4fb](https://github.com/cap-js/cds-dbs/commit/5c5f4fbf790f718c3cf1bcb6f3bf7be421be598f))
19
+ * associations in `[@cds](https://github.com/cds).search` are additive ([#1355](https://github.com/cap-js/cds-dbs/issues/1355)) ([ea931cb](https://github.com/cap-js/cds-dbs/commit/ea931cb120c2857aa18a4eb68b893926c0999a9f))
20
+ * set proper element link for path into fk ([#1344](https://github.com/cap-js/cds-dbs/issues/1344)) ([9f365d3](https://github.com/cap-js/cds-dbs/commit/9f365d35ac614969d8fd2c2a9a1a2e0cd643969d))
21
+
7
22
  ## [2.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.3.0...db-service-v2.4.0) (2025-08-27)
8
23
 
9
24
 
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 { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
5
+ const { 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,7 +230,9 @@ 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 transitions = getTransition(req.target, this, false, req.query.cmd || 'DELETE')
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')
234
236
  if (transitions.target !== transitions.queryTarget) {
235
237
  const keys = []
236
238
  const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
@@ -253,7 +255,8 @@ class SQLService extends DatabaseService {
253
255
  })
254
256
  return this.onDELETE({ query, target: transitions.target })
255
257
  }
256
- const table = getDBTable(req.target)
258
+ // REVISIT: remove fallback when cds.dbs requires cds >= 9.3
259
+ const table = resolve?.table ? resolve.table(req.target) : getDBTable(req.target)
257
260
  const { compositions } = table
258
261
  if (compositions) {
259
262
  // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
@@ -404,10 +407,6 @@ class SQLService extends DatabaseService {
404
407
  */
405
408
  cqn2sql(query, values) {
406
409
  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
- }
411
410
  let cqn2sql = new this.class.CQN2SQL(this)
412
411
  return cqn2sql.render(q, values)
413
412
  }
@@ -21,7 +21,7 @@ const StandardFunctions = {
21
21
  val = sub[2] || sub[3] || ''
22
22
  }
23
23
  arg.val = val
24
- const refs = ref.list
24
+ const refs = ref.list || [ref]
25
25
  return `(${refs.map(ref => this.expr({
26
26
  func: 'contains',
27
27
  args: [
package/lib/cqn2sql.js CHANGED
@@ -1,6 +1,7 @@
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')
4
5
  const _simple_queries = cds.env.features.sql_simple_queries
5
6
  const _strict_booleans = _simple_queries < 2
6
7
 
@@ -17,6 +18,7 @@ class CQN2SQLRenderer {
17
18
  * @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
18
19
  */
19
20
  constructor(srv) {
21
+ this.srv = srv
20
22
  this.context = srv?.context || cds.context // Using srv.context is required due to stakeholders doing unmanaged txs without cds.context being set
21
23
  this.class = new.target // for IntelliSense
22
24
  this.class._init() // is a noop for subsequent calls
@@ -25,7 +27,7 @@ class CQN2SQLRenderer {
25
27
  if (cds.env.sql.names === 'quoted') {
26
28
  this.class.prototype.name = (name, query) => {
27
29
  const e = name.id || name
28
- return (query?._target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
30
+ return (this.model?.definitions[e])?.['@cds.persistence.name'] || e || query?._target
29
31
  }
30
32
  this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
31
33
  }
@@ -89,7 +91,6 @@ class CQN2SQLRenderer {
89
91
  if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
90
92
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
91
93
 
92
-
93
94
  if (DEBUG && (LOG_SQL._debug || LOG_SQLITE._debug)) {
94
95
  let values = sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []
95
96
  if (values && !Array.isArray(values)) {
@@ -721,6 +722,49 @@ class CQN2SQLRenderer {
721
722
  return this.xpr({ xpr })
722
723
  }
723
724
 
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
+
724
768
  /**
725
769
  * Renders a HAVING clause into generic SQL
726
770
  * @param {import('./infer/cqn').predicate} xpr
@@ -826,15 +870,22 @@ class CQN2SQLRenderer {
826
870
  if (!elements && !INSERT.entries?.length) {
827
871
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
828
872
  }
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')
829
875
  const columns = elements
830
- ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation)
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
+ )
831
882
  : ObjectKeys(INSERT.entries[0])
832
883
 
833
884
  /** @type {string[]} */
834
885
  this.columns = columns
835
886
 
836
887
  const alias = INSERT.into.as
837
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
888
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
838
889
  if (!elements) {
839
890
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
840
891
  const param = this.param.bind(this, { ref: ['?'] })
@@ -856,8 +907,8 @@ class CQN2SQLRenderer {
856
907
  }
857
908
 
858
909
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
859
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
860
- }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
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(?)`)
861
912
  }
862
913
 
863
914
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -963,7 +1014,7 @@ class CQN2SQLRenderer {
963
1014
  */
964
1015
  INSERT_rows(q) {
965
1016
  const { INSERT } = q
966
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
1017
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
967
1018
  const alias = INSERT.into.as
968
1019
  const elements = q.elements || q._target?.elements
969
1020
  const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
@@ -987,8 +1038,10 @@ class CQN2SQLRenderer {
987
1038
  const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
988
1039
  .slice(0, columns.length)
989
1040
  .map(c => c.converter(c.extract))
990
-
991
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
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))
992
1045
  }) SELECT ${extraction} FROM json_each(?)`)
993
1046
  }
994
1047
 
@@ -1009,15 +1062,26 @@ class CQN2SQLRenderer {
1009
1062
  */
1010
1063
  INSERT_select(q) {
1011
1064
  const { INSERT } = q
1012
- const entity = this.name(q._target.name, q)
1065
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1013
1066
  const alias = INSERT.into.as
1067
+ const src = this.cqn4sql(INSERT.from)
1014
1068
  const elements = q.elements || q._target?.elements || {}
1015
- const columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1016
- c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
1017
- ))
1018
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${this.SELECT(
1019
- this.cqn4sql(INSERT.from || INSERT.as),
1020
- )}`
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}`
1021
1085
  this.entries = [this.values]
1022
1086
  return this.sql
1023
1087
  }
@@ -1077,7 +1141,7 @@ class CQN2SQLRenderer {
1077
1141
  .filter(c => keys.includes(c.name))
1078
1142
  .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
1079
1143
 
1080
- const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1144
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1081
1145
  sql = `SELECT ${managed.map(c => c.upsert
1082
1146
  .replace(/value->/g, '"$$$$value$$$$"->')
1083
1147
  .replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
@@ -1093,7 +1157,9 @@ class CQN2SQLRenderer {
1093
1157
  else return true
1094
1158
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
1095
1159
 
1096
- return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
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
1097
1163
  } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
1098
1164
  }
1099
1165
 
@@ -1106,29 +1172,37 @@ class CQN2SQLRenderer {
1106
1172
  */
1107
1173
  UPDATE(q) {
1108
1174
  const { entity, with: _with, data, where } = q.UPDATE
1109
- const elements = q._target?.elements
1110
- let sql = `UPDATE ${this.quote(this.name(entity.ref?.[0] || entity, q))}`
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))}`
1111
1179
  if (entity.as) sql += ` AS ${this.quote(entity.as)}`
1112
1180
 
1113
1181
  let columns = []
1114
1182
  if (data) _add(data, val => this.val({ val }))
1115
1183
  if (_with) _add(_with, x => this.expr(x))
1116
1184
  function _add(data, sql4) {
1117
- for (let c in data) {
1118
- const columnExistsInDatabase =
1119
- elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
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
1120
1192
  if (!elements || columnExistsInDatabase) {
1121
- columns.push({ name: c, sql: sql4(data[c]) })
1193
+ columns.push({ name: c, sql: sql4(data[col], col) })
1122
1194
  }
1123
1195
  }
1124
1196
  }
1125
1197
 
1126
1198
  const extraction = this.managed(columns, elements)
1127
- .filter((c, i) => columns[i] || c.onUpdate)
1128
- .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
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}`)
1129
1203
 
1130
1204
  sql += ` SET ${extraction}`
1131
- if (where) sql += ` WHERE ${this.where(where)}`
1205
+ if (where) sql += ` WHERE ${this.where_resolved(entity, where, q)}`
1132
1206
  return (this.sql = sql)
1133
1207
  }
1134
1208
 
@@ -1140,8 +1214,9 @@ class CQN2SQLRenderer {
1140
1214
  * @returns {string} SQL
1141
1215
  */
1142
1216
  DELETE(q) {
1143
- const { DELETE: { from, where } } = q
1144
- let sql = `DELETE FROM ${this.from(from, 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)}`
1145
1220
  if (where) sql += ` WHERE ${this.where(where)}`
1146
1221
  return (this.sql = sql)
1147
1222
  }
@@ -1354,6 +1429,17 @@ class CQN2SQLRenderer {
1354
1429
  return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
1355
1430
  }
1356
1431
 
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
+
1357
1443
  /**
1358
1444
  * Calculates the Database name of the given name
1359
1445
  * @param {string|import('./infer/cqn').ref} name
package/lib/cqn4sql.js CHANGED
@@ -61,7 +61,7 @@ function cqn4sql(originalQuery, model) {
61
61
  if (!hasCustomJoins && inferred.SELECT?.search) {
62
62
  // we need an instance of query because the elements of the query are needed for the calculation of the search columns
63
63
  if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
64
- const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
64
+ const searchTerm = getSearch(inferred.SELECT.search, inferred)
65
65
  if (searchTerm) {
66
66
  // Search target can be a navigation, in that case use _target to get the correct entity
67
67
  const { where, having } = transformSearch(searchTerm)
@@ -123,14 +123,9 @@ function cqn4sql(originalQuery, model) {
123
123
  // calculate the primary keys of the target entity, there is always exactly
124
124
  // one query source for UPDATE / DELETE
125
125
  const queryTarget = Object.values(inferred.sources)[0].definition
126
- const primaryKey = { list: [] }
127
- for (const k of Object.keys(queryTarget.elements)) {
128
- const e = queryTarget.elements[k]
129
- if (e.key === true && !e.virtual && e.isAssociation !== true) {
130
- subquery.SELECT.columns.push({ ref: [e.name] })
131
- primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
132
- }
133
- }
126
+ const primaryKey = { list: getPrimaryKey(queryTarget, uniqueSubqueryAlias) }
127
+ // match primary keys of the target entity with the subquery
128
+ primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
134
129
 
135
130
  const transformedSubquery = cqn4sql(subquery, model)
136
131
 
@@ -258,7 +253,7 @@ function cqn4sql(originalQuery, model) {
258
253
  if (inferred.SELECT[prop]) {
259
254
  return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
260
255
  } else {
261
- return { [prop]: [searchTerm] }
256
+ return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
262
257
  }
263
258
  }
264
259
 
@@ -1211,7 +1206,7 @@ function cqn4sql(originalQuery, model) {
1211
1206
  if(column.element && !isAssocOrStruct(column.element)) {
1212
1207
  columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1213
1208
  const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1214
- setElementOnColumns(res, element)
1209
+ setElementOnColumns(res, column.element)
1215
1210
  return [res]
1216
1211
  }
1217
1212
 
@@ -1789,7 +1784,7 @@ function cqn4sql(originalQuery, model) {
1789
1784
  filterConditions.forEach(f => {
1790
1785
  transformedWhere.push('and')
1791
1786
  if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
1792
- else if (f.length > 3 || f.includes('or') || f.includes('and')) transformedWhere.push(asXpr(f))
1787
+ else if (f.length > 3 || f.includes('or') || f.includes('and') || f.includes('in')) transformedWhere.push(asXpr(f))
1793
1788
  else transformedWhere.push(...f)
1794
1789
  })
1795
1790
  } else {
@@ -2221,30 +2216,41 @@ function cqn4sql(originalQuery, model) {
2221
2216
  return SELECT
2222
2217
  }
2223
2218
 
2224
- /**
2225
- * For a given search expression return a function "search" which holds the search expression
2226
- * as well as the searchable columns as arguments.
2219
+ /**
2220
+ * For a given search term calculate a search expression which can be used in a where clause.
2221
+ * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2222
+ * the search results of the subquery.
2227
2223
  *
2228
- * @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
2224
+ * @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source.
2229
2225
  * @param {object} query - The FROM clause of the CQN statement.
2230
2226
  *
2231
2227
  * @returns {(Object|null)} returns either:
2228
+ * - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
2232
2229
  * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2233
2230
  * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2234
2231
  */
2235
- function getSearchTerm(search, query) {
2232
+ function getSearch(searchTerm, query) {
2236
2233
  const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
2237
2234
  const searchIn = computeColumnsToBeSearched(inferred, entity)
2238
- if (searchIn.length > 0) {
2239
- const xpr = search
2240
- const searchFunc = {
2241
- func: 'search',
2242
- args: [{ list: searchIn }, xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }],
2243
- }
2244
- return searchFunc
2245
- } else {
2246
- return null
2235
+ if (searchIn.length === 0) return null
2236
+
2237
+ const searchFunc = {
2238
+ func: 'search',
2239
+ args: [
2240
+ searchIn.length === 1 ? searchIn[0] : { list: searchIn },
2241
+ searchTerm.length === 1 && 'val' in searchTerm[0] ? searchTerm[0] : { xpr: searchTerm },
2242
+ ],
2247
2243
  }
2244
+ // for aggregated queries / search on subqueries we do not do a subquery search
2245
+ if (inferred.SELECT.groupBy || entity.SELECT)
2246
+ return searchFunc
2247
+
2248
+ const matchColumns = getPrimaryKey(entity)
2249
+ if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2250
+ return searchFunc
2251
+
2252
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2253
+ return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
2248
2254
  }
2249
2255
 
2250
2256
  /**
@@ -2410,6 +2416,17 @@ function setElementOnColumns(col, element) {
2410
2416
  defineProperty(col, 'element', element)
2411
2417
  }
2412
2418
 
2419
+ function getPrimaryKey(entity, tableAlias = null) {
2420
+ const primaryKey = []
2421
+ for (const k of Object.keys(entity.elements)) {
2422
+ const e = entity.elements[k]
2423
+ if (e.key === true && !e.virtual && e.isAssociation !== true) {
2424
+ primaryKey.push({ ref: tableAlias ? [tableAlias, e.name] : [e.name] })
2425
+ }
2426
+ }
2427
+ return primaryKey
2428
+ }
2429
+
2413
2430
  const getName = col => col.as || col.ref?.at(-1)
2414
2431
  const idOnly = ref => ref.id || ref
2415
2432
  const refWithConditions = step => {
package/lib/search.js CHANGED
@@ -66,7 +66,7 @@ const _getSearchableColumns = entity => {
66
66
  if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
67
67
  }
68
68
 
69
- let atLeastOneColumnIsSearchable = false
69
+ let skipDefaultSearchableElements = false
70
70
  const deepSearchCandidates = []
71
71
 
72
72
  // build a map of columns annotated with the @cds.search annotation
@@ -74,13 +74,18 @@ const _getSearchableColumns = entity => {
74
74
  const columnName = key.split(cdsSearchTerm + '.').pop()
75
75
  const annotationKey = `${cdsSearchTerm}.${columnName}`
76
76
  const annotationValue = entity[annotationKey]
77
- if (annotationValue) atLeastOneColumnIsSearchable = true
78
77
 
79
78
  const column = entity.elements[columnName]
79
+ // always ignore virtual elements from search
80
+ if(column?.virtual) continue
80
81
  if (column?.isAssociation || columnName.includes('.')) {
81
- deepSearchCandidates.push({ ref: columnName.split('.') })
82
+ const ref = columnName.split('.')
83
+ if(ref.length > 1) skipDefaultSearchableElements = true
84
+ deepSearchCandidates.push({ ref })
82
85
  continue
83
86
  }
87
+
88
+ if(annotationValue) skipDefaultSearchableElements = true
84
89
  cdsSearchColumnMap.set(columnName, annotationValue)
85
90
  }
86
91
 
@@ -99,7 +104,7 @@ const _getSearchableColumns = entity => {
99
104
  // if at least one element is explicitly annotated as searchable, e.g.:
100
105
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
101
106
  // and it is not the current column name, then it must be excluded from the search
102
- if (atLeastOneColumnIsSearchable) return false
107
+ if (skipDefaultSearchableElements) return false
103
108
 
104
109
  // the element is considered searchable if it is explicitly annotated as such or
105
110
  // if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.4.0",
3
+ "version": "2.5.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": {