@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 +19 -0
- package/lib/SQLService.js +5 -8
- package/lib/cqn2sql.js +124 -42
- package/lib/cqn4sql.js +314 -34
- package/lib/infer/index.js +74 -13
- package/lib/infer/join-tree.js +8 -6
- package/lib/utils.js +29 -0
- package/package.json +2 -2
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
|
|
261
264
|
let sql = `SELECT`
|
|
262
265
|
if (distinct) sql += ` DISTINCT`
|
|
263
|
-
if (
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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.
|
|
351
|
-
if (
|
|
355
|
+
if (element.virtual || element.isAssociation) continue
|
|
356
|
+
if (name in availableComputedColumns) continue
|
|
352
357
|
if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
|
|
353
|
-
|
|
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 =>
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1025
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1199
|
+
let sql = `UPDATE ${this.quote(this.table_name(q))}`
|
|
1139
1200
|
if (entity.as) sql += ` AS ${this.quote(entity.as)}`
|
|
1140
1201
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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[
|
|
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) =>
|
|
1156
|
-
|
|
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.
|
|
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: {
|
|
1172
|
-
let sql = `DELETE FROM ${this.
|
|
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
|
|
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?.
|
|
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 (
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
2268
|
-
return { xpr: [
|
|
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)]
|
package/lib/infer/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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)))
|
|
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 = {
|
package/lib/infer/join-tree.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
30
|
+
"@sap/cds": ">=9.8"
|
|
31
31
|
},
|
|
32
32
|
"license": "Apache-2.0"
|
|
33
33
|
}
|