@cap-js/db-service 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/lib/SQLService.js +6 -7
- package/lib/cql-functions.js +1 -1
- package/lib/cqn2sql.js +115 -29
- package/lib/cqn4sql.js +43 -26
- package/lib/search.js +9 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
- The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
5
5
|
- This project adheres to [Semantic Versioning](http://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.4.0...db-service-v2.5.0) (2025-09-30)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* make hana server version accessible to sub classes ([#1263](https://github.com/cap-js/cds-dbs/issues/1263)) ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
|
|
13
|
+
* sets default to hana cloud if server version cannot be detected ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
* **`@cds.search`:** no duplicates for search along `to-many` paths ([#1341](https://github.com/cap-js/cds-dbs/issues/1341)) ([5c5f4fb](https://github.com/cap-js/cds-dbs/commit/5c5f4fbf790f718c3cf1bcb6f3bf7be421be598f))
|
|
19
|
+
* associations in `[@cds](https://github.com/cds).search` are additive ([#1355](https://github.com/cap-js/cds-dbs/issues/1355)) ([ea931cb](https://github.com/cap-js/cds-dbs/commit/ea931cb120c2857aa18a4eb68b893926c0999a9f))
|
|
20
|
+
* set proper element link for path into fk ([#1344](https://github.com/cap-js/cds-dbs/issues/1344)) ([9f365d3](https://github.com/cap-js/cds-dbs/commit/9f365d35ac614969d8fd2c2a9a1a2e0cd643969d))
|
|
21
|
+
|
|
7
22
|
## [2.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.3.0...db-service-v2.4.0) (2025-08-27)
|
|
8
23
|
|
|
9
24
|
|
package/lib/SQLService.js
CHANGED
|
@@ -2,7 +2,7 @@ const cds = require('@sap/cds'),
|
|
|
2
2
|
DEBUG = cds.debug('sql|db')
|
|
3
3
|
const { Readable, Transform } = require('stream')
|
|
4
4
|
const { pipeline } = require('stream/promises')
|
|
5
|
-
const {
|
|
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]`
|
|
@@ -404,10 +407,6 @@ class SQLService extends DatabaseService {
|
|
|
404
407
|
*/
|
|
405
408
|
cqn2sql(query, values) {
|
|
406
409
|
let q = this.cqn4sql(query)
|
|
407
|
-
let kind = q.kind || Object.keys(q)[0]
|
|
408
|
-
if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
|
|
409
|
-
q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
|
|
410
|
-
}
|
|
411
410
|
let cqn2sql = new this.class.CQN2SQL(this)
|
|
412
411
|
return cqn2sql.render(q, values)
|
|
413
412
|
}
|
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
|
@@ -61,7 +61,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
61
61
|
if (!hasCustomJoins && inferred.SELECT?.search) {
|
|
62
62
|
// we need an instance of query because the elements of the query are needed for the calculation of the search columns
|
|
63
63
|
if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
|
|
64
|
-
const searchTerm =
|
|
64
|
+
const searchTerm = getSearch(inferred.SELECT.search, inferred)
|
|
65
65
|
if (searchTerm) {
|
|
66
66
|
// Search target can be a navigation, in that case use _target to get the correct entity
|
|
67
67
|
const { where, having } = transformSearch(searchTerm)
|
|
@@ -123,14 +123,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
123
123
|
// calculate the primary keys of the target entity, there is always exactly
|
|
124
124
|
// one query source for UPDATE / DELETE
|
|
125
125
|
const queryTarget = Object.values(inferred.sources)[0].definition
|
|
126
|
-
const primaryKey = { list:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (e.key === true && !e.virtual && e.isAssociation !== true) {
|
|
130
|
-
subquery.SELECT.columns.push({ ref: [e.name] })
|
|
131
|
-
primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
|
|
132
|
-
}
|
|
133
|
-
}
|
|
126
|
+
const primaryKey = { list: getPrimaryKey(queryTarget, uniqueSubqueryAlias) }
|
|
127
|
+
// match primary keys of the target entity with the subquery
|
|
128
|
+
primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
|
|
134
129
|
|
|
135
130
|
const transformedSubquery = cqn4sql(subquery, model)
|
|
136
131
|
|
|
@@ -258,7 +253,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
258
253
|
if (inferred.SELECT[prop]) {
|
|
259
254
|
return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
|
|
260
255
|
} else {
|
|
261
|
-
return { [prop]: [searchTerm] }
|
|
256
|
+
return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
|
|
262
257
|
}
|
|
263
258
|
}
|
|
264
259
|
|
|
@@ -1211,7 +1206,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1211
1206
|
if(column.element && !isAssocOrStruct(column.element)) {
|
|
1212
1207
|
columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
|
|
1213
1208
|
const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
|
|
1214
|
-
setElementOnColumns(res, element)
|
|
1209
|
+
setElementOnColumns(res, column.element)
|
|
1215
1210
|
return [res]
|
|
1216
1211
|
}
|
|
1217
1212
|
|
|
@@ -1789,7 +1784,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1789
1784
|
filterConditions.forEach(f => {
|
|
1790
1785
|
transformedWhere.push('and')
|
|
1791
1786
|
if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
|
|
1792
|
-
else if (f.length > 3 || f.includes('or') || f.includes('and')) transformedWhere.push(asXpr(f))
|
|
1787
|
+
else if (f.length > 3 || f.includes('or') || f.includes('and') || f.includes('in')) transformedWhere.push(asXpr(f))
|
|
1793
1788
|
else transformedWhere.push(...f)
|
|
1794
1789
|
})
|
|
1795
1790
|
} else {
|
|
@@ -2221,30 +2216,41 @@ function cqn4sql(originalQuery, model) {
|
|
|
2221
2216
|
return SELECT
|
|
2222
2217
|
}
|
|
2223
2218
|
|
|
2224
|
-
|
|
2225
|
-
* For a given search
|
|
2226
|
-
*
|
|
2219
|
+
/**
|
|
2220
|
+
* For a given search term calculate a search expression which can be used in a where clause.
|
|
2221
|
+
* The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
|
|
2222
|
+
* the search results of the subquery.
|
|
2227
2223
|
*
|
|
2228
|
-
* @param {object}
|
|
2224
|
+
* @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source.
|
|
2229
2225
|
* @param {object} query - The FROM clause of the CQN statement.
|
|
2230
2226
|
*
|
|
2231
2227
|
* @returns {(Object|null)} returns either:
|
|
2228
|
+
* - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
|
|
2232
2229
|
* - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
|
|
2233
2230
|
* - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
|
|
2234
2231
|
*/
|
|
2235
|
-
function
|
|
2232
|
+
function getSearch(searchTerm, query) {
|
|
2236
2233
|
const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
|
|
2237
2234
|
const searchIn = computeColumnsToBeSearched(inferred, entity)
|
|
2238
|
-
if (searchIn.length
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
return null
|
|
2235
|
+
if (searchIn.length === 0) return null
|
|
2236
|
+
|
|
2237
|
+
const searchFunc = {
|
|
2238
|
+
func: 'search',
|
|
2239
|
+
args: [
|
|
2240
|
+
searchIn.length === 1 ? searchIn[0] : { list: searchIn },
|
|
2241
|
+
searchTerm.length === 1 && 'val' in searchTerm[0] ? searchTerm[0] : { xpr: searchTerm },
|
|
2242
|
+
],
|
|
2247
2243
|
}
|
|
2244
|
+
// for aggregated queries / search on subqueries we do not do a subquery search
|
|
2245
|
+
if (inferred.SELECT.groupBy || entity.SELECT)
|
|
2246
|
+
return searchFunc
|
|
2247
|
+
|
|
2248
|
+
const matchColumns = getPrimaryKey(entity)
|
|
2249
|
+
if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
|
|
2250
|
+
return searchFunc
|
|
2251
|
+
|
|
2252
|
+
const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
|
|
2253
|
+
return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
|
|
2248
2254
|
}
|
|
2249
2255
|
|
|
2250
2256
|
/**
|
|
@@ -2410,6 +2416,17 @@ function setElementOnColumns(col, element) {
|
|
|
2410
2416
|
defineProperty(col, 'element', element)
|
|
2411
2417
|
}
|
|
2412
2418
|
|
|
2419
|
+
function getPrimaryKey(entity, tableAlias = null) {
|
|
2420
|
+
const primaryKey = []
|
|
2421
|
+
for (const k of Object.keys(entity.elements)) {
|
|
2422
|
+
const e = entity.elements[k]
|
|
2423
|
+
if (e.key === true && !e.virtual && e.isAssociation !== true) {
|
|
2424
|
+
primaryKey.push({ ref: tableAlias ? [tableAlias, e.name] : [e.name] })
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
return primaryKey
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2413
2430
|
const getName = col => col.as || col.ref?.at(-1)
|
|
2414
2431
|
const idOnly = ref => ref.id || ref
|
|
2415
2432
|
const refWithConditions = step => {
|
package/lib/search.js
CHANGED
|
@@ -66,7 +66,7 @@ const _getSearchableColumns = entity => {
|
|
|
66
66
|
if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
let
|
|
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/package.json
CHANGED