@cap-js/db-service 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/lib/SQLService.js +15 -7
- package/lib/cql-functions.js +1 -1
- package/lib/cqn2sql.js +115 -29
- package/lib/cqn4sql.js +152 -104
- package/lib/infer/index.js +12 -5
- package/lib/infer/join-tree.js +13 -0
- package/lib/search.js +9 -4
- package/lib/utils.js +7 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,39 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](http://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.4.0...db-service-v2.5.0) (2025-09-30)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* make hana server version accessible to sub classes ([#1263](https://github.com/cap-js/cds-dbs/issues/1263)) ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
|
|
13
|
+
* sets default to hana cloud if server version cannot be detected ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
* **`@cds.search`:** no duplicates for search along `to-many` paths ([#1341](https://github.com/cap-js/cds-dbs/issues/1341)) ([5c5f4fb](https://github.com/cap-js/cds-dbs/commit/5c5f4fbf790f718c3cf1bcb6f3bf7be421be598f))
|
|
19
|
+
* associations in `[@cds](https://github.com/cds).search` are additive ([#1355](https://github.com/cap-js/cds-dbs/issues/1355)) ([ea931cb](https://github.com/cap-js/cds-dbs/commit/ea931cb120c2857aa18a4eb68b893926c0999a9f))
|
|
20
|
+
* set proper element link for path into fk ([#1344](https://github.com/cap-js/cds-dbs/issues/1344)) ([9f365d3](https://github.com/cap-js/cds-dbs/commit/9f365d35ac614969d8fd2c2a9a1a2e0cd643969d))
|
|
21
|
+
|
|
22
|
+
## [2.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.3.0...db-service-v2.4.0) (2025-08-27)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
* **`tuple expansion`:** allow structs with exactly one element/fk in comparison ([#1291](https://github.com/cap-js/cds-dbs/issues/1291)) ([75ea826](https://github.com/cap-js/cds-dbs/commit/75ea82694faeafcaf78df9d4b0bbce37b4f65b63))
|
|
28
|
+
* cds.db.foreach uses real object mode streaming ([#1318](https://github.com/cap-js/cds-dbs/issues/1318)) ([cd28b53](https://github.com/cap-js/cds-dbs/commit/cd28b53966dbe28ad1d5ef3827767e78742e0fbd))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
* **`assoc2join`:** target side access detection ([#1282](https://github.com/cap-js/cds-dbs/issues/1282)) ([6f9befa](https://github.com/cap-js/cds-dbs/commit/6f9befa24a06bcc629fe853aa66290613734c3ef))
|
|
34
|
+
* **`cqn4sql`:** only consider `own` property `[@cds](https://github.com/cds).persistence.skip` ([#1324](https://github.com/cap-js/cds-dbs/issues/1324)) ([bd1f52f](https://github.com/cap-js/cds-dbs/commit/bd1f52f67fb4709dce3a27fea8856cb9b875da6b))
|
|
35
|
+
* **`exists`:** do not loose custom where ([#1322](https://github.com/cap-js/cds-dbs/issues/1322)) ([644918c](https://github.com/cap-js/cds-dbs/commit/644918c56d9d939f43f4a0346f42e16722bd6fe9))
|
|
36
|
+
* arithmetic operators can only be used with scalar operands ([#1307](https://github.com/cap-js/cds-dbs/issues/1307)) ([d58d335](https://github.com/cap-js/cds-dbs/commit/d58d33539e22f818d18240bb86ba596fc6fe21d1))
|
|
37
|
+
* detect path expression inside nested xpr after `exists` ([#1292](https://github.com/cap-js/cds-dbs/issues/1292)) ([852d915](https://github.com/cap-js/cds-dbs/commit/852d9155d5bb09a56a6c152259c8282662ceb29d)), closes [#1225](https://github.com/cap-js/cds-dbs/issues/1225)
|
|
38
|
+
* reject comparison of two empty structures ([#1306](https://github.com/cap-js/cds-dbs/issues/1306)) ([d97304d](https://github.com/cap-js/cds-dbs/commit/d97304d95c7f629afe75aba57192277b7124eb3e))
|
|
39
|
+
|
|
7
40
|
## [2.3.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.2.0...db-service-v2.3.0) (2025-07-28)
|
|
8
41
|
|
|
9
42
|
|
package/lib/SQLService.js
CHANGED
|
@@ -2,7 +2,7 @@ const cds = require('@sap/cds'),
|
|
|
2
2
|
DEBUG = cds.debug('sql|db')
|
|
3
3
|
const { Readable, Transform } = require('stream')
|
|
4
4
|
const { pipeline } = require('stream/promises')
|
|
5
|
-
const {
|
|
5
|
+
const { getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
|
|
6
6
|
const DatabaseService = require('./common/DatabaseService')
|
|
7
7
|
const cqn4sql = require('./cqn4sql')
|
|
8
8
|
|
|
@@ -230,7 +230,9 @@ class SQLService extends DatabaseService {
|
|
|
230
230
|
// REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
|
|
231
231
|
return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
|
|
232
232
|
async function deep_delete(/** @type {Request} */ req) {
|
|
233
|
-
const
|
|
233
|
+
const resolve = this.resolve
|
|
234
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
235
|
+
const transitions = resolve?.transitions4db ? resolve.transitions4db(req.query, false) : getTransition(req.target, this, false, req.query.cmd || 'DELETE')
|
|
234
236
|
if (transitions.target !== transitions.queryTarget) {
|
|
235
237
|
const keys = []
|
|
236
238
|
const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
|
|
@@ -253,7 +255,8 @@ class SQLService extends DatabaseService {
|
|
|
253
255
|
})
|
|
254
256
|
return this.onDELETE({ query, target: transitions.target })
|
|
255
257
|
}
|
|
256
|
-
|
|
258
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
259
|
+
const table = resolve?.table ? resolve.table(req.target) : getDBTable(req.target)
|
|
257
260
|
const { compositions } = table
|
|
258
261
|
if (compositions) {
|
|
259
262
|
// Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
|
|
@@ -355,6 +358,15 @@ class SQLService extends DatabaseService {
|
|
|
355
358
|
return count
|
|
356
359
|
}
|
|
357
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Streaming API variant of .run().
|
|
363
|
+
* @param {import('@sap/cds/apis/cqn').SELECT} query - SELECT CQN
|
|
364
|
+
* @param {function} callback - Function to be invoked for each row
|
|
365
|
+
*/
|
|
366
|
+
foreach (query, callback) {
|
|
367
|
+
return query.foreach(callback)
|
|
368
|
+
}
|
|
369
|
+
|
|
358
370
|
/**
|
|
359
371
|
* Helper class for results of INSERTs.
|
|
360
372
|
* Subclasses may override this.
|
|
@@ -395,10 +407,6 @@ class SQLService extends DatabaseService {
|
|
|
395
407
|
*/
|
|
396
408
|
cqn2sql(query, values) {
|
|
397
409
|
let q = this.cqn4sql(query)
|
|
398
|
-
let kind = q.kind || Object.keys(q)[0]
|
|
399
|
-
if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
|
|
400
|
-
q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
|
|
401
|
-
}
|
|
402
410
|
let cqn2sql = new this.class.CQN2SQL(this)
|
|
403
411
|
return cqn2sql.render(q, values)
|
|
404
412
|
}
|
package/lib/cql-functions.js
CHANGED
package/lib/cqn2sql.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
2
|
const cds_infer = require('./infer')
|
|
3
3
|
const cqn4sql = require('./cqn4sql')
|
|
4
|
+
const { getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
|
|
4
5
|
const _simple_queries = cds.env.features.sql_simple_queries
|
|
5
6
|
const _strict_booleans = _simple_queries < 2
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ class CQN2SQLRenderer {
|
|
|
17
18
|
* @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
|
|
18
19
|
*/
|
|
19
20
|
constructor(srv) {
|
|
21
|
+
this.srv = srv
|
|
20
22
|
this.context = srv?.context || cds.context // Using srv.context is required due to stakeholders doing unmanaged txs without cds.context being set
|
|
21
23
|
this.class = new.target // for IntelliSense
|
|
22
24
|
this.class._init() // is a noop for subsequent calls
|
|
@@ -25,7 +27,7 @@ class CQN2SQLRenderer {
|
|
|
25
27
|
if (cds.env.sql.names === 'quoted') {
|
|
26
28
|
this.class.prototype.name = (name, query) => {
|
|
27
29
|
const e = name.id || name
|
|
28
|
-
return (
|
|
30
|
+
return (this.model?.definitions[e])?.['@cds.persistence.name'] || e || query?._target
|
|
29
31
|
}
|
|
30
32
|
this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
|
|
31
33
|
}
|
|
@@ -89,7 +91,6 @@ class CQN2SQLRenderer {
|
|
|
89
91
|
if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
|
|
90
92
|
const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
|
|
91
93
|
|
|
92
|
-
|
|
93
94
|
if (DEBUG && (LOG_SQL._debug || LOG_SQLITE._debug)) {
|
|
94
95
|
let values = sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []
|
|
95
96
|
if (values && !Array.isArray(values)) {
|
|
@@ -721,6 +722,49 @@ class CQN2SQLRenderer {
|
|
|
721
722
|
return this.xpr({ xpr })
|
|
722
723
|
}
|
|
723
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Renders a transformed where clause that maps the query target view to the source table
|
|
727
|
+
* @param {import('./infer/cqn').source} from
|
|
728
|
+
* @param {import('./infer/cqn').predicate} where
|
|
729
|
+
* @param {import('./infer/cqn').query} q
|
|
730
|
+
* @returns SQL
|
|
731
|
+
*/
|
|
732
|
+
where_resolved(from, where, q) {
|
|
733
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
734
|
+
const kind = q.kind || (
|
|
735
|
+
q.SELECT ? 'SELECT' :
|
|
736
|
+
q.INSERT ? 'INSERT' :
|
|
737
|
+
q.UPSERT ? 'UPSERT' :
|
|
738
|
+
q.UPDATE ? 'UPDATE' :
|
|
739
|
+
q.DELETE ? 'DELETE' :
|
|
740
|
+
undefined
|
|
741
|
+
)
|
|
742
|
+
const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, kind)
|
|
743
|
+
if (transitions.target === transitions.queryTarget) return this.where(where)
|
|
744
|
+
|
|
745
|
+
// view and table column refs to be matched
|
|
746
|
+
const viewCols = []
|
|
747
|
+
const tableCols = []
|
|
748
|
+
|
|
749
|
+
// Only match key columns when possible
|
|
750
|
+
const elements = q._target.keys || q._target.elements
|
|
751
|
+
for (const c in elements) {
|
|
752
|
+
if (
|
|
753
|
+
c in elements
|
|
754
|
+
&& transitions.mapping.has(c)
|
|
755
|
+
&& !elements[c].virtual
|
|
756
|
+
&& !elements[c].value
|
|
757
|
+
&& !elements[c].isAssociation
|
|
758
|
+
) {
|
|
759
|
+
viewCols.push({ ref: [c] })
|
|
760
|
+
tableCols.push(transitions.mapping.get(c))
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return tableCols.length > 0
|
|
764
|
+
? this.where([{ list: tableCols }, 'in', SELECT.from(from).columns(viewCols).where(where)])
|
|
765
|
+
: this.where(where)
|
|
766
|
+
}
|
|
767
|
+
|
|
724
768
|
/**
|
|
725
769
|
* Renders a HAVING clause into generic SQL
|
|
726
770
|
* @param {import('./infer/cqn').predicate} xpr
|
|
@@ -826,15 +870,22 @@ class CQN2SQLRenderer {
|
|
|
826
870
|
if (!elements && !INSERT.entries?.length) {
|
|
827
871
|
return // REVISIT: mtx sends an insert statement without entries and no reference entity
|
|
828
872
|
}
|
|
873
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
874
|
+
const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'INSERT')
|
|
829
875
|
const columns = elements
|
|
830
|
-
? ObjectKeys(elements).filter(c => c
|
|
876
|
+
? ObjectKeys(elements).filter(c => (c = transitions.mapping.get(c)?.ref?.[0] || c)
|
|
877
|
+
&& c in transitions.target.elements
|
|
878
|
+
&& !transitions.target.elements[c].virtual
|
|
879
|
+
&& !transitions.target.elements[c].value
|
|
880
|
+
&& !transitions.target.elements[c].isAssociation
|
|
881
|
+
)
|
|
831
882
|
: ObjectKeys(INSERT.entries[0])
|
|
832
883
|
|
|
833
884
|
/** @type {string[]} */
|
|
834
885
|
this.columns = columns
|
|
835
886
|
|
|
836
887
|
const alias = INSERT.into.as
|
|
837
|
-
const entity = this.
|
|
888
|
+
const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
|
|
838
889
|
if (!elements) {
|
|
839
890
|
this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
|
|
840
891
|
const param = this.param.bind(this, { ref: ['?'] })
|
|
@@ -856,8 +907,8 @@ class CQN2SQLRenderer {
|
|
|
856
907
|
}
|
|
857
908
|
|
|
858
909
|
const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
|
|
859
|
-
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
|
|
860
|
-
}) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
|
|
910
|
+
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
|
|
911
|
+
}) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`)
|
|
861
912
|
}
|
|
862
913
|
|
|
863
914
|
async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
|
|
@@ -963,7 +1014,7 @@ class CQN2SQLRenderer {
|
|
|
963
1014
|
*/
|
|
964
1015
|
INSERT_rows(q) {
|
|
965
1016
|
const { INSERT } = q
|
|
966
|
-
const entity = this.
|
|
1017
|
+
const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
|
|
967
1018
|
const alias = INSERT.into.as
|
|
968
1019
|
const elements = q.elements || q._target?.elements
|
|
969
1020
|
const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
|
|
@@ -987,8 +1038,10 @@ class CQN2SQLRenderer {
|
|
|
987
1038
|
const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
|
|
988
1039
|
.slice(0, columns.length)
|
|
989
1040
|
.map(c => c.converter(c.extract))
|
|
990
|
-
|
|
991
|
-
|
|
1041
|
+
|
|
1042
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
1043
|
+
const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'INSERT')
|
|
1044
|
+
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))
|
|
992
1045
|
}) SELECT ${extraction} FROM json_each(?)`)
|
|
993
1046
|
}
|
|
994
1047
|
|
|
@@ -1009,15 +1062,26 @@ class CQN2SQLRenderer {
|
|
|
1009
1062
|
*/
|
|
1010
1063
|
INSERT_select(q) {
|
|
1011
1064
|
const { INSERT } = q
|
|
1012
|
-
const entity = this.
|
|
1065
|
+
const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
|
|
1013
1066
|
const alias = INSERT.into.as
|
|
1067
|
+
const src = this.cqn4sql(INSERT.from)
|
|
1014
1068
|
const elements = q.elements || q._target?.elements || {}
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
))
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1069
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
1070
|
+
const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q, this.srv) : getTransition(q._target, this.srv, false, 'INSERT')
|
|
1071
|
+
let columns = (this.columns = (INSERT.columns || src.SELECT.columns?.map(c => this.column_name(c)) || ObjectKeys(src.elements) || ObjectKeys(elements))
|
|
1072
|
+
.filter(c => (c = transitions.mapping.get(c)?.ref?.[0] || c)
|
|
1073
|
+
&& c in transitions.target.elements
|
|
1074
|
+
&& !transitions.target.elements[c].virtual
|
|
1075
|
+
&& !transitions.target.elements[c].value
|
|
1076
|
+
&& !transitions.target.elements[c].isAssociation
|
|
1077
|
+
))
|
|
1078
|
+
|
|
1079
|
+
const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements)
|
|
1080
|
+
const sql = extractions.length > columns.length
|
|
1081
|
+
? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW`
|
|
1082
|
+
: this.SELECT(src)
|
|
1083
|
+
if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name)
|
|
1084
|
+
this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql}`
|
|
1021
1085
|
this.entries = [this.values]
|
|
1022
1086
|
return this.sql
|
|
1023
1087
|
}
|
|
@@ -1077,7 +1141,7 @@ class CQN2SQLRenderer {
|
|
|
1077
1141
|
.filter(c => keys.includes(c.name))
|
|
1078
1142
|
.map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
|
|
1079
1143
|
|
|
1080
|
-
const entity = this.
|
|
1144
|
+
const entity = q._target ? this.table_name(q) : INSERT.into.ref[0]
|
|
1081
1145
|
sql = `SELECT ${managed.map(c => c.upsert
|
|
1082
1146
|
.replace(/value->/g, '"$$$$value$$$$"->')
|
|
1083
1147
|
.replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
|
|
@@ -1093,7 +1157,9 @@ class CQN2SQLRenderer {
|
|
|
1093
1157
|
else return true
|
|
1094
1158
|
}).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
|
|
1095
1159
|
|
|
1096
|
-
|
|
1160
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
1161
|
+
const transitions = this.srv.resolve.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'UPDATE')
|
|
1162
|
+
return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql
|
|
1097
1163
|
} WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
|
|
1098
1164
|
}
|
|
1099
1165
|
|
|
@@ -1106,29 +1172,37 @@ class CQN2SQLRenderer {
|
|
|
1106
1172
|
*/
|
|
1107
1173
|
UPDATE(q) {
|
|
1108
1174
|
const { entity, with: _with, data, where } = q.UPDATE
|
|
1109
|
-
|
|
1110
|
-
|
|
1175
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
1176
|
+
const transitions = this.srv.resolve?.transitions4db ? this.srv.resolve.transitions4db(q) : getTransition(q._target, this.srv, false, 'UPDATE')
|
|
1177
|
+
const elements = q._target?.elements
|
|
1178
|
+
let sql = `UPDATE ${this.quote(this.table_name(q))}`
|
|
1111
1179
|
if (entity.as) sql += ` AS ${this.quote(entity.as)}`
|
|
1112
1180
|
|
|
1113
1181
|
let columns = []
|
|
1114
1182
|
if (data) _add(data, val => this.val({ val }))
|
|
1115
1183
|
if (_with) _add(_with, x => this.expr(x))
|
|
1116
1184
|
function _add(data, sql4) {
|
|
1117
|
-
for (let
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1185
|
+
for (let col in data) {
|
|
1186
|
+
const c = transitions.mapping.get(col)?.ref?.[0] || col
|
|
1187
|
+
const columnExistsInDatabase = elements
|
|
1188
|
+
&& c in transitions.target.elements
|
|
1189
|
+
&& !transitions.target.elements[c].virtual
|
|
1190
|
+
&& !transitions.target.elements[c].value
|
|
1191
|
+
&& !transitions.target.elements[c].isAssociation
|
|
1120
1192
|
if (!elements || columnExistsInDatabase) {
|
|
1121
|
-
columns.push({ name: c, sql: sql4(data[
|
|
1193
|
+
columns.push({ name: c, sql: sql4(data[col], col) })
|
|
1122
1194
|
}
|
|
1123
1195
|
}
|
|
1124
1196
|
}
|
|
1125
1197
|
|
|
1126
1198
|
const extraction = this.managed(columns, elements)
|
|
1127
|
-
.filter((c, i) =>
|
|
1128
|
-
|
|
1199
|
+
.filter((c, i) => {
|
|
1200
|
+
if(transitions.mapping.get(c.name)?.ref?.length > 1) return false
|
|
1201
|
+
return columns[i] || c.onUpdate
|
|
1202
|
+
}).map((c, i) => `${this.quote(transitions.mapping.get(c.name)?.ref?.[0] || c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
|
|
1129
1203
|
|
|
1130
1204
|
sql += ` SET ${extraction}`
|
|
1131
|
-
if (where) sql += ` WHERE ${this.
|
|
1205
|
+
if (where) sql += ` WHERE ${this.where_resolved(entity, where, q)}`
|
|
1132
1206
|
return (this.sql = sql)
|
|
1133
1207
|
}
|
|
1134
1208
|
|
|
@@ -1140,8 +1214,9 @@ class CQN2SQLRenderer {
|
|
|
1140
1214
|
* @returns {string} SQL
|
|
1141
1215
|
*/
|
|
1142
1216
|
DELETE(q) {
|
|
1143
|
-
const { DELETE: {
|
|
1144
|
-
let sql = `DELETE FROM ${this.
|
|
1217
|
+
const { DELETE: { where, from } } = q
|
|
1218
|
+
let sql = `DELETE FROM ${this.quote(this.table_name(q))}`
|
|
1219
|
+
if (from.as) sql += ` AS ${this.quote(from.as)}`
|
|
1145
1220
|
if (where) sql += ` WHERE ${this.where(where)}`
|
|
1146
1221
|
return (this.sql = sql)
|
|
1147
1222
|
}
|
|
@@ -1354,6 +1429,17 @@ class CQN2SQLRenderer {
|
|
|
1354
1429
|
return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
|
|
1355
1430
|
}
|
|
1356
1431
|
|
|
1432
|
+
/**
|
|
1433
|
+
* Calculates the Database table name of the query target
|
|
1434
|
+
* @param {import('./infer/cqn').Query} query
|
|
1435
|
+
* @returns {string} Database table name
|
|
1436
|
+
*/
|
|
1437
|
+
table_name(q) {
|
|
1438
|
+
// REVISIT: remove fallback when cds.dbs requires cds >= 9.3
|
|
1439
|
+
const table = cds.db.resolve?.table ? cds.db.resolve.table(q._target) : getDBTable(q._target)
|
|
1440
|
+
return this.name(table.name, q)
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1357
1443
|
/**
|
|
1358
1444
|
* Calculates the Database name of the given name
|
|
1359
1445
|
* @param {string|import('./infer/cqn').ref} name
|
package/lib/cqn4sql.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
getImplicitAlias,
|
|
13
13
|
defineProperty,
|
|
14
14
|
getModelUtils,
|
|
15
|
+
hasOwnSkip,
|
|
15
16
|
} = require('./utils')
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -20,14 +21,14 @@ const {
|
|
|
20
21
|
*/
|
|
21
22
|
const eqOps = [['is'], ['='] /* ['=='] */]
|
|
22
23
|
/**
|
|
23
|
-
* For operators of <notEqOps>, do the same but use or instead of and
|
|
24
|
-
* This ensures that not struct == <value
|
|
24
|
+
* For operators of <notEqOps>, do the same but use `or` instead of `and`.
|
|
25
|
+
* This ensures that `not struct == <value>` is the same as `struct != <value>`.
|
|
25
26
|
*/
|
|
26
27
|
const notEqOps = [['is', 'not'], ['<>'], ['!=']]
|
|
27
28
|
/**
|
|
28
29
|
* not supported in comparison w/ struct because of unclear semantics
|
|
29
30
|
*/
|
|
30
|
-
const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
|
|
31
|
+
const notSupportedOps = [['>'], ['<'], ['>='], ['<='], ['*'], ['+'], ['-'], ['/']]
|
|
31
32
|
|
|
32
33
|
const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
|
|
33
34
|
|
|
@@ -60,7 +61,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
60
61
|
if (!hasCustomJoins && inferred.SELECT?.search) {
|
|
61
62
|
// we need an instance of query because the elements of the query are needed for the calculation of the search columns
|
|
62
63
|
if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
|
|
63
|
-
const searchTerm =
|
|
64
|
+
const searchTerm = getSearch(inferred.SELECT.search, inferred)
|
|
64
65
|
if (searchTerm) {
|
|
65
66
|
// Search target can be a navigation, in that case use _target to get the correct entity
|
|
66
67
|
const { where, having } = transformSearch(searchTerm)
|
|
@@ -122,14 +123,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
122
123
|
// calculate the primary keys of the target entity, there is always exactly
|
|
123
124
|
// one query source for UPDATE / DELETE
|
|
124
125
|
const queryTarget = Object.values(inferred.sources)[0].definition
|
|
125
|
-
const primaryKey = { list:
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (e.key === true && !e.virtual && e.isAssociation !== true) {
|
|
129
|
-
subquery.SELECT.columns.push({ ref: [e.name] })
|
|
130
|
-
primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
|
|
131
|
-
}
|
|
132
|
-
}
|
|
126
|
+
const primaryKey = { list: getPrimaryKey(queryTarget, uniqueSubqueryAlias) }
|
|
127
|
+
// match primary keys of the target entity with the subquery
|
|
128
|
+
primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
|
|
133
129
|
|
|
134
130
|
const transformedSubquery = cqn4sql(subquery, model)
|
|
135
131
|
|
|
@@ -257,7 +253,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
257
253
|
if (inferred.SELECT[prop]) {
|
|
258
254
|
return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
|
|
259
255
|
} else {
|
|
260
|
-
return { [prop]: [searchTerm] }
|
|
256
|
+
return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
|
|
261
257
|
}
|
|
262
258
|
}
|
|
263
259
|
|
|
@@ -467,7 +463,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
467
463
|
const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
|
|
468
464
|
if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
|
|
469
465
|
|
|
470
|
-
if (col.$refLinks.some(link => getDefinition(link.definition.target)
|
|
466
|
+
if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
|
|
471
467
|
|
|
472
468
|
const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
|
|
473
469
|
flatColumns.forEach(flatColumn => {
|
|
@@ -966,7 +962,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
966
962
|
} else if (pseudos.elements[col.ref?.[0]]) {
|
|
967
963
|
res.push({ ...col })
|
|
968
964
|
} else if (col.ref) {
|
|
969
|
-
if (col.$refLinks.some(link => getDefinition(link.definition.target)
|
|
965
|
+
if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
|
|
970
966
|
continue
|
|
971
967
|
if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
|
|
972
968
|
const dollarSelfReplacement = calculateDollarSelfColumn(col)
|
|
@@ -1189,7 +1185,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1189
1185
|
let { baseName, columnAlias = column.as, tableAlias } = names
|
|
1190
1186
|
const { exclude, replace } = excludeAndReplace || {}
|
|
1191
1187
|
const { $refLinks, flatName, isJoinRelevant } = column
|
|
1192
|
-
let
|
|
1188
|
+
let firstNonJoinRelevantAssoc, stepAfterAssoc
|
|
1193
1189
|
let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
|
|
1194
1190
|
if (isWildcard && element.type === 'cds.LargeBinary') return []
|
|
1195
1191
|
if (element.on && !element.keys)
|
|
@@ -1197,14 +1193,23 @@ function cqn4sql(originalQuery, model) {
|
|
|
1197
1193
|
else if (element.virtual === true) return []
|
|
1198
1194
|
else if (!isJoinRelevant && flatName) baseName = flatName
|
|
1199
1195
|
else if (isJoinRelevant) {
|
|
1200
|
-
const
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1196
|
+
const leafAssocIndex = column.$refLinks.findIndex(link => link.definition.isAssociation && link.onlyForeignKeyAccess)
|
|
1197
|
+
firstNonJoinRelevantAssoc = column.$refLinks[leafAssocIndex] || [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
|
|
1198
|
+
stepAfterAssoc = column.$refLinks.at(leafAssocIndex + 1) || column.$refLinks.at(-1)
|
|
1199
|
+
let elements = firstNonJoinRelevantAssoc.definition.elements || firstNonJoinRelevantAssoc.definition.foreignKeys
|
|
1200
|
+
if (elements && stepAfterAssoc.definition.name in elements) {
|
|
1201
|
+
element = firstNonJoinRelevantAssoc.definition
|
|
1202
|
+
baseName = getFullName(firstNonJoinRelevantAssoc.definition)
|
|
1206
1203
|
columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
|
|
1207
1204
|
} else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
|
|
1205
|
+
|
|
1206
|
+
if(column.element && !isAssocOrStruct(column.element)) {
|
|
1207
|
+
columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
|
|
1208
|
+
const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
|
|
1209
|
+
setElementOnColumns(res, column.element)
|
|
1210
|
+
return [res]
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1208
1213
|
} else if (!baseName && structsAreUnfoldedAlready) {
|
|
1209
1214
|
baseName = element.name // name is already fully constructed
|
|
1210
1215
|
} else {
|
|
@@ -1245,11 +1250,11 @@ function cqn4sql(originalQuery, model) {
|
|
|
1245
1250
|
const flattenThisForeignKey =
|
|
1246
1251
|
!$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
|
|
1247
1252
|
element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
|
|
1248
|
-
keyElement ===
|
|
1253
|
+
keyElement === stepAfterAssoc.definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
|
|
1249
1254
|
if (flattenThisForeignKey) {
|
|
1250
1255
|
const fkElement = getElementForRef(k.ref, getDefinition(element.target))
|
|
1251
1256
|
let fkBaseName
|
|
1252
|
-
if (!
|
|
1257
|
+
if (!firstNonJoinRelevantAssoc || firstNonJoinRelevantAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
|
|
1253
1258
|
// e.g. if foreign key is accessed via infix filter - use join alias to access key in target
|
|
1254
1259
|
else fkBaseName = k.ref.at(-1)
|
|
1255
1260
|
const fkPath = [...csnPath, k.ref.at(-1)]
|
|
@@ -1462,6 +1467,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1462
1467
|
flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
|
|
1463
1468
|
}
|
|
1464
1469
|
}
|
|
1470
|
+
// TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
|
|
1465
1471
|
if (flatKeys.length > 1)
|
|
1466
1472
|
throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
|
|
1467
1473
|
flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
|
|
@@ -1474,35 +1480,38 @@ function cqn4sql(originalQuery, model) {
|
|
|
1474
1480
|
transformedTokenStream.push({ ...token })
|
|
1475
1481
|
} else {
|
|
1476
1482
|
// expand `struct = null | struct2`
|
|
1477
|
-
const definition = token.$refLinks?.at(-1).definition
|
|
1478
1483
|
const next = tokenStream[i + 1]
|
|
1479
|
-
|
|
1484
|
+
let indexRhs = i + 2
|
|
1485
|
+
let rhs = tokenStream[indexRhs] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
|
|
1486
|
+
const lhsDef = token.$refLinks?.at(-1).definition
|
|
1487
|
+
let rhsDef = rhs?.$refLinks?.at(-1)?.definition
|
|
1488
|
+
if (
|
|
1489
|
+
allOps.some(([firstOp]) => firstOp === next) &&
|
|
1490
|
+
(lhsDef?.elements || lhsDef?.keys || rhsDef?.elements || rhsDef?.keys)
|
|
1491
|
+
) {
|
|
1480
1492
|
const ops = [next]
|
|
1481
|
-
let indexRhs = i + 2
|
|
1482
|
-
let rhs = tokenStream[i + 2] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
|
|
1483
1493
|
if (allOps.some(([, secondOp]) => secondOp === rhs)) {
|
|
1484
1494
|
ops.push(rhs)
|
|
1485
1495
|
rhs = tokenStream[i + 3]
|
|
1486
1496
|
indexRhs += 1
|
|
1497
|
+
rhsDef = rhs?.$refLinks?.at(-1)?.definition
|
|
1487
1498
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
)
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
i = indexRhs // jump to next relevant index
|
|
1500
|
-
}
|
|
1499
|
+
|
|
1500
|
+
if (notSupportedOps.some(([firstOp]) => firstOp === next))
|
|
1501
|
+
throw new Error(`The operator "${next}" can only be used with scalar operands`)
|
|
1502
|
+
|
|
1503
|
+
const newTokens = expandComparison(token, ops, rhs, $baseLink)
|
|
1504
|
+
if(newTokens.length === 0)
|
|
1505
|
+
throw new Error(`Can't compare two empty structures`)
|
|
1506
|
+
|
|
1507
|
+
const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
|
|
1508
|
+
transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
|
|
1509
|
+
i = indexRhs // jump to next relevant index
|
|
1501
1510
|
} else {
|
|
1502
1511
|
// reject associations in expression, except if we are in an infix filter -> $baseLink is set
|
|
1503
1512
|
assertNoStructInXpr(token, $baseLink)
|
|
1504
1513
|
// reject virtual elements in expressions as they will lead to a sql error down the line
|
|
1505
|
-
if (
|
|
1514
|
+
if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
|
|
1506
1515
|
|
|
1507
1516
|
let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
|
|
1508
1517
|
if (token.ref) {
|
|
@@ -1528,7 +1537,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1528
1537
|
token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
|
|
1529
1538
|
const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
|
|
1530
1539
|
if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
|
|
1531
|
-
let name = calculateElementName(token
|
|
1540
|
+
let name = calculateElementName(token)
|
|
1532
1541
|
result.ref = [tableAlias, name]
|
|
1533
1542
|
} else if (tableAlias) {
|
|
1534
1543
|
result.ref = [tableAlias, token.flatName]
|
|
@@ -1557,9 +1566,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
1557
1566
|
/**
|
|
1558
1567
|
* Expand the given definition and compare all leafs to `val`.
|
|
1559
1568
|
*
|
|
1560
|
-
* @param {object}
|
|
1569
|
+
* @param {object} lhs with $refLinks
|
|
1561
1570
|
* @param {string} operator one of allOps
|
|
1562
|
-
* @param {object}
|
|
1571
|
+
* @param {object} rhs either `null` or a column (with `ref` and `$refLinks`)
|
|
1563
1572
|
* @param {object} $baseLink optional base `$refLink`, e.g. for infix filters of scoped queries.
|
|
1564
1573
|
* In the following example, we must pass `bookshop:Reproduce` as $baseLink for `author`:
|
|
1565
1574
|
*
|
|
@@ -1567,26 +1576,21 @@ function cqn4sql(originalQuery, model) {
|
|
|
1567
1576
|
* ^^^^^^
|
|
1568
1577
|
* @returns {array}
|
|
1569
1578
|
*/
|
|
1570
|
-
function expandComparison(
|
|
1571
|
-
const { definition } =
|
|
1572
|
-
|
|
1579
|
+
function expandComparison(lhs, operator, rhs, $baseLink = null) {
|
|
1580
|
+
const { definition: lhsDef, val: lhsVal } = lhs.val ? lhs : lhs.$refLinks.at(-1)
|
|
1581
|
+
const { definition: rhsDef, val: rhsVal } = rhs.val ? rhs : rhs.$refLinks?.at(-1) || {}
|
|
1573
1582
|
const result = []
|
|
1574
|
-
if (
|
|
1575
|
-
//
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
value.$refLinks[value.$refLinks.length - 1].definition.name
|
|
1586
|
-
}": the operands must have the same structure`,
|
|
1587
|
-
)
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1583
|
+
if (lhsDef && rhsDef) {
|
|
1584
|
+
// both must be structured
|
|
1585
|
+
const lhsIsStructured = isAssocOrStruct(lhsDef)
|
|
1586
|
+
const rhsIsStructured = isAssocOrStruct(rhsDef)
|
|
1587
|
+
if (!lhsIsStructured)
|
|
1588
|
+
throw new Error(`Can't compare structure “${rhs.ref.map(idOnly).join('.')}” with non-structure “${lhs.ref.map(idOnly).join('.')}”`)
|
|
1589
|
+
if (!rhsIsStructured)
|
|
1590
|
+
throw new Error(`Can't compare structure “${lhs.ref.map(idOnly).join('.')}” with non-structure “${rhs.ref.map(idOnly).join('.')}”`)
|
|
1591
|
+
|
|
1592
|
+
const flatLhs = flattenWithBaseName(lhs)
|
|
1593
|
+
const flatRhs = flattenWithBaseName(rhs)
|
|
1590
1594
|
const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
|
|
1591
1595
|
while (flatLhs.length > 0) {
|
|
1592
1596
|
// retrieve and remove one flat element from LHS and search for it in RHS (remove it there too)
|
|
@@ -1598,27 +1602,47 @@ function cqn4sql(originalQuery, model) {
|
|
|
1598
1602
|
})
|
|
1599
1603
|
// not found in rhs --> exit
|
|
1600
1604
|
if (indexOfElementOnRhs === -1) {
|
|
1601
|
-
const lhsPath =
|
|
1602
|
-
const rhsPath =
|
|
1605
|
+
const lhsPath = lhs.ref.map(idOnly).join('.')
|
|
1606
|
+
const rhsPath = rhs.ref.map(idOnly).join('.')
|
|
1603
1607
|
throw new Error(`Can't compare "${lhsPath}" with "${rhsPath}": the operands must have the same structure`)
|
|
1604
1608
|
}
|
|
1605
|
-
const
|
|
1606
|
-
result.push({ ref }, ...operator,
|
|
1609
|
+
const cleansedRhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
|
|
1610
|
+
result.push({ ref }, ...operator, cleansedRhs)
|
|
1607
1611
|
if (flatLhs.length > 0) result.push(boolOp)
|
|
1608
1612
|
}
|
|
1609
|
-
} else {
|
|
1613
|
+
} else if (lhsDef && (rhsVal || rhs === 'null' || rhs.val === null)) {
|
|
1610
1614
|
// compare with value
|
|
1611
|
-
const flatLhs = flattenWithBaseName(
|
|
1612
|
-
if (flatLhs.length
|
|
1613
|
-
|
|
1615
|
+
const flatLhs = flattenWithBaseName(lhs)
|
|
1616
|
+
if (flatLhs.length !== 1 && rhsVal && rhs !== 'null')
|
|
1617
|
+
canOnlyCompareToExactlyOneLeaf(lhsDef, lhs.ref, rhsVal)
|
|
1618
|
+
|
|
1614
1619
|
const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
|
|
1615
1620
|
flatLhs.forEach((column, i) => {
|
|
1616
|
-
result.push(column, ...operator,
|
|
1621
|
+
result.push(column, ...operator, rhs)
|
|
1617
1622
|
if (flatLhs[i + 1]) result.push(boolOp)
|
|
1618
1623
|
})
|
|
1624
|
+
} else if (lhsVal && rhsDef) {
|
|
1625
|
+
const flatRhs = flattenWithBaseName(rhs)
|
|
1626
|
+
// comparing a struct to a value is ok if structure has exactly one leaf
|
|
1627
|
+
if (flatRhs.length !== 1 && lhsVal)
|
|
1628
|
+
canOnlyCompareToExactlyOneLeaf(rhsDef, rhs.ref, lhsVal)
|
|
1629
|
+
|
|
1630
|
+
const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
|
|
1631
|
+
flatRhs.forEach((column, i) => {
|
|
1632
|
+
result.push(lhs, ...operator, column)
|
|
1633
|
+
if (flatRhs[i + 1]) result.push(boolOp)
|
|
1634
|
+
})
|
|
1619
1635
|
}
|
|
1620
1636
|
return result
|
|
1621
1637
|
|
|
1638
|
+
function canOnlyCompareToExactlyOneLeaf(struct, structRef, val) {
|
|
1639
|
+
const what = struct.isAssociation ? 'association' : 'structure'
|
|
1640
|
+
const postfix = struct.isAssociation ? 'associations with one foreign key' : 'structures with one sub-element'
|
|
1641
|
+
throw new Error(
|
|
1642
|
+
`Can't compare ${what} "${structRef.map(idOnly).join('.')}" to value "${val}"; only possible for ${postfix}`
|
|
1643
|
+
)
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1622
1646
|
function flattenWithBaseName(def) {
|
|
1623
1647
|
if (!def.$refLinks) return def
|
|
1624
1648
|
const leaf = def.$refLinks[def.$refLinks.length - 1]
|
|
@@ -1760,7 +1784,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1760
1784
|
filterConditions.forEach(f => {
|
|
1761
1785
|
transformedWhere.push('and')
|
|
1762
1786
|
if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
|
|
1763
|
-
else if (f.length > 3 || f.includes('or') || f.includes('and')) transformedWhere.push(asXpr(f))
|
|
1787
|
+
else if (f.length > 3 || f.includes('or') || f.includes('and') || f.includes('in')) transformedWhere.push(asXpr(f))
|
|
1764
1788
|
else transformedWhere.push(...f)
|
|
1765
1789
|
})
|
|
1766
1790
|
} else {
|
|
@@ -2127,7 +2151,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
2127
2151
|
* @param {boolean} inWhere whether or not the path is part of the queries where clause
|
|
2128
2152
|
* -> if it is, target and source side are flipped in the where exists subquery
|
|
2129
2153
|
* @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
|
|
2130
|
-
*
|
|
2154
|
+
*
|
|
2131
2155
|
* @returns {CQN.SELECT}
|
|
2132
2156
|
*/
|
|
2133
2157
|
function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
|
|
@@ -2165,54 +2189,68 @@ function cqn4sql(originalQuery, model) {
|
|
|
2165
2189
|
where: on,
|
|
2166
2190
|
}
|
|
2167
2191
|
// this requires sub-sequent transformation of the subquery
|
|
2168
|
-
if (
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2192
|
+
if (
|
|
2193
|
+
next.pathExpressionInsideFilter ||
|
|
2194
|
+
(queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))
|
|
2195
|
+
) {
|
|
2196
|
+
SELECT.where = customWhere || []
|
|
2197
|
+
if (queryModifier) assignQueryModifiers(SELECT, queryModifier)
|
|
2198
|
+
|
|
2199
|
+
const transformedExists = transformSubquery({ SELECT })
|
|
2173
2200
|
if (transformedExists.SELECT.where?.length) {
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2201
|
+
const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
|
|
2202
|
+
? [asXpr(transformedExists.SELECT.where)]
|
|
2203
|
+
: transformedExists.SELECT.where
|
|
2177
2204
|
|
|
2178
|
-
|
|
2205
|
+
on.push('and', ...wrappedWhere)
|
|
2179
2206
|
}
|
|
2180
|
-
transformedExists.SELECT.where = on
|
|
2181
|
-
return transformedExists.SELECT
|
|
2207
|
+
transformedExists.SELECT.where = on
|
|
2208
|
+
return transformedExists.SELECT
|
|
2182
2209
|
}
|
|
2183
2210
|
|
|
2184
2211
|
if (customWhere) {
|
|
2185
|
-
const filter = getTransformedTokenStream(customWhere, next)
|
|
2186
|
-
const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
|
|
2187
|
-
on.push('and', ...wrappedFilter)
|
|
2212
|
+
const filter = getTransformedTokenStream(customWhere, next)
|
|
2213
|
+
const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
|
|
2214
|
+
on.push('and', ...wrappedFilter)
|
|
2188
2215
|
}
|
|
2189
2216
|
return SELECT
|
|
2190
2217
|
}
|
|
2191
2218
|
|
|
2192
|
-
|
|
2193
|
-
* For a given search
|
|
2194
|
-
*
|
|
2219
|
+
/**
|
|
2220
|
+
* For a given search term calculate a search expression which can be used in a where clause.
|
|
2221
|
+
* The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
|
|
2222
|
+
* the search results of the subquery.
|
|
2195
2223
|
*
|
|
2196
|
-
* @param {object}
|
|
2224
|
+
* @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source.
|
|
2197
2225
|
* @param {object} query - The FROM clause of the CQN statement.
|
|
2198
2226
|
*
|
|
2199
2227
|
* @returns {(Object|null)} returns either:
|
|
2228
|
+
* - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
|
|
2200
2229
|
* - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
|
|
2201
2230
|
* - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
|
|
2202
2231
|
*/
|
|
2203
|
-
function
|
|
2232
|
+
function getSearch(searchTerm, query) {
|
|
2204
2233
|
const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
|
|
2205
2234
|
const searchIn = computeColumnsToBeSearched(inferred, entity)
|
|
2206
|
-
if (searchIn.length
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
return null
|
|
2235
|
+
if (searchIn.length === 0) return null
|
|
2236
|
+
|
|
2237
|
+
const searchFunc = {
|
|
2238
|
+
func: 'search',
|
|
2239
|
+
args: [
|
|
2240
|
+
searchIn.length === 1 ? searchIn[0] : { list: searchIn },
|
|
2241
|
+
searchTerm.length === 1 && 'val' in searchTerm[0] ? searchTerm[0] : { xpr: searchTerm },
|
|
2242
|
+
],
|
|
2215
2243
|
}
|
|
2244
|
+
// for aggregated queries / search on subqueries we do not do a subquery search
|
|
2245
|
+
if (inferred.SELECT.groupBy || entity.SELECT)
|
|
2246
|
+
return searchFunc
|
|
2247
|
+
|
|
2248
|
+
const matchColumns = getPrimaryKey(entity)
|
|
2249
|
+
if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
|
|
2250
|
+
return searchFunc
|
|
2251
|
+
|
|
2252
|
+
const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
|
|
2253
|
+
return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
|
|
2216
2254
|
}
|
|
2217
2255
|
|
|
2218
2256
|
/**
|
|
@@ -2349,7 +2387,6 @@ function getParentEntity(element) {
|
|
|
2349
2387
|
else return getParentEntity(element.parent)
|
|
2350
2388
|
}
|
|
2351
2389
|
|
|
2352
|
-
|
|
2353
2390
|
function asXpr(thing) {
|
|
2354
2391
|
return { xpr: thing }
|
|
2355
2392
|
}
|
|
@@ -2379,6 +2416,17 @@ function setElementOnColumns(col, element) {
|
|
|
2379
2416
|
defineProperty(col, 'element', element)
|
|
2380
2417
|
}
|
|
2381
2418
|
|
|
2419
|
+
function getPrimaryKey(entity, tableAlias = null) {
|
|
2420
|
+
const primaryKey = []
|
|
2421
|
+
for (const k of Object.keys(entity.elements)) {
|
|
2422
|
+
const e = entity.elements[k]
|
|
2423
|
+
if (e.key === true && !e.virtual && e.isAssociation !== true) {
|
|
2424
|
+
primaryKey.push({ ref: tableAlias ? [tableAlias, e.name] : [e.name] })
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
return primaryKey
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2382
2430
|
const getName = col => col.as || col.ref?.at(-1)
|
|
2383
2431
|
const idOnly = ref => ref.id || ref
|
|
2384
2432
|
const refWithConditions = step => {
|
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 } = require('../utils')
|
|
7
|
+
const { isCalculatedOnRead, getImplicitAlias, getModelUtils, defineProperty, hasOwnSkip } = require('../utils')
|
|
8
8
|
const cdsTypes = cds.linked({
|
|
9
9
|
definitions: {
|
|
10
10
|
Timestamp: { type: 'cds.Timestamp' },
|
|
@@ -404,7 +404,7 @@ function infer(originalQuery, model) {
|
|
|
404
404
|
if (arg.list) arg.list.forEach(arg => inferArg(arg, null, $baseLink, context))
|
|
405
405
|
if (arg.xpr)
|
|
406
406
|
arg.xpr.forEach((token, i) =>
|
|
407
|
-
inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: arg.xpr[i - 1] === 'exists' }),
|
|
407
|
+
inferArg(token, queryElements, $baseLink, { ...context, inXpr: true, inExists: inExists || arg.xpr[i - 1] === 'exists' }),
|
|
408
408
|
) // e.g. function in expression
|
|
409
409
|
|
|
410
410
|
if (!arg.ref) {
|
|
@@ -587,7 +587,7 @@ function infer(originalQuery, model) {
|
|
|
587
587
|
}
|
|
588
588
|
|
|
589
589
|
arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
|
|
590
|
-
if (getDefinition(arg.$refLinks[i].definition.target)
|
|
590
|
+
if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
|
|
591
591
|
if (!arg.ref[i + 1]) {
|
|
592
592
|
const flatName = nameSegments.join('_')
|
|
593
593
|
defineProperty(arg, 'flatName', flatName)
|
|
@@ -640,7 +640,7 @@ function infer(originalQuery, model) {
|
|
|
640
640
|
// ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
|
|
641
641
|
if (arg.expand) {
|
|
642
642
|
const { $refLinks } = arg
|
|
643
|
-
const skip = $refLinks.some(link => getDefinition(link.definition.target)
|
|
643
|
+
const skip = $refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))
|
|
644
644
|
if (skip) {
|
|
645
645
|
$refLinks[$refLinks.length - 1].skipExpand = true
|
|
646
646
|
return
|
|
@@ -937,8 +937,15 @@ function infer(originalQuery, model) {
|
|
|
937
937
|
return true
|
|
938
938
|
}
|
|
939
939
|
if (assoc) {
|
|
940
|
+
// if(!link.definition.isAssociation) continue
|
|
941
|
+
let fkIndex = assoc.keys?.findIndex(key => key.ref.every((step, j) => column.ref[i + j] === step))
|
|
940
942
|
// foreign key access without filters never join relevant
|
|
941
|
-
if (
|
|
943
|
+
if (fkIndex !== -1) {
|
|
944
|
+
if(column.ref.slice(i).some(s => s.where)) continue // probably join relevant later on
|
|
945
|
+
fkAccess = true
|
|
946
|
+
assoc = null
|
|
947
|
+
continue
|
|
948
|
+
}
|
|
942
949
|
// <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
|
|
943
950
|
return true
|
|
944
951
|
}
|
package/lib/infer/join-tree.js
CHANGED
|
@@ -212,6 +212,8 @@ class JoinTree {
|
|
|
212
212
|
// filter is always join relevant
|
|
213
213
|
// if the column ends up in an `inline` -> each assoc step is join relevant
|
|
214
214
|
child.$refLink.onlyForeignKeyAccess = false
|
|
215
|
+
// all parents are now also join relevant
|
|
216
|
+
markParentAsJoinRelevant(child.parent)
|
|
215
217
|
} else {
|
|
216
218
|
child.$refLink.onlyForeignKeyAccess = true
|
|
217
219
|
}
|
|
@@ -223,6 +225,8 @@ class JoinTree {
|
|
|
223
225
|
if (node.$refLink && (!elements || !(child.$refLink.definition.name in elements))) {
|
|
224
226
|
// no foreign key access
|
|
225
227
|
node.$refLink.onlyForeignKeyAccess = false
|
|
228
|
+
markParentAsJoinRelevant(node.parent)
|
|
229
|
+
|
|
226
230
|
col.$refLinks[i - 1] = node.$refLink
|
|
227
231
|
}
|
|
228
232
|
|
|
@@ -233,6 +237,15 @@ class JoinTree {
|
|
|
233
237
|
}
|
|
234
238
|
return true
|
|
235
239
|
|
|
240
|
+
function markParentAsJoinRelevant(parent) {
|
|
241
|
+
while (parent) {
|
|
242
|
+
if (parent.$refLink?.definition.isAssociation) {
|
|
243
|
+
parent.$refLink.onlyForeignKeyAccess = false
|
|
244
|
+
}
|
|
245
|
+
parent = parent.parent
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
236
249
|
function joinId(step, args, where) {
|
|
237
250
|
let appendix
|
|
238
251
|
if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
|
package/lib/search.js
CHANGED
|
@@ -66,7 +66,7 @@ const _getSearchableColumns = entity => {
|
|
|
66
66
|
if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
let
|
|
69
|
+
let skipDefaultSearchableElements = false
|
|
70
70
|
const deepSearchCandidates = []
|
|
71
71
|
|
|
72
72
|
// build a map of columns annotated with the @cds.search annotation
|
|
@@ -74,13 +74,18 @@ const _getSearchableColumns = entity => {
|
|
|
74
74
|
const columnName = key.split(cdsSearchTerm + '.').pop()
|
|
75
75
|
const annotationKey = `${cdsSearchTerm}.${columnName}`
|
|
76
76
|
const annotationValue = entity[annotationKey]
|
|
77
|
-
if (annotationValue) atLeastOneColumnIsSearchable = true
|
|
78
77
|
|
|
79
78
|
const column = entity.elements[columnName]
|
|
79
|
+
// always ignore virtual elements from search
|
|
80
|
+
if(column?.virtual) continue
|
|
80
81
|
if (column?.isAssociation || columnName.includes('.')) {
|
|
81
|
-
|
|
82
|
+
const ref = columnName.split('.')
|
|
83
|
+
if(ref.length > 1) skipDefaultSearchableElements = true
|
|
84
|
+
deepSearchCandidates.push({ ref })
|
|
82
85
|
continue
|
|
83
86
|
}
|
|
87
|
+
|
|
88
|
+
if(annotationValue) skipDefaultSearchableElements = true
|
|
84
89
|
cdsSearchColumnMap.set(columnName, annotationValue)
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -99,7 +104,7 @@ const _getSearchableColumns = entity => {
|
|
|
99
104
|
// if at least one element is explicitly annotated as searchable, e.g.:
|
|
100
105
|
// `@cds.search { element1: true }` or `@cds.search { element1 }`
|
|
101
106
|
// and it is not the current column name, then it must be excluded from the search
|
|
102
|
-
if (
|
|
107
|
+
if (skipDefaultSearchableElements) return false
|
|
103
108
|
|
|
104
109
|
// the element is considered searchable if it is explicitly annotated as such or
|
|
105
110
|
// if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
|
package/lib/utils.js
CHANGED
|
@@ -21,6 +21,12 @@ function prettyPrintRef(ref, model = null) {
|
|
|
21
21
|
}, '')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function hasOwnSkip(definition) {
|
|
25
|
+
return (
|
|
26
|
+
definition && Object.hasOwn(definition, '@cds.persistence.skip') && definition['@cds.persistence.skip'] === true
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* Determines if a definition is calculated on read.
|
|
26
32
|
* - Stored calculated elements are not unfolded
|
|
@@ -123,7 +129,6 @@ function getModelUtils(model, query) {
|
|
|
123
129
|
if (!def || !isLocalized(def)) return def
|
|
124
130
|
return model.definitions[`localized.${def.name}`] || def
|
|
125
131
|
}
|
|
126
|
-
|
|
127
132
|
return {
|
|
128
133
|
getLocalizedName,
|
|
129
134
|
isLocalized,
|
|
@@ -139,4 +144,5 @@ module.exports = {
|
|
|
139
144
|
getImplicitAlias,
|
|
140
145
|
defineProperty,
|
|
141
146
|
getModelUtils,
|
|
147
|
+
hasOwnSkip,
|
|
142
148
|
}
|
package/package.json
CHANGED