@cap-js/db-service 2.8.2 → 2.9.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,25 @@
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.9.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.8.2...db-service-v2.9.0) (2026-03-09)
8
+
9
+
10
+ ### Added
11
+
12
+ * runtime views ([#1410](https://github.com/cap-js/cds-dbs/issues/1410)) ([5242675](https://github.com/cap-js/cds-dbs/commit/5242675c97472b86b81b3dc5fe0906141d276b02))
13
+ * support calculated elements in hierarchies ([#1456](https://github.com/cap-js/cds-dbs/issues/1456)) ([97c6f66](https://github.com/cap-js/cds-dbs/commit/97c6f6661f0ac4043245e021f2bf182f4e5d406f))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * **`exists`:** detect join relevant path after exists ([#1412](https://github.com/cap-js/cds-dbs/issues/1412)) ([c5bad06](https://github.com/cap-js/cds-dbs/commit/c5bad06724ce6761379f91748490c6caac84153a)), closes [#1407](https://github.com/cap-js/cds-dbs/issues/1407)
19
+ * **cqn2sql:** Relied on inconstistent behavior of cds.ql.cloned queries ([#1500](https://github.com/cap-js/cds-dbs/issues/1500)) ([f9cb201](https://github.com/cap-js/cds-dbs/commit/f9cb2011219a86ae22f22fcc105e597b23209adf))
20
+ * enable expressions for `inline` ([#1512](https://github.com/cap-js/cds-dbs/issues/1512)) ([65f78e1](https://github.com/cap-js/cds-dbs/commit/65f78e1f3af83188462e9d44db67daa5d743ceb0))
21
+ * path expressions for scoped queries ([#1507](https://github.com/cap-js/cds-dbs/issues/1507)) ([0f1e234](https://github.com/cap-js/cds-dbs/commit/0f1e234b373f26a6244c715c9ca9d4a207a0faed))
22
+ * reject duplicated wildcards ([#1511](https://github.com/cap-js/cds-dbs/issues/1511)) ([b483062](https://github.com/cap-js/cds-dbs/commit/b483062e2ff5a8d0960dc2e7b71880af87ee8f78))
23
+ * the combination of `iterator` and `SELECT.one` ([#1514](https://github.com/cap-js/cds-dbs/issues/1514)) ([4b28579](https://github.com/cap-js/cds-dbs/commit/4b2857920a7a57bcfc09a9b5fb765283cf8bd70b))
24
+ * wildcard on inlined assoc ([#1513](https://github.com/cap-js/cds-dbs/issues/1513)) ([e520b97](https://github.com/cap-js/cds-dbs/commit/e520b97fd30394825b937b3613370c32c36c24a4))
25
+
7
26
  ## [2.8.2](https://github.com/cap-js/cds-dbs/compare/db-service-v2.8.1...db-service-v2.8.2) (2026-02-03)
8
27
 
9
28
 
package/lib/SQLService.js CHANGED
@@ -2,9 +2,9 @@ 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')
6
5
  const DatabaseService = require('./common/DatabaseService')
7
6
  const cqn4sql = require('./cqn4sql')
7
+ const { resolveTable } = require('./utils')
8
8
 
9
9
  const BINARY_TYPES = {
10
10
  'cds.Binary': 1,
@@ -168,7 +168,7 @@ class SQLService extends DatabaseService {
168
168
  return SQLService._arrayWithCount(rows, await this.count(query, rows))
169
169
  }
170
170
 
171
- return iterator !== false && isOne ? rows[0] : rows
171
+ return !iterator && isOne ? rows[0] : rows
172
172
  } catch (err) {
173
173
  // Ensure that iterators receive pre stream errors
174
174
  if (iterator) rows.emit('error', err)
@@ -230,7 +230,8 @@ 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
+ const transitions = resolve.transitions(req.query)
234
235
  if (transitions.target !== transitions.queryTarget) {
235
236
  const keys = []
236
237
  const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
@@ -253,7 +254,7 @@ class SQLService extends DatabaseService {
253
254
  })
254
255
  return this.onDELETE({ query, target: transitions.target })
255
256
  }
256
- const table = getDBTable(req.target)
257
+ const table = resolveTable(req.target)
257
258
  const { compositions } = table
258
259
  if (compositions) {
259
260
  // Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
@@ -404,10 +405,6 @@ class SQLService extends DatabaseService {
404
405
  */
405
406
  cqn2sql(query, values) {
406
407
  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
408
  let cqn2sql = new this.class.CQN2SQL(this)
412
409
  return cqn2sql.render(q, values)
413
410
  }
package/lib/cqn2sql.js CHANGED
@@ -1,6 +1,8 @@
1
1
  const cds = require('@sap/cds')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
+ const { resolveTable } = require('./utils')
5
+
4
6
  const _simple_queries = cds.env.features.sql_simple_queries
5
7
  const _strict_booleans = _simple_queries < 2
6
8
 
@@ -26,7 +28,8 @@ class CQN2SQLRenderer {
26
28
  if (cds.env.sql.names === 'quoted') {
27
29
  this.class.prototype.name = (name, query) => {
28
30
  const e = name.id || name
29
- return (query?._target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
31
+ const entity = query?._target || this.model?.definitions[e]
32
+ return (!entity?.['@cds.persistence.skip'] && entity?.['@cds.persistence.name']) || e
30
33
  }
31
34
  this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
32
35
  }
@@ -76,6 +79,7 @@ class CQN2SQLRenderer {
76
79
  */
77
80
  render(q, vars) {
78
81
  const kind = q.kind || Object.keys(q)[0] // SELECT, INSERT, ...
82
+ if (q._with) this._with = q._with
79
83
  /**
80
84
  * @type {string} the rendered SQL string
81
85
  */
@@ -90,7 +94,6 @@ class CQN2SQLRenderer {
90
94
  if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
91
95
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
92
96
 
93
-
94
97
  if (DEBUG && (LOG_SQL._debug || LOG_SQLITE._debug)) {
95
98
  let values = sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []
96
99
  if (values && !Array.isArray(values)) {
@@ -257,13 +260,15 @@ class CQN2SQLRenderer {
257
260
 
258
261
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
259
262
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
260
- const columns = this.SELECT_columns(q)
263
+
261
264
  let sql = `SELECT`
262
265
  if (distinct) sql += ` DISTINCT`
263
- if (!_empty(columns)) sql += ` ${columns}`
264
- if (recurse) sql += ` FROM ${this.SELECT_recurse(q)}`
265
- else if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
266
- else sql += this.from_dummy()
266
+ if (recurse) sql += this.SELECT_recurse(q)
267
+ else {
268
+ sql += ` ${this.SELECT_columns(q)}`
269
+ if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
270
+ else sql += this.from_dummy()
271
+ }
267
272
  if (!recurse && !_empty(where)) sql += ` WHERE ${this.where(where)}`
268
273
  if (!recurse && !_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
269
274
  if (!recurse && !_empty(having)) sql += ` HAVING ${this.having(having)}`
@@ -296,7 +301,7 @@ class CQN2SQLRenderer {
296
301
 
297
302
  // `where` needs to be wrapped to also support `where == ['exists', { SELECT }]` which is not allowed in `START WHERE`
298
303
  const clone = q.clone()
299
- clone.columns(keys)
304
+ clone.SELECT.columns = keys
300
305
  clone.SELECT.recurse = undefined
301
306
  clone.SELECT.limit = undefined
302
307
  clone.SELECT.expand = undefined // omits JSON
@@ -347,10 +352,16 @@ class CQN2SQLRenderer {
347
352
  for (const name in target.elements) {
348
353
  const ref = { ref: [name] }
349
354
  const element = target.elements[name]
350
- if (element.virtual || element.value || element.isAssociation) continue
351
- if (element['@Core.Computed'] && name in availableComputedColumns) continue
355
+ if (element.virtual || element.isAssociation) continue
356
+ if (name in availableComputedColumns) continue
352
357
  if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
353
- columnsIn.push(ref)
358
+ // This only supports calculated elements within the scope of the own entity
359
+ if ('value' in element) {
360
+ const requested = columnsFiltered.find(c => this.column_name(c) === element.name)
361
+ if (requested) columnsIn.push(requested)
362
+ else continue
363
+ }
364
+ else columnsIn.push(ref)
354
365
  const foreignkey4 = element._foreignKey4
355
366
  if (
356
367
  from.args ||
@@ -380,7 +391,7 @@ class CQN2SQLRenderer {
380
391
  )
381
392
 
382
393
  if (orderBy) {
383
- orderBy = orderBy.map(r => {
394
+ orderBy = orderBy.filter(o => o.ref).map(r => {
384
395
  let col = r.ref.at(-1)
385
396
  if (col.toUpperCase() in reservedColumnNames) col = `$$${col}$$`
386
397
  if (!columnsIn.find(c => this.column_name(c) === col)) {
@@ -497,13 +508,19 @@ class CQN2SQLRenderer {
497
508
  }
498
509
  }
499
510
 
511
+ const columnsQuery = cds.ql(q).clone()
512
+ columnsQuery.SELECT.columns = columns.map(x => {
513
+ if (x.element && 'value' in x.element) return { element: x.element, ref: [this.column_name(x)] }
514
+ return x
515
+ })
516
+ const recurseColumns = this.SELECT_columns(columnsQuery)
500
517
  // Only apply result join if the columns contain a references which doesn't start with the source alias
501
518
  if (from.args && columns.find(c => c.ref?.[0] === alias)) {
502
519
  graph.as = alias
503
- return this.from(setStableFrom(from, graph))
520
+ return ` ${recurseColumns} FROM ${this.from(setStableFrom(from, graph))}`
504
521
  }
505
522
 
506
- return `(${this.SELECT(graph)})${alias ? ` AS ${this.quote(alias)}` : ''} `
523
+ return ` ${recurseColumns} FROM (${this.SELECT(graph)})${alias ? ` AS ${this.quote(alias)}` : ''} `
507
524
 
508
525
  function collectDistanceTo(where, innot = false) {
509
526
  for (let i = 0; i < where.length; i++) {
@@ -730,6 +747,38 @@ class CQN2SQLRenderer {
730
747
  return this.xpr({ xpr })
731
748
  }
732
749
 
750
+ /**
751
+ * Renders a transformed where clause that maps the query target view to the source table
752
+ * @param {import('./infer/cqn').source} alias
753
+ * @param {import('./infer/cqn').predicate} where
754
+ * @param {import('./infer/cqn').query} q
755
+ * @returns SQL
756
+ */
757
+ where_resolved(alias, where, q) {
758
+ const transitions = this.srv.resolve.transitions(q)
759
+ if (transitions.target === transitions.queryTarget) return this.where(where)
760
+
761
+ // view and table column refs to be matched
762
+ const viewCols = []
763
+ const tableCols = []
764
+
765
+ // Only match key columns when possible
766
+ const elements = q._target.keys || q._target.elements
767
+ for (const c in elements) {
768
+ if (
769
+ c in elements
770
+ && transitions.mapping.has(c)
771
+ && this.physical_column(elements, c)
772
+ ) {
773
+ viewCols.push({ ref: [c] })
774
+ tableCols.push(transitions.mapping.get(c))
775
+ }
776
+ }
777
+ return tableCols.length > 0
778
+ ? this.where([{ list: tableCols }, 'in', SELECT.from(q._target).alias(alias).columns(viewCols).where(where)])
779
+ : this.where(where)
780
+ }
781
+
733
782
  /**
734
783
  * Renders a HAVING clause into generic SQL
735
784
  * @param {import('./infer/cqn').predicate} xpr
@@ -835,15 +884,20 @@ class CQN2SQLRenderer {
835
884
  if (!elements && !INSERT.entries?.length) {
836
885
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
837
886
  }
887
+ const transitions = this.srv.resolve.transitions(q)
838
888
  const columns = elements
839
- ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation)
889
+ ? ObjectKeys(elements).filter(c => this.physical_column(elements, c)
890
+ && (c = transitions.mapping.get(c)?.ref?.[0] || c)
891
+ && c in transitions.target.elements
892
+ && this.physical_column(transitions.target.elements, c)
893
+ )
840
894
  : ObjectKeys(INSERT.entries[0])
841
895
 
842
896
  /** @type {string[]} */
843
897
  this.columns = columns
844
898
 
845
899
  const alias = INSERT.into.as
846
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
900
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
847
901
  if (!elements) {
848
902
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
849
903
  const param = this.param.bind(this, { ref: ['?'] })
@@ -865,8 +919,8 @@ class CQN2SQLRenderer {
865
919
  }
866
920
 
867
921
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
868
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
869
- }) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
922
+ 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))
923
+ }) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`)
870
924
  }
871
925
 
872
926
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
@@ -972,7 +1026,7 @@ class CQN2SQLRenderer {
972
1026
  */
973
1027
  INSERT_rows(q) {
974
1028
  const { INSERT } = q
975
- const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
1029
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
976
1030
  const alias = INSERT.into.as
977
1031
  const elements = q.elements || q._target?.elements
978
1032
  const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
@@ -997,7 +1051,8 @@ class CQN2SQLRenderer {
997
1051
  .slice(0, columns.length)
998
1052
  .map(c => c.converter(c.extract))
999
1053
 
1000
- return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
1054
+ const transitions = this.srv.resolve.transitions(q)
1055
+ 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))
1001
1056
  }) SELECT ${extraction} FROM json_each(?)`)
1002
1057
  }
1003
1058
 
@@ -1018,20 +1073,24 @@ class CQN2SQLRenderer {
1018
1073
  */
1019
1074
  INSERT_select(q) {
1020
1075
  const { INSERT } = q
1021
- const entity = this.name(q._target.name, q)
1076
+ const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
1022
1077
  const alias = INSERT.into.as
1078
+ const src = this.cqn4sql(INSERT.from)
1023
1079
  const elements = q.elements || q._target?.elements || {}
1024
- let columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
1025
- c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
1026
- ))
1080
+ const transitions = this.srv.resolve.transitions(q)
1081
+ let columns = (this.columns = (INSERT.columns || src.SELECT.columns?.map(c => this.column_name(c)) || ObjectKeys(src.elements) || ObjectKeys(elements))
1082
+ .filter(c => this.physical_column(elements, c)
1083
+ && (c = transitions.mapping.get(c)?.ref?.[0] || c)
1084
+ && c in transitions.target.elements
1085
+ && this.physical_column(transitions.target.elements, c)
1086
+ ))
1027
1087
 
1028
- const src = this.cqn4sql(INSERT.from)
1029
1088
  const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements)
1030
1089
  const sql = extractions.length > columns.length
1031
1090
  ? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW`
1032
1091
  : this.SELECT(src)
1033
1092
  if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
1034
- this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(c))}) ${sql}`
1093
+ 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}`
1035
1094
  this.entries = [this.values]
1036
1095
  return this.sql
1037
1096
  }
@@ -1085,7 +1144,7 @@ class CQN2SQLRenderer {
1085
1144
  .join(' AND ')
1086
1145
 
1087
1146
  let columns = this.columns // this.columns is computed as part of this.INSERT
1088
- const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
1147
+ const entity = q._target ? this.table_name(q) : this.name(UPSERT.into.ref[0], q)
1089
1148
  if (UPSERT.entries || UPSERT.rows || UPSERT.values) {
1090
1149
  const managed = this._managed.slice(0, columns.length)
1091
1150
 
@@ -1121,7 +1180,8 @@ class CQN2SQLRenderer {
1121
1180
  else return true
1122
1181
  }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
1123
1182
 
1124
- return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
1183
+ const transitions = this.srv.resolve.transitions(q)
1184
+ return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql
1125
1185
  } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
1126
1186
  }
1127
1187
 
@@ -1134,29 +1194,36 @@ class CQN2SQLRenderer {
1134
1194
  */
1135
1195
  UPDATE(q) {
1136
1196
  const { entity, with: _with, data, where } = q.UPDATE
1197
+ const transitions = this.srv.resolve.transitions(q)
1137
1198
  const elements = q._target?.elements
1138
- let sql = `UPDATE ${this.quote(this.name(entity.ref?.[0] || entity, q))}`
1199
+ let sql = `UPDATE ${this.quote(this.table_name(q))}`
1139
1200
  if (entity.as) sql += ` AS ${this.quote(entity.as)}`
1140
1201
 
1141
- let columns = []
1142
- if (data) _add(data, val => this.val({ val }))
1143
- if (_with) _add(_with, x => this.expr(x))
1144
- function _add(data, sql4) {
1145
- for (let c in data) {
1146
- const columnExistsInDatabase =
1147
- elements && c in elements && !elements[c].virtual && !elements[c].isAssociation && !elements[c].value
1202
+ const _add = (data, sql4) => {
1203
+ for (let col in data) {
1204
+ const c = transitions.mapping.get(col)?.ref?.[0] || col
1205
+ const columnExistsInDatabase = elements
1206
+ && this.physical_column(elements, col)
1207
+ && c in transitions.target.elements
1208
+ && this.physical_column(transitions.target.elements, c)
1148
1209
  if (!elements || columnExistsInDatabase) {
1149
- columns.push({ name: c, sql: sql4(data[c]) })
1210
+ columns.push({ name: c, sql: sql4(data[col], col) })
1150
1211
  }
1151
1212
  }
1152
1213
  }
1153
1214
 
1215
+ let columns = []
1216
+ if (data) _add(data, val => this.val({ val }))
1217
+ if (_with) _add(_with, x => this.expr(x))
1218
+
1154
1219
  const extraction = this.managed(columns, elements)
1155
- .filter((c, i) => columns[i] || c.onUpdate)
1156
- .map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1220
+ .filter((c, i) => {
1221
+ if (transitions.mapping.get(c.name)?.ref?.length > 1) return false
1222
+ return columns[i] || c.onUpdate
1223
+ }).map((c, i) => `${this.quote(transitions.mapping.get(c.name)?.ref?.[0] || c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
1157
1224
 
1158
1225
  sql += ` SET ${extraction}`
1159
- if (where) sql += ` WHERE ${this.where(where)}`
1226
+ if (where) sql += ` WHERE ${this.where_resolved(entity.as, where, q)}`
1160
1227
  return (this.sql = sql)
1161
1228
  }
1162
1229
 
@@ -1168,8 +1235,9 @@ class CQN2SQLRenderer {
1168
1235
  * @returns {string} SQL
1169
1236
  */
1170
1237
  DELETE(q) {
1171
- const { DELETE: { from, where } } = q
1172
- let sql = `DELETE FROM ${this.from(from, q)}`
1238
+ const { DELETE: { where, from } } = q
1239
+ let sql = `DELETE FROM ${this.quote(this.table_name(q))}`
1240
+ if (from.as) sql += ` AS ${this.quote(from.as)}`
1173
1241
  if (where) sql += ` WHERE ${this.where(where)}`
1174
1242
  return (this.sql = sql)
1175
1243
  }
@@ -1382,6 +1450,16 @@ class CQN2SQLRenderer {
1382
1450
  return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
1383
1451
  }
1384
1452
 
1453
+ /**
1454
+ * Calculates the Database table name of the query target
1455
+ * @param {import('./infer/cqn').Query} query
1456
+ * @returns {string} Database table name
1457
+ */
1458
+ table_name(q) {
1459
+ const table = resolveTable(q._target)
1460
+ return this.name(table.name, { _target: table })
1461
+ }
1462
+
1385
1463
  /**
1386
1464
  * Calculates the Database name of the given name
1387
1465
  * @param {string|import('./infer/cqn').ref} name
@@ -1489,6 +1567,10 @@ class CQN2SQLRenderer {
1489
1567
  })
1490
1568
  }
1491
1569
 
1570
+ physical_column(elements, c) {
1571
+ return elements[c] && !elements[c].virtual && !elements[c].value && !elements[c].isAssociation
1572
+ }
1573
+
1492
1574
  managed_extract(name, element, converter) {
1493
1575
  const { UPSERT, INSERT } = this.cqn
1494
1576
  const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)
package/lib/cqn4sql.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  defineProperty,
14
14
  getModelUtils,
15
15
  hasOwnSkip,
16
+ isRuntimeView,
16
17
  } = require('./utils')
17
18
 
18
19
  /**
@@ -173,8 +174,114 @@ function cqn4sql(originalQuery, model) {
173
174
  }
174
175
  }
175
176
 
177
+ if (cds.env.features.runtime_views) processRuntimeViews(transformedQuery, model)
178
+
176
179
  return transformedQuery
177
180
 
181
+ /**
182
+ * If the target entity is annotated with persistence skip and has an underlying db entity,
183
+ * we treat it as a runtime view and transform it into a CTE.
184
+ *
185
+ * @param {object} transformedQuery - The query object to be transformed.
186
+ * @param {string} model - The data model used for inference and transformation.
187
+ */
188
+ function processRuntimeViews(transformedQuery, model) {
189
+ const currentDef = transformedQuery._target
190
+
191
+ if (hasOwnSkip(currentDef)) {
192
+ if (!isRuntimeView(currentDef)) throw new Error(`${currentDef.name} is not a runtime view`)
193
+
194
+ addWith(currentDef, transformedQuery, model)
195
+ updateRefsWithRTVAlias(transformedQuery._with, transformedQuery)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Recursively call cqn4sql for all nested runtime views to calculate cte and
201
+ * add it as a with clause to the transformed query.
202
+ * Alias the runtime view with a unique alias and update all references to the runtime view to point to the alias.
203
+ *
204
+ * @param {object} rootDefinition - The root definition of the query. This is used to recursively process nested runtime views.
205
+ * @param {object} transformedQuery - The query object to be transformed.
206
+ * @param {string} model - The data model used for infer and cqn4sql.
207
+ */
208
+ function addWith(rootDefinition, transformedQuery, model) {
209
+ if (!rootDefinition?.query) return
210
+
211
+ // early exit if already processed
212
+ if (transformedQuery._with?.some(w => w._source === rootDefinition)) return
213
+
214
+ const q = cds.ql.clone(rootDefinition.query)
215
+ if (q.SELECT) {
216
+ if (!q.SELECT.columns) q.SELECT.columns = ['*']
217
+ if (q.SELECT.columns.includes('*')) {
218
+ // cache element names for faster lookup
219
+ const existingColumns = new Set(
220
+ q.SELECT.columns
221
+ .map(col => col.as || col.ref?.at(-1))
222
+ .filter(Boolean)
223
+ )
224
+
225
+ for (let el of rootDefinition.elements) {
226
+ if (el.type === 'cds.LargeBinary' && !existingColumns.has(el.name)) {
227
+ q.SELECT.columns.push({ ref: [el.name] })
228
+ }
229
+ }
230
+ }
231
+ }
232
+ const inferredDQ = infer(q, model)
233
+ inferredDQ._with = transformedQuery._with
234
+ const transformedDQ = cqn4sql(inferredDQ, model)
235
+
236
+ if (q.SELECT?.from?.args) {
237
+ for (const arg of q.SELECT.from.args) {
238
+ addWith(arg.$refLinks.at(-1).definition, inferredDQ, model)
239
+ arg.as ??= arg.ref.at(-1).split('.').at(-1) // apply @sap/cds-compiler default alias
240
+ updateRefsWithRTVAlias(inferredDQ._with, transformedDQ, arg.ref)
241
+ }
242
+ }
243
+
244
+ const newWiths = transformedDQ._with || []
245
+ const rootDefinitionName = rootDefinition.name
246
+
247
+ defineProperty(transformedDQ, '_source', rootDefinition)
248
+ const alias = `RTV_${getImplicitAlias(rootDefinitionName)}`
249
+ transformedDQ.as = transformedDQ.joinTree.addNextAvailableTableAlias(alias, newWiths, rootDefinitionName)
250
+
251
+ // update SELECT.from with runtime view alias
252
+ if (hasOwnSkip(transformedDQ._target)) updateRefsWithRTVAlias(transformedDQ._with, transformedDQ)
253
+
254
+ if (transformedDQ._with) delete transformedDQ._with
255
+ newWiths.push(transformedDQ)
256
+
257
+ // propagate with clauses
258
+ if (!transformedQuery._with) transformedQuery._with = newWiths
259
+ }
260
+
261
+ function updateRefsWithRTVAlias(_with, query, ref) {
262
+ if (!_with?.length) return
263
+
264
+ const _updateRef = (ref) => {
265
+ const refAlias = ref[0]
266
+ if (/RTV_$/.test(refAlias)) return
267
+ for (const w of _with) {
268
+ const aliasValue = w.joinTree._queryAliases.get(refAlias)
269
+ if (aliasValue) {
270
+ ref[0] = aliasValue
271
+ if (query.joinTree?._queryAliases) {
272
+ query.joinTree._queryAliases.set(refAlias, aliasValue)
273
+ }
274
+ break
275
+ }
276
+ }
277
+ }
278
+
279
+ if (ref) return _updateRef(ref)
280
+
281
+ if (query.SELECT.from.args) for (const arg of query.SELECT.from.args) _updateRef(arg.ref)
282
+ else if (query.SELECT.from.ref) _updateRef(query.SELECT.from.ref)
283
+ }
284
+
178
285
  function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
179
286
  const { columns, having, groupBy, orderBy, limit } = queryProp
180
287
 
@@ -314,7 +421,9 @@ function cqn4sql(originalQuery, model) {
314
421
  ),
315
422
  )
316
423
 
317
- const id = getDefinition(nextAssoc.$refLink.definition.target).name
424
+ const def = getDefinition(nextAssoc.$refLink.definition.target)
425
+ const id = def.name
426
+ if (hasOwnSkip(def) && isRuntimeView(def)) addWith(model.definitions[id], transformedQuery, model)
318
427
  const { args } = nextAssoc
319
428
  const arg = {
320
429
  ref: [args ? { id, args } : id],
@@ -352,7 +461,7 @@ function cqn4sql(originalQuery, model) {
352
461
  for (let i = 0; i < columns.length; i++) {
353
462
  const col = columns[i]
354
463
 
355
- if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
464
+ if (isCalculatedOnRead(col.$refLinks?.at(-1).definition) && !col.$refLinks?.at(-1).target?.SELECT) {
356
465
  const name = getName(col)
357
466
  if (!transformedColumns.some(inserted => getName(inserted) === name)) {
358
467
  const calcElement = resolveCalculatedElement(col)
@@ -473,7 +582,13 @@ function cqn4sql(originalQuery, model) {
473
582
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
474
583
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
475
584
 
476
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
585
+ if (
586
+ col.$refLinks.some(link => {
587
+ const def = getDefinition(link.definition.target)
588
+ return hasOwnSkip(def) && !isRuntimeView(def)
589
+ })
590
+ )
591
+ return
477
592
 
478
593
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
479
594
  flatColumns.forEach(flatColumn => {
@@ -560,6 +675,7 @@ function cqn4sql(originalQuery, model) {
560
675
  res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
561
676
  }
562
677
  if (!omitAlias) res.as = column.as || column.name || column.flatName
678
+ setElementOnColumns(res, column.element || column)
563
679
  return res
564
680
  }
565
681
 
@@ -643,8 +759,7 @@ function cqn4sql(originalQuery, model) {
643
759
  nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
644
760
  const name = nameParts.join('_')
645
761
  if (nestedProjection.ref) {
646
- const augmentedInlineCol = { ...nestedProjection }
647
- augmentedInlineCol.ref = col.ref ? [...col.ref, ...nestedProjection.ref] : nestedProjection.ref
762
+ const augmentedInlineCol = augmentInlineRefWithParent(nestedProjection, col)
648
763
  if (
649
764
  col.as ||
650
765
  nestedProjection.as ||
@@ -653,19 +768,6 @@ function cqn4sql(originalQuery, model) {
653
768
  ) {
654
769
  augmentedInlineCol.as = nameParts.join('_')
655
770
  }
656
- Object.defineProperties(augmentedInlineCol, {
657
- $refLinks: { value: [...nestedProjection.$refLinks], writable: true },
658
- isJoinRelevant: {
659
- value: nestedProjection.isJoinRelevant,
660
- writable: true,
661
- },
662
- })
663
- // if the expand is not anonymous, we must prepend the expand columns path
664
- // to make sure the full path is resolvable
665
- if (col.ref) {
666
- augmentedInlineCol.$refLinks.unshift(...col.$refLinks)
667
- augmentedInlineCol.isJoinRelevant = augmentedInlineCol.isJoinRelevant || col.isJoinRelevant
668
- }
669
771
  const flatColumns = getTransformedColumns([augmentedInlineCol])
670
772
  flatColumns.forEach(flatColumn => {
671
773
  const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
@@ -682,6 +784,10 @@ function cqn4sql(originalQuery, model) {
682
784
  if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
683
785
  const rewrittenColumn = { ...nestedProjection }
684
786
  rewrittenColumn.as = name
787
+ // For xpr, we need to transform refs inside to include the struct prefix
788
+ if (nestedProjection.xpr && col.ref) {
789
+ rewrittenColumn.xpr = augmentInlineXprRefs(nestedProjection.xpr, col)
790
+ }
685
791
  rewrittenColumns.push(rewrittenColumn)
686
792
  }
687
793
  }
@@ -690,6 +796,53 @@ function cqn4sql(originalQuery, model) {
690
796
  })
691
797
 
692
798
  return res
799
+
800
+ /**
801
+ * Augment a ref column with the parent column's path and $refLinks.
802
+ */
803
+ function augmentInlineRefWithParent(refCol, parentCol) {
804
+ const augmented = { ...refCol }
805
+ augmented.ref = parentCol.ref ? [...parentCol.ref, ...refCol.ref] : refCol.ref
806
+ Object.defineProperties(augmented, {
807
+ $refLinks: { value: [...refCol.$refLinks], writable: true },
808
+ isJoinRelevant: { value: refCol.isJoinRelevant, writable: true },
809
+ })
810
+ if (parentCol.ref) {
811
+ augmented.$refLinks.unshift(...parentCol.$refLinks)
812
+ augmented.isJoinRelevant = augmented.isJoinRelevant || parentCol.isJoinRelevant
813
+ }
814
+ return augmented
815
+ }
816
+
817
+ /**
818
+ * Augment refs inside an xpr with the parent column's path and $refLinks,
819
+ * then transform them to flat refs.
820
+ */
821
+ function augmentInlineXprRefs(xpr, parentCol) {
822
+ return xpr.map(token => {
823
+ if (typeof token === 'string' || token.val !== undefined) {
824
+ return token
825
+ }
826
+ if (token.ref && token.$refLinks) {
827
+ const augmented = augmentInlineRefWithParent(token, parentCol)
828
+ // Transform this single ref column to get the flat version
829
+ const transformed = getTransformedColumns([augmented])
830
+ if (transformed.length === 1) {
831
+ return transformed[0]
832
+ }
833
+ return augmented
834
+ }
835
+ if (token.xpr) {
836
+ return { ...token, xpr: augmentInlineXprRefs(token.xpr, parentCol) }
837
+ }
838
+ if (token.func && token.args) {
839
+ return { ...token, args: token.args.map(arg =>
840
+ arg.ref ? augmentInlineXprRefs([arg], parentCol)[0] : arg
841
+ )}
842
+ }
843
+ return token
844
+ })
845
+ }
693
846
  }
694
847
 
695
848
  /**
@@ -747,6 +900,9 @@ function cqn4sql(originalQuery, model) {
747
900
 
748
901
  if (baseRefLinks.at(-1).definition.kind === 'entity') {
749
902
  res.push(...getColumnsForWildcard(exclude, replace, col.as))
903
+ } else if (baseRefLinks.at(-1).definition.target) {
904
+ // Wildcard on association - need to include FK columns and join-relevant target columns
905
+ res.push(...expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace))
750
906
  } else
751
907
  res.push(
752
908
  ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
@@ -757,6 +913,117 @@ function cqn4sql(originalQuery, model) {
757
913
  return res
758
914
  }
759
915
 
916
+ /**
917
+ * Expands a wildcard on an association into:
918
+ * 1. FK columns from the source table
919
+ * 2. Non-FK columns from the target via join
920
+ */
921
+ function expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace) {
922
+ const res = []
923
+ const assocDef = baseRefLinks.at(-1).definition
924
+ const targetDef = getDefinition(assocDef.target)
925
+ const columnAlias = col.as || baseRef.map(idOnly).join('_')
926
+ const sourceTableAlias = getTableAlias(col)
927
+
928
+ // Get the join alias for this association (set during join tree merge)
929
+ const joinAlias = baseRefLinks.at(-1).alias
930
+
931
+ // Collect FK element names
932
+ const fkNames = new Set()
933
+ if (assocDef.keys) {
934
+ for (const k of assocDef.keys) {
935
+ fkNames.add(k.ref[0])
936
+ }
937
+ }
938
+
939
+ // First, add FK columns from source table
940
+ // These are accessed via the source table alias, not the join
941
+ const fkColumns = getFlatColumnsFor(col, { tableAlias: sourceTableAlias }, [], {
942
+ exclude,
943
+ replace,
944
+ })
945
+ res.push(...fkColumns.filter(fk => !col.excluding?.some(e => targetDef.elements[e] === fk.element)))
946
+
947
+ // Then, add non-FK columns from target via join
948
+ if (targetDef?.elements) {
949
+ for (const [elemName, elemDef] of Object.entries(targetDef.elements)) {
950
+ // Skip FK elements (already included above), virtual, blobs, and unmanaged assocs
951
+ if (fkNames.has(elemName)) continue
952
+ if (elemDef.virtual) continue
953
+ if (elemDef.type === 'cds.LargeBinary') continue
954
+ if (elemDef.on && !elemDef.keys) continue // unmanaged association
955
+
956
+ // Check exclusions
957
+ const fullName = `${columnAlias}_${elemName}`
958
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === elemName)) continue
959
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fullName)) continue
960
+
961
+ // Check for replacement
962
+ const replacement = replace.find(r => (r.ref?.at(-1) || r.as) === elemName)
963
+ if (replacement) {
964
+ // Handle replacement - create augmented column
965
+ const augmented = { ...replacement }
966
+ augmented.as = fullName
967
+ res.push(...getTransformedColumns([augmented]))
968
+ continue
969
+ }
970
+
971
+ // Create column referencing the join alias
972
+ if (elemDef.elements) {
973
+ // Structured element - need to flatten it
974
+ const structCols = getFlatColumnsFor(
975
+ elemDef,
976
+ { baseName: elemName, columnAlias: fullName, tableAlias: joinAlias },
977
+ [],
978
+ { exclude, replace },
979
+ true,
980
+ )
981
+ res.push(...structCols)
982
+ } else if (elemDef.keys) {
983
+ // Association element - flatten its foreign keys
984
+ // The FK column name is: assocName_keyName (e.g., 'head_id')
985
+ for (const k of elemDef.keys) {
986
+ const keyName = k.as || k.ref.join('_')
987
+ const fkName = `${elemName}_${keyName}` // e.g., 'head_id'
988
+ const fkFullName = `${columnAlias}_${fkName}` // e.g., 'department_head_id'
989
+
990
+ // Check if this FK is excluded
991
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkName)) continue
992
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkFullName)) continue
993
+
994
+ const flatColumn = {
995
+ ref: [joinAlias, fkName],
996
+ as: fkFullName,
997
+ }
998
+ const fkElement = getElementForRef(k.ref, getDefinition(elemDef.target))
999
+ setElementOnColumns(flatColumn, fkElement)
1000
+ res.push(flatColumn)
1001
+ }
1002
+ } else if (elemDef.value) {
1003
+ // Calculated element - resolve it
1004
+ const calcElement = resolveCalculatedElement({ $refLinks: [{ definition: elemDef }] }, true)
1005
+ if (calcElement.as) {
1006
+ calcElement.as = fullName
1007
+ } else {
1008
+ calcElement.as = fullName
1009
+ }
1010
+ res.push(calcElement)
1011
+ }
1012
+ else {
1013
+ // Scalar element
1014
+ const flatColumn = {
1015
+ ref: [joinAlias, elemName],
1016
+ as: fullName,
1017
+ }
1018
+ setElementOnColumns(flatColumn, elemDef)
1019
+ res.push(flatColumn)
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ return res
1025
+ }
1026
+
760
1027
  /**
761
1028
  * This function converts a column with an `expand` property into a subquery.
762
1029
  *
@@ -972,7 +1239,12 @@ function cqn4sql(originalQuery, model) {
972
1239
  } else if (pseudos.elements[col.ref?.[0]]) {
973
1240
  res.push({ ...col })
974
1241
  } else if (col.ref) {
975
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
1242
+ if (
1243
+ col.$refLinks.some(link => {
1244
+ const def = getDefinition(link.definition.target)
1245
+ return hasOwnSkip(def) && !isRuntimeView(def)
1246
+ })
1247
+ )
976
1248
  continue
977
1249
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
978
1250
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1070,10 +1342,18 @@ function cqn4sql(originalQuery, model) {
1070
1342
  outerQueries.push(inferred)
1071
1343
  defineProperty(q, 'outerQueries', outerQueries)
1072
1344
  }
1345
+
1073
1346
  const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
1074
1347
  if (isLocalized(target)) q.SELECT.localized = true
1075
1348
  if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
1076
- return cqn4sql(q, model)
1349
+ if (cds.env.features.runtime_views) q._with = transformedQuery._with
1350
+ const _q = cqn4sql(q, model)
1351
+ if (cds.env.features.runtime_views && _q._with) {
1352
+ if (!transformedQuery._with) transformedQuery._with = _q._with
1353
+ delete _q._with
1354
+ }
1355
+ return _q
1356
+
1077
1357
 
1078
1358
  function assignUniqueSubqueryAlias() {
1079
1359
  if (q.SELECT.from.uniqueSubqueryAlias) return
@@ -1213,7 +1493,7 @@ function cqn4sql(originalQuery, model) {
1213
1493
  columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1214
1494
  } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
1215
1495
 
1216
- if(column.element && !isAssocOrStruct(column.element)) {
1496
+ if (column.element && !isAssocOrStruct(column.element)) {
1217
1497
  columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1218
1498
  const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1219
1499
  setElementOnColumns(res, column.element)
@@ -1503,7 +1783,7 @@ function cqn4sql(originalQuery, model) {
1503
1783
  throw new Error(`The operator "${next}" can only be used with scalar operands`)
1504
1784
 
1505
1785
  const newTokens = expandComparison(token, ops, rhs, $baseLink)
1506
- if(newTokens.length === 0)
1786
+ if (newTokens.length === 0)
1507
1787
  throw new Error(`Can't compare two empty structures`)
1508
1788
 
1509
1789
  const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
@@ -1538,9 +1818,9 @@ function cqn4sql(originalQuery, model) {
1538
1818
  const lastAssoc =
1539
1819
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1540
1820
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1541
- if(isAssocOrStruct(definition)) {
1542
- const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1543
- if(flat.length === 0)
1821
+ if (isAssocOrStruct(definition)) {
1822
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1823
+ if (flat.length === 0)
1544
1824
  throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1545
1825
  else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1546
1826
  throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
@@ -1673,7 +1953,7 @@ function cqn4sql(originalQuery, model) {
1673
1953
 
1674
1954
  function assertNoStructInXpr(token, context) {
1675
1955
  const definition = token.$refLinks?.at(-1).definition
1676
- if(!definition) return
1956
+ if (!definition) return
1677
1957
  const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1678
1958
  // unmanaged is always forbidden
1679
1959
  // expanding a ref in a `where`/`having` context
@@ -1785,7 +2065,7 @@ function cqn4sql(originalQuery, model) {
1785
2065
 
1786
2066
  // OData variant w/o mentioning key
1787
2067
  if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1788
- filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
2068
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where, { $baseLink: $refLinksReverse[0] }))
1789
2069
  }
1790
2070
 
1791
2071
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
@@ -2231,7 +2511,7 @@ function cqn4sql(originalQuery, model) {
2231
2511
  return SELECT
2232
2512
  }
2233
2513
 
2234
- /**
2514
+ /**
2235
2515
  * For a given search term calculate a search expression which can be used in a where clause.
2236
2516
  * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2237
2517
  * the search results of the subquery.
@@ -2259,13 +2539,13 @@ function cqn4sql(originalQuery, model) {
2259
2539
  // for aggregated queries / search on subqueries we do not do a subquery search
2260
2540
  if (inferred.SELECT.groupBy || entity.SELECT)
2261
2541
  return searchFunc
2262
-
2542
+
2263
2543
  const matchColumns = getPrimaryKey(entity)
2264
2544
  if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2265
2545
  return searchFunc
2266
-
2267
- const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2268
- return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
2546
+
2547
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2548
+ return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] }
2269
2549
  }
2270
2550
 
2271
2551
  /**
@@ -2282,7 +2562,7 @@ function cqn4sql(originalQuery, model) {
2282
2562
  if (!node || !node.$refLinks || !node.ref) {
2283
2563
  throw new Error('Invalid node')
2284
2564
  }
2285
- if(node.$refLinks[0].$main) {
2565
+ if (node.$refLinks[0].$main) {
2286
2566
  if (node.isJoinRelevant) {
2287
2567
  return getJoinRelevantAlias(node)
2288
2568
  }
@@ -2425,7 +2705,7 @@ function assignQueryModifiers(SELECT, modifiers) {
2425
2705
  else SELECT.having.push('and', ...val)
2426
2706
  } else if (key === 'where') {
2427
2707
  // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2428
- if(!val || val.length === 1 && val[0].val) continue
2708
+ if (!val || val.length === 1 && val[0].val) continue
2429
2709
  if (!SELECT.where) SELECT.where = val
2430
2710
  // infix filter comes first in resulting where
2431
2711
  else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]
@@ -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, hasOwnSkip } = require('../utils')
7
+ const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip, isRuntimeView } = require('../utils')
8
8
  const cdsTypes = cds.builtin.types
9
9
  /**
10
10
  * @param {import('@sap/cds/apis/cqn').Query|string} originalQuery
@@ -191,6 +191,7 @@ function infer(originalQuery, model) {
191
191
  const dollarSelfRefs = []
192
192
  columns.forEach(col => {
193
193
  if (col === '*') {
194
+ if (wildcardSelect) throw new Error('Duplicate wildcard "*" in column list')
194
195
  wildcardSelect = true
195
196
  } else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
196
197
  const as = col.as || col.func || col.val
@@ -459,10 +460,10 @@ function infer(originalQuery, model) {
459
460
  const element = elements[id]
460
461
  if (inInfixFilter) {
461
462
  const nextStep = arg.ref[1]?.id || arg.ref[1]
462
- if (isNonForeignKeyNavigation(element, nextStep)) {
463
+ if (isNonForeignKeyNavigation(element, nextStep) || arg.ref[0]?.where) {
463
464
  if (inExists) {
464
465
  defineProperty($baseLink, 'pathExpressionInsideFilter', true)
465
- } else {
466
+ } else if (!inFrom) {
466
467
  rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
467
468
  }
468
469
  }
@@ -523,10 +524,10 @@ function infer(originalQuery, model) {
523
524
  if (element) {
524
525
  if ($baseLink && inInfixFilter) {
525
526
  const nextStep = arg.ref[i + 1]?.id || arg.ref[i + 1]
526
- if (isNonForeignKeyNavigation(element, nextStep)) {
527
+ if (isNonForeignKeyNavigation(element, nextStep) || arg.ref[i-1]?.where) {
527
528
  if (inExists) {
528
529
  defineProperty($baseLink, 'pathExpressionInsideFilter', true)
529
- } else {
530
+ } else if (!inFrom) {
530
531
  rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
531
532
  }
532
533
  }
@@ -565,7 +566,7 @@ function infer(originalQuery, model) {
565
566
  if (step.where) {
566
567
  const danglingFilter = !(arg.ref[i + 1] || arg.expand || arg.inline || inExists)
567
568
  const definition = arg.$refLinks[i].definition
568
- if ((!definition.target && definition.kind !== 'entity') || (!inFrom && danglingFilter))
569
+ if ((!definition.target && definition.kind !== 'entity') || (!inFrom && !inCalcElement && danglingFilter))
569
570
  throw new Error('A filter can only be provided when navigating along associations')
570
571
  if (!inFrom && !arg.expand)defineProperty(arg, 'isJoinRelevant', true)
571
572
  let skipJoinsForFilter = false
@@ -574,9 +575,11 @@ function infer(originalQuery, model) {
574
575
  // books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
575
576
  skipJoinsForFilter = true
576
577
  } else if (token.ref || token.xpr || token.list) {
578
+ // For scoped queries (non-dangling filters in FROM), treat filter contents as EXISTS context
579
+ // because they will become part of an EXISTS subquery
577
580
  inferArg(token, false, arg.$refLinks[i], {
578
581
  ...context,
579
- inExists: skipJoinsForFilter || inExists,
582
+ inExists: skipJoinsForFilter || inExists || (inFrom && !danglingFilter),
580
583
  inXpr: !!token.xpr,
581
584
  inInfixFilter: true,
582
585
  inFrom,
@@ -586,7 +589,7 @@ function infer(originalQuery, model) {
586
589
  applyToFunctionArgs(token.args, inferArg, [
587
590
  false,
588
591
  arg.$refLinks[i],
589
- { inExists: skipJoinsForFilter || inExists, inXpr: true, inInfixFilter: true, inFrom },
592
+ { inExists: skipJoinsForFilter || inExists || (inFrom && !danglingFilter), inXpr: true, inInfixFilter: true, inFrom },
590
593
  ])
591
594
  }
592
595
  }
@@ -595,7 +598,8 @@ function infer(originalQuery, model) {
595
598
 
596
599
  if(!arg.$refLinks[i].$main)
597
600
  arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
598
- if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
601
+ const def = getDefinition(arg.$refLinks[i].definition.target)
602
+ if (hasOwnSkip(def) && !isRuntimeView(def)) isPersisted = false
599
603
  if (!arg.ref[i + 1]) {
600
604
  const flatName = nameSegments.join('_')
601
605
  defineProperty(arg, 'flatName', flatName)
@@ -655,7 +659,11 @@ function infer(originalQuery, model) {
655
659
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
656
660
  if (arg.expand) {
657
661
  const { $refLinks } = arg
658
- const skip = $refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))
662
+
663
+ const skip = $refLinks.some(link => {
664
+ const def = getDefinition(link.definition.target)
665
+ return hasOwnSkip(def) && !isRuntimeView(def)
666
+ })
659
667
  if (skip) {
660
668
  $refLinks[$refLinks.length - 1].skipExpand = true
661
669
  return
@@ -708,24 +716,65 @@ function infer(originalQuery, model) {
708
716
  )
709
717
  }
710
718
  let elements = {}
719
+ let seenWildcard = false
711
720
  inline.forEach(inlineCol => {
712
721
  inferArg(inlineCol, null, $leafLink, { inXpr: true, baseColumn: col })
713
722
  if (inlineCol === '*') {
723
+ if (seenWildcard) throw new Error(`Duplicate wildcard "*" in inline of "${col.as || col.ref.map(idOnly).join('_')}"`)
724
+ seenWildcard = true
714
725
  const wildCardElements = {}
715
726
  // either the `.elements´ of the struct or the `.elements` of the assoc target
716
- const leafLinkElements = getDefinition($leafLink.definition.target)?.elements || $leafLink.definition.elements
727
+ const targetDef = getDefinition($leafLink.definition.target)
728
+ const leafLinkElements = targetDef?.elements || $leafLink.definition.elements
729
+ const isAssociation = !!$leafLink.definition.target
730
+
731
+ const deferredCalcElements = []
717
732
  Object.entries(leafLinkElements).forEach(([k, v]) => {
718
733
  const name = namePrefix ? `${namePrefix}_${k}` : k
719
734
  // if overwritten/excluded omit from wildcard elements
720
735
  // in elements the names are already flat so consider the prefix
721
736
  // in excluding, the elements are addressed without the prefix
722
- if (!(name in elements || col.excluding?.includes(k))) wildCardElements[name] = v
737
+ if (!(name in elements || col.excluding?.includes(k))) {
738
+ wildCardElements[name] = v
739
+
740
+ if(v.value) {
741
+ // defer linkCalculatedElement calls until after all association joins are registered
742
+ // so that the join tree order is correct
743
+ deferredCalcElements.push({ k, v })
744
+ }
745
+ else if (isAssociation && !v.virtual && v.type !== 'cds.LargeBinary' && !(v.on && !v.keys)) {
746
+ // Check if this element is a foreign key (FK elements don't need join)
747
+ const isFK = $leafLink.definition.keys?.some(key => key.ref[0] === k)
748
+ if (!isFK) {
749
+ // Create a fake column with ref [<inlined assoc>, <element name>] and proper $refLinks
750
+ const fakeCol = {
751
+ ref: [...col.ref, k],
752
+ }
753
+ // Copy $refLinks and add new link for the target element with proper alias
754
+ const fakeRefLinks = [
755
+ ...$refLinks,
756
+ { definition: v, target: targetDef, alias: k }
757
+ ]
758
+ defineProperty(fakeCol, '$refLinks', fakeRefLinks)
759
+ defineProperty(fakeCol, 'isJoinRelevant', true)
760
+ // Merge into join tree
761
+ inferred.joinTree.mergeColumn(fakeCol, originalQuery.outerQueries)
762
+ }
763
+ }
764
+ }
723
765
  })
766
+ // link calculated elements after association joins are registered in the join tree
767
+ for (const { k, v } of deferredCalcElements) {
768
+ linkCalculatedElement(
769
+ { ref: [k], $refLinks: [{ definition: v, target: targetDef }] },
770
+ $leafLink,
771
+ )
772
+ }
724
773
  elements = { ...elements, ...wildCardElements }
725
774
  } else {
726
775
  const nameParts = namePrefix ? [namePrefix] : []
727
776
  if (inlineCol.as) nameParts.push(inlineCol.as)
728
- else nameParts.push(...inlineCol.ref.map(idOnly))
777
+ else if (inlineCol.ref) nameParts.push(...inlineCol.ref.map(idOnly))
729
778
  const name = nameParts.join('_')
730
779
  if (inlineCol.inline) {
731
780
  const inlineElements = resolveInline(inlineCol, name)
@@ -737,6 +786,8 @@ function infer(originalQuery, model) {
737
786
  elements[name] = getCdsTypeForVal(inlineCol.val)
738
787
  } else if (inlineCol.func) {
739
788
  elements[name] = {}
789
+ } else if (inlineCol.xpr) {
790
+ elements[name] = {}
740
791
  } else {
741
792
  elements[name] = inlineCol.$refLinks[inlineCol.$refLinks.length - 1].definition
742
793
  }
@@ -764,6 +815,16 @@ function infer(originalQuery, model) {
764
815
  `Unexpected “expand” on “${col.ref.map(idOnly)}”; can only be used after a reference to a structure, association or table alias`,
765
816
  )
766
817
  }
818
+ // Check for duplicate wildcards before creating the subquery
819
+ let seenWildcard = false
820
+ for (const e of expand) {
821
+ if (e === '*') {
822
+ if (seenWildcard) {
823
+ throw new Error(`Duplicate wildcard "*" in expand of "${col.as || col.ref.map(idOnly).join('_')}"`)
824
+ }
825
+ seenWildcard = true
826
+ }
827
+ }
767
828
  const target = getDefinition($leafLink.definition.target)
768
829
  if (target) {
769
830
  const expandSubquery = {
@@ -133,20 +133,22 @@ class JoinTree {
133
133
  *
134
134
  * @param {string} alias - The original alias name.
135
135
  * @param {unknown[]} outerQueries - An array of outer queries.
136
+ * @param {string} key - The key to be used for storing the alias in the map. If not provided, the upper-case version of the alias will be used as the key.
136
137
  * @returns {string} - The next unambiguous table alias.
137
138
  */
138
- addNextAvailableTableAlias(alias, outerQueries) {
139
+ addNextAvailableTableAlias(alias, outerQueries, key) {
139
140
  const upperAlias = alias.toUpperCase()
140
- if (this._queryAliases.get(upperAlias) || outerQueries?.some(outer => outerHasAlias(outer))) {
141
+ if (this._queryAliases.get(upperAlias) || outerQueries?.some(outer => outerHasAlias(outer, key))) {
141
142
  let j = 2
142
- while (this._queryAliases.get(upperAlias + j) || outerQueries?.some(outer => outerHasAlias(outer, j))) j += 1
143
+ while (this._queryAliases.get(upperAlias + j) || outerQueries?.some(outer => outerHasAlias(outer, key, j))) j += 1
143
144
  alias += j
144
145
  }
145
- this._queryAliases.set(alias.toUpperCase(), alias)
146
+ this._queryAliases.set(key || alias.toUpperCase(), alias)
146
147
  return alias
147
148
 
148
- function outerHasAlias(outer, number) {
149
- return outer.joinTree._queryAliases.get(number ? upperAlias + number : upperAlias)
149
+ function outerHasAlias(outer, searchInValues = false, number) {
150
+ const currAlias = number ? upperAlias + number : upperAlias
151
+ return searchInValues ? Array.from(outer.joinTree._queryAliases.values()).includes(currAlias) : outer.joinTree._queryAliases.get(currAlias)
150
152
  }
151
153
  }
152
154
 
package/lib/utils.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const cds = require('@sap/cds')
4
+
3
5
  /**
4
6
  * Formats a ref array into a string representation.
5
7
  * If the first step is an entity, the separator is a colon, otherwise a dot.
@@ -27,6 +29,25 @@ function hasOwnSkip(definition) {
27
29
  )
28
30
  }
29
31
 
32
+ function isRuntimeView(definition) {
33
+ if (!definition || !cds.env.features.runtime_views) return false
34
+ if (definition['_isRuntimeView']) return true
35
+ if (!definition['@cds.persistence.skip']) {
36
+ Object.defineProperty(definition, '_isRuntimeView', {
37
+ value: true,
38
+ writable: false,
39
+ configurable: true,
40
+ enumerable: false
41
+ })
42
+ return true
43
+ }
44
+ // views with "as select from" variant are also runtime views, even if they are annotated with persistence skip
45
+ if (definition.query && !definition.query._target) return true
46
+ if (definition.query) return isRuntimeView(definition.query._target)
47
+
48
+ return false
49
+ }
50
+
30
51
  /**
31
52
  * Determines if a definition is calculated on read.
32
53
  * - Stored calculated elements are not unfolded
@@ -136,6 +157,12 @@ function getModelUtils(model, query) {
136
157
  }
137
158
  }
138
159
 
160
+ function resolveTable(target) {
161
+ if (target.query?._target && !Object.prototype.hasOwnProperty.call(target, '@cds.persistence.table'))
162
+ return resolveTable(target.query._target)
163
+ return target
164
+ }
165
+
139
166
  // export the function to be used in other modules
140
167
  module.exports = {
141
168
  prettyPrintRef,
@@ -145,4 +172,6 @@ module.exports = {
145
172
  defineProperty,
146
173
  getModelUtils,
147
174
  hasOwnSkip,
175
+ isRuntimeView,
176
+ resolveTable
148
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.8.2",
3
+ "version": "2.9.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": {
@@ -27,7 +27,7 @@
27
27
  "generic-pool": "^3.9.0"
28
28
  },
29
29
  "peerDependencies": {
30
- "@sap/cds": ">=9.4.5"
30
+ "@sap/cds": ">=9.8"
31
31
  },
32
32
  "license": "Apache-2.0"
33
33
  }