@cap-js/db-service 2.3.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,39 @@
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
+
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)
23
+
24
+
25
+ ### Added
26
+
27
+ * **`tuple expansion`:** allow structs with exactly one element/fk in comparison ([#1291](https://github.com/cap-js/cds-dbs/issues/1291)) ([75ea826](https://github.com/cap-js/cds-dbs/commit/75ea82694faeafcaf78df9d4b0bbce37b4f65b63))
28
+ * cds.db.foreach uses real object mode streaming ([#1318](https://github.com/cap-js/cds-dbs/issues/1318)) ([cd28b53](https://github.com/cap-js/cds-dbs/commit/cd28b53966dbe28ad1d5ef3827767e78742e0fbd))
29
+
30
+
31
+ ### Fixed
32
+
33
+ * **`assoc2join`:** target side access detection ([#1282](https://github.com/cap-js/cds-dbs/issues/1282)) ([6f9befa](https://github.com/cap-js/cds-dbs/commit/6f9befa24a06bcc629fe853aa66290613734c3ef))
34
+ * **`cqn4sql`:** only consider `own` property `[@cds](https://github.com/cds).persistence.skip` ([#1324](https://github.com/cap-js/cds-dbs/issues/1324)) ([bd1f52f](https://github.com/cap-js/cds-dbs/commit/bd1f52f67fb4709dce3a27fea8856cb9b875da6b))
35
+ * **`exists`:** do not loose custom where ([#1322](https://github.com/cap-js/cds-dbs/issues/1322)) ([644918c](https://github.com/cap-js/cds-dbs/commit/644918c56d9d939f43f4a0346f42e16722bd6fe9))
36
+ * arithmetic operators can only be used with scalar operands ([#1307](https://github.com/cap-js/cds-dbs/issues/1307)) ([d58d335](https://github.com/cap-js/cds-dbs/commit/d58d33539e22f818d18240bb86ba596fc6fe21d1))
37
+ * detect path expression inside nested xpr after `exists` ([#1292](https://github.com/cap-js/cds-dbs/issues/1292)) ([852d915](https://github.com/cap-js/cds-dbs/commit/852d9155d5bb09a56a6c152259c8282662ceb29d)), closes [#1225](https://github.com/cap-js/cds-dbs/issues/1225)
38
+ * reject comparison of two empty structures ([#1306](https://github.com/cap-js/cds-dbs/issues/1306)) ([d97304d](https://github.com/cap-js/cds-dbs/commit/d97304d95c7f629afe75aba57192277b7124eb3e))
39
+
7
40
  ## [2.3.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.2.0...db-service-v2.3.0) (2025-07-28)
8
41
 
9
42
 
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]`
@@ -355,6 +358,15 @@ class SQLService extends DatabaseService {
355
358
  return count
356
359
  }
357
360
 
361
+ /**
362
+ * Streaming API variant of .run().
363
+ * @param {import('@sap/cds/apis/cqn').SELECT} query - SELECT CQN
364
+ * @param {function} callback - Function to be invoked for each row
365
+ */
366
+ foreach (query, callback) {
367
+ return query.foreach(callback)
368
+ }
369
+
358
370
  /**
359
371
  * Helper class for results of INSERTs.
360
372
  * Subclasses may override this.
@@ -395,10 +407,6 @@ class SQLService extends DatabaseService {
395
407
  */
396
408
  cqn2sql(query, values) {
397
409
  let q = this.cqn4sql(query)
398
- let kind = q.kind || Object.keys(q)[0]
399
- if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
400
- 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?
401
- }
402
410
  let cqn2sql = new this.class.CQN2SQL(this)
403
411
  return cqn2sql.render(q, values)
404
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
@@ -12,6 +12,7 @@ const {
12
12
  getImplicitAlias,
13
13
  defineProperty,
14
14
  getModelUtils,
15
+ hasOwnSkip,
15
16
  } = require('./utils')
16
17
 
17
18
  /**
@@ -20,14 +21,14 @@ const {
20
21
  */
21
22
  const eqOps = [['is'], ['='] /* ['=='] */]
22
23
  /**
23
- * For operators of <notEqOps>, do the same but use or instead of and.
24
- * This ensures that not struct == <value> is the same as struct != <value>.
24
+ * For operators of <notEqOps>, do the same but use `or` instead of `and`.
25
+ * This ensures that `not struct == <value>` is the same as `struct != <value>`.
25
26
  */
26
27
  const notEqOps = [['is', 'not'], ['<>'], ['!=']]
27
28
  /**
28
29
  * not supported in comparison w/ struct because of unclear semantics
29
30
  */
30
- const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
31
+ const notSupportedOps = [['>'], ['<'], ['>='], ['<='], ['*'], ['+'], ['-'], ['/']]
31
32
 
32
33
  const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
33
34
 
@@ -60,7 +61,7 @@ function cqn4sql(originalQuery, model) {
60
61
  if (!hasCustomJoins && inferred.SELECT?.search) {
61
62
  // we need an instance of query because the elements of the query are needed for the calculation of the search columns
62
63
  if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
63
- const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
64
+ const searchTerm = getSearch(inferred.SELECT.search, inferred)
64
65
  if (searchTerm) {
65
66
  // Search target can be a navigation, in that case use _target to get the correct entity
66
67
  const { where, having } = transformSearch(searchTerm)
@@ -122,14 +123,9 @@ function cqn4sql(originalQuery, model) {
122
123
  // calculate the primary keys of the target entity, there is always exactly
123
124
  // one query source for UPDATE / DELETE
124
125
  const queryTarget = Object.values(inferred.sources)[0].definition
125
- const primaryKey = { list: [] }
126
- for (const k of Object.keys(queryTarget.elements)) {
127
- const e = queryTarget.elements[k]
128
- if (e.key === true && !e.virtual && e.isAssociation !== true) {
129
- subquery.SELECT.columns.push({ ref: [e.name] })
130
- primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
131
- }
132
- }
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) }))
133
129
 
134
130
  const transformedSubquery = cqn4sql(subquery, model)
135
131
 
@@ -257,7 +253,7 @@ function cqn4sql(originalQuery, model) {
257
253
  if (inferred.SELECT[prop]) {
258
254
  return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
259
255
  } else {
260
- return { [prop]: [searchTerm] }
256
+ return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
261
257
  }
262
258
  }
263
259
 
@@ -467,7 +463,7 @@ function cqn4sql(originalQuery, model) {
467
463
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
468
464
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
469
465
 
470
- if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)) return
466
+ if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
471
467
 
472
468
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
473
469
  flatColumns.forEach(flatColumn => {
@@ -966,7 +962,7 @@ function cqn4sql(originalQuery, model) {
966
962
  } else if (pseudos.elements[col.ref?.[0]]) {
967
963
  res.push({ ...col })
968
964
  } else if (col.ref) {
969
- if (col.$refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true))
965
+ if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
970
966
  continue
971
967
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
972
968
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1189,7 +1185,7 @@ function cqn4sql(originalQuery, model) {
1189
1185
  let { baseName, columnAlias = column.as, tableAlias } = names
1190
1186
  const { exclude, replace } = excludeAndReplace || {}
1191
1187
  const { $refLinks, flatName, isJoinRelevant } = column
1192
- let leafAssoc
1188
+ let firstNonJoinRelevantAssoc, stepAfterAssoc
1193
1189
  let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
1194
1190
  if (isWildcard && element.type === 'cds.LargeBinary') return []
1195
1191
  if (element.on && !element.keys)
@@ -1197,14 +1193,23 @@ function cqn4sql(originalQuery, model) {
1197
1193
  else if (element.virtual === true) return []
1198
1194
  else if (!isJoinRelevant && flatName) baseName = flatName
1199
1195
  else if (isJoinRelevant) {
1200
- const leaf = column.$refLinks.at(-1)
1201
- leafAssoc = [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1202
- let elements = leafAssoc.definition.elements || leafAssoc.definition.foreignKeys
1203
- if (elements && leaf.definition.name in elements) {
1204
- element = leafAssoc.definition
1205
- baseName = getFullName(leafAssoc.definition)
1196
+ const leafAssocIndex = column.$refLinks.findIndex(link => link.definition.isAssociation && link.onlyForeignKeyAccess)
1197
+ firstNonJoinRelevantAssoc = column.$refLinks[leafAssocIndex] || [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1198
+ stepAfterAssoc = column.$refLinks.at(leafAssocIndex + 1) || column.$refLinks.at(-1)
1199
+ let elements = firstNonJoinRelevantAssoc.definition.elements || firstNonJoinRelevantAssoc.definition.foreignKeys
1200
+ if (elements && stepAfterAssoc.definition.name in elements) {
1201
+ element = firstNonJoinRelevantAssoc.definition
1202
+ baseName = getFullName(firstNonJoinRelevantAssoc.definition)
1206
1203
  columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1207
1204
  } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
1205
+
1206
+ if(column.element && !isAssocOrStruct(column.element)) {
1207
+ columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1208
+ const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1209
+ setElementOnColumns(res, column.element)
1210
+ return [res]
1211
+ }
1212
+
1208
1213
  } else if (!baseName && structsAreUnfoldedAlready) {
1209
1214
  baseName = element.name // name is already fully constructed
1210
1215
  } else {
@@ -1245,11 +1250,11 @@ function cqn4sql(originalQuery, model) {
1245
1250
  const flattenThisForeignKey =
1246
1251
  !$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
1247
1252
  element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
1248
- keyElement === $refLinks.at(-1).definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1253
+ keyElement === stepAfterAssoc.definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1249
1254
  if (flattenThisForeignKey) {
1250
1255
  const fkElement = getElementForRef(k.ref, getDefinition(element.target))
1251
1256
  let fkBaseName
1252
- if (!leafAssoc || leafAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
1257
+ if (!firstNonJoinRelevantAssoc || firstNonJoinRelevantAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
1253
1258
  // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
1254
1259
  else fkBaseName = k.ref.at(-1)
1255
1260
  const fkPath = [...csnPath, k.ref.at(-1)]
@@ -1462,6 +1467,7 @@ function cqn4sql(originalQuery, model) {
1462
1467
  flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1463
1468
  }
1464
1469
  }
1470
+ // TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
1465
1471
  if (flatKeys.length > 1)
1466
1472
  throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1467
1473
  flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
@@ -1474,35 +1480,38 @@ function cqn4sql(originalQuery, model) {
1474
1480
  transformedTokenStream.push({ ...token })
1475
1481
  } else {
1476
1482
  // expand `struct = null | struct2`
1477
- const definition = token.$refLinks?.at(-1).definition
1478
1483
  const next = tokenStream[i + 1]
1479
- if (allOps.some(([firstOp]) => firstOp === next) && (definition?.elements || definition?.keys)) {
1484
+ let indexRhs = i + 2
1485
+ let rhs = tokenStream[indexRhs] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1486
+ const lhsDef = token.$refLinks?.at(-1).definition
1487
+ let rhsDef = rhs?.$refLinks?.at(-1)?.definition
1488
+ if (
1489
+ allOps.some(([firstOp]) => firstOp === next) &&
1490
+ (lhsDef?.elements || lhsDef?.keys || rhsDef?.elements || rhsDef?.keys)
1491
+ ) {
1480
1492
  const ops = [next]
1481
- let indexRhs = i + 2
1482
- let rhs = tokenStream[i + 2] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1483
1493
  if (allOps.some(([, secondOp]) => secondOp === rhs)) {
1484
1494
  ops.push(rhs)
1485
1495
  rhs = tokenStream[i + 3]
1486
1496
  indexRhs += 1
1497
+ rhsDef = rhs?.$refLinks?.at(-1)?.definition
1487
1498
  }
1488
- if (
1489
- isAssocOrStruct(rhs.$refLinks?.[rhs.$refLinks.length - 1].definition) ||
1490
- rhs.val !== undefined ||
1491
- /* unary operator `is null` parsed as string */
1492
- rhs === 'null'
1493
- ) {
1494
- if (notSupportedOps.some(([firstOp]) => firstOp === next))
1495
- throw new Error(`The operator "${next}" is not supported for structure comparison`)
1496
- const newTokens = expandComparison(token, ops, rhs, $baseLink)
1497
- const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1498
- transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1499
- i = indexRhs // jump to next relevant index
1500
- }
1499
+
1500
+ if (notSupportedOps.some(([firstOp]) => firstOp === next))
1501
+ throw new Error(`The operator "${next}" can only be used with scalar operands`)
1502
+
1503
+ const newTokens = expandComparison(token, ops, rhs, $baseLink)
1504
+ if(newTokens.length === 0)
1505
+ throw new Error(`Can't compare two empty structures`)
1506
+
1507
+ const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1508
+ transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1509
+ i = indexRhs // jump to next relevant index
1501
1510
  } else {
1502
1511
  // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1503
1512
  assertNoStructInXpr(token, $baseLink)
1504
1513
  // reject virtual elements in expressions as they will lead to a sql error down the line
1505
- if (definition?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1514
+ if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1506
1515
 
1507
1516
  let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1508
1517
  if (token.ref) {
@@ -1528,7 +1537,7 @@ function cqn4sql(originalQuery, model) {
1528
1537
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1529
1538
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1530
1539
  if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1531
- let name = calculateElementName(token, getFullName)
1540
+ let name = calculateElementName(token)
1532
1541
  result.ref = [tableAlias, name]
1533
1542
  } else if (tableAlias) {
1534
1543
  result.ref = [tableAlias, token.flatName]
@@ -1557,9 +1566,9 @@ function cqn4sql(originalQuery, model) {
1557
1566
  /**
1558
1567
  * Expand the given definition and compare all leafs to `val`.
1559
1568
  *
1560
- * @param {object} token with $refLinks
1569
+ * @param {object} lhs with $refLinks
1561
1570
  * @param {string} operator one of allOps
1562
- * @param {object} value either `null` or a column (with `ref` and `$refLinks`)
1571
+ * @param {object} rhs either `null` or a column (with `ref` and `$refLinks`)
1563
1572
  * @param {object} $baseLink optional base `$refLink`, e.g. for infix filters of scoped queries.
1564
1573
  * In the following example, we must pass `bookshop:Reproduce` as $baseLink for `author`:
1565
1574
  *
@@ -1567,26 +1576,21 @@ function cqn4sql(originalQuery, model) {
1567
1576
  * ^^^^^^
1568
1577
  * @returns {array}
1569
1578
  */
1570
- function expandComparison(token, operator, value, $baseLink = null) {
1571
- const { definition } = token.$refLinks[token.$refLinks.length - 1]
1572
- let flatRhs
1579
+ function expandComparison(lhs, operator, rhs, $baseLink = null) {
1580
+ const { definition: lhsDef, val: lhsVal } = lhs.val ? lhs : lhs.$refLinks.at(-1)
1581
+ const { definition: rhsDef, val: rhsVal } = rhs.val ? rhs : rhs.$refLinks?.at(-1) || {}
1573
1582
  const result = []
1574
- if (value.$refLinks) {
1575
- // structural comparison
1576
- flatRhs = flattenWithBaseName(value)
1577
- }
1578
-
1579
- if (flatRhs) {
1580
- const flatLhs = flattenWithBaseName(token)
1581
- // make sure we can compare both structures
1582
- if (flatRhs.length !== flatLhs.length) {
1583
- throw new Error(
1584
- `Can't compare "${definition.name}" with "${
1585
- value.$refLinks[value.$refLinks.length - 1].definition.name
1586
- }": the operands must have the same structure`,
1587
- )
1588
- }
1589
-
1583
+ if (lhsDef && rhsDef) {
1584
+ // both must be structured
1585
+ const lhsIsStructured = isAssocOrStruct(lhsDef)
1586
+ const rhsIsStructured = isAssocOrStruct(rhsDef)
1587
+ if (!lhsIsStructured)
1588
+ throw new Error(`Can't compare structure “${rhs.ref.map(idOnly).join('.')}” with non-structure “${lhs.ref.map(idOnly).join('.')}”`)
1589
+ if (!rhsIsStructured)
1590
+ throw new Error(`Can't compare structure “${lhs.ref.map(idOnly).join('.')}” with non-structure “${rhs.ref.map(idOnly).join('.')}”`)
1591
+
1592
+ const flatLhs = flattenWithBaseName(lhs)
1593
+ const flatRhs = flattenWithBaseName(rhs)
1590
1594
  const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1591
1595
  while (flatLhs.length > 0) {
1592
1596
  // retrieve and remove one flat element from LHS and search for it in RHS (remove it there too)
@@ -1598,27 +1602,47 @@ function cqn4sql(originalQuery, model) {
1598
1602
  })
1599
1603
  // not found in rhs --> exit
1600
1604
  if (indexOfElementOnRhs === -1) {
1601
- const lhsPath = token.ref.join('.')
1602
- const rhsPath = value.ref.join('.')
1605
+ const lhsPath = lhs.ref.map(idOnly).join('.')
1606
+ const rhsPath = rhs.ref.map(idOnly).join('.')
1603
1607
  throw new Error(`Can't compare "${lhsPath}" with "${rhsPath}": the operands must have the same structure`)
1604
1608
  }
1605
- const rhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1606
- result.push({ ref }, ...operator, rhs)
1609
+ const cleansedRhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1610
+ result.push({ ref }, ...operator, cleansedRhs)
1607
1611
  if (flatLhs.length > 0) result.push(boolOp)
1608
1612
  }
1609
- } else {
1613
+ } else if (lhsDef && (rhsVal || rhs === 'null' || rhs.val === null)) {
1610
1614
  // compare with value
1611
- const flatLhs = flattenWithBaseName(token)
1612
- if (flatLhs.length > 1 && value.val !== null && value !== 'null')
1613
- throw new Error(`Can't compare structure "${token.ref.join('.')}" with value "${value.val}"`)
1615
+ const flatLhs = flattenWithBaseName(lhs)
1616
+ if (flatLhs.length !== 1 && rhsVal && rhs !== 'null')
1617
+ canOnlyCompareToExactlyOneLeaf(lhsDef, lhs.ref, rhsVal)
1618
+
1614
1619
  const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1615
1620
  flatLhs.forEach((column, i) => {
1616
- result.push(column, ...operator, value)
1621
+ result.push(column, ...operator, rhs)
1617
1622
  if (flatLhs[i + 1]) result.push(boolOp)
1618
1623
  })
1624
+ } else if (lhsVal && rhsDef) {
1625
+ const flatRhs = flattenWithBaseName(rhs)
1626
+ // comparing a struct to a value is ok if structure has exactly one leaf
1627
+ if (flatRhs.length !== 1 && lhsVal)
1628
+ canOnlyCompareToExactlyOneLeaf(rhsDef, rhs.ref, lhsVal)
1629
+
1630
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1631
+ flatRhs.forEach((column, i) => {
1632
+ result.push(lhs, ...operator, column)
1633
+ if (flatRhs[i + 1]) result.push(boolOp)
1634
+ })
1619
1635
  }
1620
1636
  return result
1621
1637
 
1638
+ function canOnlyCompareToExactlyOneLeaf(struct, structRef, val) {
1639
+ const what = struct.isAssociation ? 'association' : 'structure'
1640
+ const postfix = struct.isAssociation ? 'associations with one foreign key' : 'structures with one sub-element'
1641
+ throw new Error(
1642
+ `Can't compare ${what} "${structRef.map(idOnly).join('.')}" to value "${val}"; only possible for ${postfix}`
1643
+ )
1644
+ }
1645
+
1622
1646
  function flattenWithBaseName(def) {
1623
1647
  if (!def.$refLinks) return def
1624
1648
  const leaf = def.$refLinks[def.$refLinks.length - 1]
@@ -1760,7 +1784,7 @@ function cqn4sql(originalQuery, model) {
1760
1784
  filterConditions.forEach(f => {
1761
1785
  transformedWhere.push('and')
1762
1786
  if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
1763
- 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))
1764
1788
  else transformedWhere.push(...f)
1765
1789
  })
1766
1790
  } else {
@@ -2127,7 +2151,7 @@ function cqn4sql(originalQuery, model) {
2127
2151
  * @param {boolean} inWhere whether or not the path is part of the queries where clause
2128
2152
  * -> if it is, target and source side are flipped in the where exists subquery
2129
2153
  * @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
2130
- *
2154
+ *
2131
2155
  * @returns {CQN.SELECT}
2132
2156
  */
2133
2157
  function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
@@ -2165,54 +2189,68 @@ function cqn4sql(originalQuery, model) {
2165
2189
  where: on,
2166
2190
  }
2167
2191
  // this requires sub-sequent transformation of the subquery
2168
- if (next.pathExpressionInsideFilter || (queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))) {
2169
- SELECT.where = next.pathExpressionInsideFilter ? customWhere : [];
2170
- if (queryModifier) assignQueryModifiers(SELECT, queryModifier);
2171
-
2172
- const transformedExists = transformSubquery({ SELECT });
2192
+ if (
2193
+ next.pathExpressionInsideFilter ||
2194
+ (queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))
2195
+ ) {
2196
+ SELECT.where = customWhere || []
2197
+ if (queryModifier) assignQueryModifiers(SELECT, queryModifier)
2198
+
2199
+ const transformedExists = transformSubquery({ SELECT })
2173
2200
  if (transformedExists.SELECT.where?.length) {
2174
- const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2175
- ? [asXpr(transformedExists.SELECT.where)]
2176
- : transformedExists.SELECT.where;
2201
+ const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2202
+ ? [asXpr(transformedExists.SELECT.where)]
2203
+ : transformedExists.SELECT.where
2177
2204
 
2178
- on.push('and', ...wrappedWhere);
2205
+ on.push('and', ...wrappedWhere)
2179
2206
  }
2180
- transformedExists.SELECT.where = on;
2181
- return transformedExists.SELECT;
2207
+ transformedExists.SELECT.where = on
2208
+ return transformedExists.SELECT
2182
2209
  }
2183
2210
 
2184
2211
  if (customWhere) {
2185
- const filter = getTransformedTokenStream(customWhere, next);
2186
- const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter;
2187
- on.push('and', ...wrappedFilter);
2212
+ const filter = getTransformedTokenStream(customWhere, next)
2213
+ const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2214
+ on.push('and', ...wrappedFilter)
2188
2215
  }
2189
2216
  return SELECT
2190
2217
  }
2191
2218
 
2192
- /**
2193
- * For a given search expression return a function "search" which holds the search expression
2194
- * 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.
2195
2223
  *
2196
- * @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.
2197
2225
  * @param {object} query - The FROM clause of the CQN statement.
2198
2226
  *
2199
2227
  * @returns {(Object|null)} returns either:
2228
+ * - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
2200
2229
  * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2201
2230
  * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2202
2231
  */
2203
- function getSearchTerm(search, query) {
2232
+ function getSearch(searchTerm, query) {
2204
2233
  const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
2205
2234
  const searchIn = computeColumnsToBeSearched(inferred, entity)
2206
- if (searchIn.length > 0) {
2207
- const xpr = search
2208
- const searchFunc = {
2209
- func: 'search',
2210
- args: [{ list: searchIn }, xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }],
2211
- }
2212
- return searchFunc
2213
- } else {
2214
- 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
+ ],
2215
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] }
2216
2254
  }
2217
2255
 
2218
2256
  /**
@@ -2349,7 +2387,6 @@ function getParentEntity(element) {
2349
2387
  else return getParentEntity(element.parent)
2350
2388
  }
2351
2389
 
2352
-
2353
2390
  function asXpr(thing) {
2354
2391
  return { xpr: thing }
2355
2392
  }
@@ -2379,6 +2416,17 @@ function setElementOnColumns(col, element) {
2379
2416
  defineProperty(col, 'element', element)
2380
2417
  }
2381
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
+
2382
2430
  const getName = col => col.as || col.ref?.at(-1)
2383
2431
  const idOnly = ref => ref.id || ref
2384
2432
  const refWithConditions = step => {
@@ -4,7 +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, getImplicitAlias, getModelUtils, defineProperty } = require('../utils')
7
+ const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip } = require('../utils')
8
8
  const cdsTypes = cds.linked({
9
9
  definitions: {
10
10
  Timestamp: { type: 'cds.Timestamp' },
@@ -404,7 +404,7 @@ function infer(originalQuery, model) {
404
404
  if (arg.list) arg.list.forEach(arg => inferArg(arg, null, $baseLink, context))
405
405
  if (arg.xpr)
406
406
  arg.xpr.forEach((token, i) =>
407
- inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: arg.xpr[i - 1] === 'exists' }),
407
+ inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: inExists || arg.xpr[i - 1] === 'exists' }),
408
408
  ) // e.g. function in expression
409
409
 
410
410
  if (!arg.ref) {
@@ -587,7 +587,7 @@ function infer(originalQuery, model) {
587
587
  }
588
588
 
589
589
  arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
590
- if (getDefinition(arg.$refLinks[i].definition.target)?.['@cds.persistence.skip'] === true) isPersisted = false
590
+ if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
591
591
  if (!arg.ref[i + 1]) {
592
592
  const flatName = nameSegments.join('_')
593
593
  defineProperty(arg, 'flatName', flatName)
@@ -640,7 +640,7 @@ function infer(originalQuery, model) {
640
640
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
641
641
  if (arg.expand) {
642
642
  const { $refLinks } = arg
643
- const skip = $refLinks.some(link => getDefinition(link.definition.target)?.['@cds.persistence.skip'] === true)
643
+ const skip = $refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))
644
644
  if (skip) {
645
645
  $refLinks[$refLinks.length - 1].skipExpand = true
646
646
  return
@@ -937,8 +937,15 @@ function infer(originalQuery, model) {
937
937
  return true
938
938
  }
939
939
  if (assoc) {
940
+ // if(!link.definition.isAssociation) continue
941
+ let fkIndex = assoc.keys?.findIndex(key => key.ref.every((step, j) => column.ref[i + j] === step))
940
942
  // foreign key access without filters never join relevant
941
- if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
943
+ if (fkIndex !== -1) {
944
+ if(column.ref.slice(i).some(s => s.where)) continue // probably join relevant later on
945
+ fkAccess = true
946
+ assoc = null
947
+ continue
948
+ }
942
949
  // <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
943
950
  return true
944
951
  }
@@ -212,6 +212,8 @@ class JoinTree {
212
212
  // filter is always join relevant
213
213
  // if the column ends up in an `inline` -> each assoc step is join relevant
214
214
  child.$refLink.onlyForeignKeyAccess = false
215
+ // all parents are now also join relevant
216
+ markParentAsJoinRelevant(child.parent)
215
217
  } else {
216
218
  child.$refLink.onlyForeignKeyAccess = true
217
219
  }
@@ -223,6 +225,8 @@ class JoinTree {
223
225
  if (node.$refLink && (!elements || !(child.$refLink.definition.name in elements))) {
224
226
  // no foreign key access
225
227
  node.$refLink.onlyForeignKeyAccess = false
228
+ markParentAsJoinRelevant(node.parent)
229
+
226
230
  col.$refLinks[i - 1] = node.$refLink
227
231
  }
228
232
 
@@ -233,6 +237,15 @@ class JoinTree {
233
237
  }
234
238
  return true
235
239
 
240
+ function markParentAsJoinRelevant(parent) {
241
+ while (parent) {
242
+ if (parent.$refLink?.definition.isAssociation) {
243
+ parent.$refLink.onlyForeignKeyAccess = false
244
+ }
245
+ parent = parent.parent
246
+ }
247
+ }
248
+
236
249
  function joinId(step, args, where) {
237
250
  let appendix
238
251
  if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
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/lib/utils.js CHANGED
@@ -21,6 +21,12 @@ function prettyPrintRef(ref, model = null) {
21
21
  }, '')
22
22
  }
23
23
 
24
+ function hasOwnSkip(definition) {
25
+ return (
26
+ definition && Object.hasOwn(definition, '@cds.persistence.skip') && definition['@cds.persistence.skip'] === true
27
+ )
28
+ }
29
+
24
30
  /**
25
31
  * Determines if a definition is calculated on read.
26
32
  * - Stored calculated elements are not unfolded
@@ -123,7 +129,6 @@ function getModelUtils(model, query) {
123
129
  if (!def || !isLocalized(def)) return def
124
130
  return model.definitions[`localized.${def.name}`] || def
125
131
  }
126
-
127
132
  return {
128
133
  getLocalizedName,
129
134
  isLocalized,
@@ -139,4 +144,5 @@ module.exports = {
139
144
  getImplicitAlias,
140
145
  defineProperty,
141
146
  getModelUtils,
147
+ hasOwnSkip,
142
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.3.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": {