@cap-js/db-service 1.13.0 → 1.14.1
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 +22 -0
- package/lib/SQLService.js +35 -4
- package/lib/cql-functions.js +3 -1
- package/lib/cqn2sql.js +151 -88
- package/lib/cqn4sql.js +9 -16
- package/lib/infer/index.js +4 -3
- package/lib/utils.js +19 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@
|
|
|
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
|
+
## [1.14.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.0...db-service-v1.14.1) (2024-10-28)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
* deep delete for views ([#496](https://github.com/cap-js/cds-dbs/issues/496)) ([82154ef](https://github.com/cap-js/cds-dbs/commit/82154ef8b837f17e81e2516056e03ff215f1dff8))
|
|
13
|
+
* properly support `default`, `cds.on.insert` and `cds.on.update` for `UPSERT` queries ([#425](https://github.com/cap-js/cds-dbs/issues/425)) ([338e9f5](https://github.com/cap-js/cds-dbs/commit/338e9f5de9109d36013208547fc648c17ce8c7b0))
|
|
14
|
+
* SELECT cds.hana.BINARY ([#870](https://github.com/cap-js/cds-dbs/issues/870)) ([33c3ebe](https://github.com/cap-js/cds-dbs/commit/33c3ebe84be4c0181b1c230d5f2d332332201ce0))
|
|
15
|
+
|
|
16
|
+
## [1.14.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.13.0...db-service-v1.14.0) (2024-10-15)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
* assoc-like calc elements after exists predicate ([#831](https://github.com/cap-js/cds-dbs/issues/831)) ([05f7d75](https://github.com/cap-js/cds-dbs/commit/05f7d75837495d58cc4f72ad628077bdebb0acf6))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
* Improved behavioral consistency between the database services ([#837](https://github.com/cap-js/cds-dbs/issues/837)) ([b6f7187](https://github.com/cap-js/cds-dbs/commit/b6f718701e48dfb1c4c3d98ee016ec45930f8e7b))
|
|
27
|
+
* Treat assoc-like calculated elements as unmanaged assocs ([#830](https://github.com/cap-js/cds-dbs/issues/830)) ([cbe0df7](https://github.com/cap-js/cds-dbs/commit/cbe0df7a66fec0d421947767adc8621ed8bf236c))
|
|
28
|
+
|
|
7
29
|
## [1.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
|
|
8
30
|
|
|
9
31
|
|
package/lib/SQLService.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const cds = require('@sap/cds'),
|
|
2
2
|
DEBUG = cds.debug('sql|db')
|
|
3
3
|
const { Readable } = require('stream')
|
|
4
|
-
const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
|
|
4
|
+
const { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
|
|
5
5
|
const DatabaseService = require('./common/DatabaseService')
|
|
6
6
|
const cqn4sql = require('./cqn4sql')
|
|
7
7
|
|
|
@@ -25,14 +25,16 @@ class SQLService extends DatabaseService {
|
|
|
25
25
|
this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
|
|
26
26
|
if (cds.env.features.db_strict) {
|
|
27
27
|
this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => {
|
|
28
|
-
const elements = query.target?.elements
|
|
28
|
+
const elements = query.target?.elements
|
|
29
|
+
if (!elements) return
|
|
29
30
|
const kind = query.kind || Object.keys(query)[0]
|
|
30
31
|
const operation = query[kind]
|
|
31
32
|
if (!operation.columns && !operation.entries && !operation.data) return
|
|
32
33
|
const columns =
|
|
33
34
|
operation.columns ||
|
|
34
35
|
Object.keys(
|
|
35
|
-
operation.data ||
|
|
36
|
+
operation.data ||
|
|
37
|
+
operation.entries?.reduce((acc, obj) => {
|
|
36
38
|
return Object.assign(acc, obj)
|
|
37
39
|
}, {}),
|
|
38
40
|
)
|
|
@@ -214,7 +216,31 @@ class SQLService extends DatabaseService {
|
|
|
214
216
|
// REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
|
|
215
217
|
return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
|
|
216
218
|
async function deep_delete(/** @type {Request} */ req) {
|
|
217
|
-
|
|
219
|
+
const transitions = getTransition(req.target, this, false, req.query.cmd || 'DELETE')
|
|
220
|
+
if (transitions.target !== transitions.queryTarget) {
|
|
221
|
+
const keys = []
|
|
222
|
+
const transitionsTarget = transitions.queryTarget.keys || transitions.queryTarget.elements
|
|
223
|
+
for (const key in transitionsTarget) {
|
|
224
|
+
const exists = e => e && !e.virtual && !e.value && !e.isAssociation
|
|
225
|
+
if (exists(transitionsTarget[key])) keys.push(key)
|
|
226
|
+
}
|
|
227
|
+
const matchedKeys = keys.filter(key => transitions.mapping.has(key)).map(k => ({ ref: [k] }))
|
|
228
|
+
const query = DELETE.from({
|
|
229
|
+
ref: [
|
|
230
|
+
{
|
|
231
|
+
id: transitions.target.name,
|
|
232
|
+
where: [
|
|
233
|
+
{ list: matchedKeys.map(k => transitions.mapping.get(k.ref[0])) },
|
|
234
|
+
'in',
|
|
235
|
+
SELECT.from(req.query.DELETE.from).columns(matchedKeys).where(req.query.DELETE.where),
|
|
236
|
+
],
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
})
|
|
240
|
+
return this.onDELETE({ query, target: transitions.target })
|
|
241
|
+
}
|
|
242
|
+
const table = getDBTable(req.target)
|
|
243
|
+
const { compositions } = table
|
|
218
244
|
if (compositions) {
|
|
219
245
|
// Transform CQL`DELETE from Foo[p1] WHERE p2` into CQL`DELETE from Foo[p1 and p2]`
|
|
220
246
|
let { from, where } = req.query.DELETE
|
|
@@ -241,6 +267,7 @@ class SQLService extends DatabaseService {
|
|
|
241
267
|
)
|
|
242
268
|
// Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
|
|
243
269
|
const query = DELETE.from({ ref: [...from.ref, c.name] })
|
|
270
|
+
query.target = c._target
|
|
244
271
|
return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
|
|
245
272
|
}),
|
|
246
273
|
)
|
|
@@ -466,6 +493,10 @@ const sqls = new (class extends SQLService {
|
|
|
466
493
|
get factory() {
|
|
467
494
|
return null
|
|
468
495
|
}
|
|
496
|
+
|
|
497
|
+
get model() {
|
|
498
|
+
return cds.model
|
|
499
|
+
}
|
|
469
500
|
})()
|
|
470
501
|
cds.extend(cds.ql.Query).with(
|
|
471
502
|
class {
|
package/lib/cql-functions.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const cds = require("@sap/cds")
|
|
2
|
+
|
|
1
3
|
const StandardFunctions = {
|
|
2
4
|
// OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
|
|
3
5
|
|
|
@@ -59,7 +61,7 @@ const StandardFunctions = {
|
|
|
59
61
|
* @param {string} x
|
|
60
62
|
* @returns {string}
|
|
61
63
|
*/
|
|
62
|
-
countdistinct: x => `count(distinct ${x ||
|
|
64
|
+
countdistinct: x => `count(distinct ${x || cds.error`countdistinct requires a ref to be counted`})`,
|
|
63
65
|
/**
|
|
64
66
|
* Generates SQL statement that produces the index of the first occurrence of the second string in the first string
|
|
65
67
|
* @param {string} x
|
package/lib/cqn2sql.js
CHANGED
|
@@ -4,12 +4,6 @@ const cqn4sql = require('./cqn4sql')
|
|
|
4
4
|
const _simple_queries = cds.env.features.sql_simple_queries
|
|
5
5
|
const _strict_booleans = _simple_queries < 2
|
|
6
6
|
|
|
7
|
-
const BINARY_TYPES = {
|
|
8
|
-
'cds.Binary': 1,
|
|
9
|
-
'cds.LargeBinary': 1,
|
|
10
|
-
'cds.hana.BINARY': 1,
|
|
11
|
-
}
|
|
12
|
-
|
|
13
7
|
const { Readable } = require('stream')
|
|
14
8
|
|
|
15
9
|
const DEBUG = (() => {
|
|
@@ -18,8 +12,8 @@ const DEBUG = (() => {
|
|
|
18
12
|
return cds.debug('sql|sqlite')
|
|
19
13
|
//if (DEBUG) {
|
|
20
14
|
// return DEBUG
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
// (sql, ...more) => DEBUG (sql.replace(/(?:SELECT[\n\r\s]+(json_group_array\()?[\n\r\s]*json_insert\((\n|\r|.)*?\)[\n\r\s]*\)?[\n\r\s]+as[\n\r\s]+_json_[\n\r\s]+FROM[\n\r\s]*\(|\)[\n\r\s]*(\)[\n\r\s]+AS )|\)$)/gim,(a,b,c,d) => d || ''), ...more)
|
|
16
|
+
// FIXME: looses closing ) on INSERT queries
|
|
23
17
|
//}
|
|
24
18
|
})()
|
|
25
19
|
|
|
@@ -42,6 +36,12 @@ class CQN2SQLRenderer {
|
|
|
42
36
|
}
|
|
43
37
|
}
|
|
44
38
|
|
|
39
|
+
BINARY_TYPES = {
|
|
40
|
+
'cds.Binary': 1,
|
|
41
|
+
'cds.LargeBinary': 1,
|
|
42
|
+
'cds.hana.BINARY': 1,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
45
|
static _add_mixins(aspect, mixins) {
|
|
46
46
|
const fqn = this.name + aspect
|
|
47
47
|
const types = cds.builtin.types
|
|
@@ -88,6 +88,7 @@ class CQN2SQLRenderer {
|
|
|
88
88
|
this.values = [] // prepare values, filled in by subroutines
|
|
89
89
|
this[kind]((this.cqn = q)) // actual sql rendering happens here
|
|
90
90
|
if (vars?.length && !this.values?.length) this.values = vars
|
|
91
|
+
if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
|
|
91
92
|
const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
|
|
92
93
|
DEBUG?.(
|
|
93
94
|
this.sql,
|
|
@@ -116,8 +117,13 @@ class CQN2SQLRenderer {
|
|
|
116
117
|
* @param {import('./infer/cqn').CREATE} q
|
|
117
118
|
*/
|
|
118
119
|
CREATE(q) {
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
let { target } = q
|
|
121
|
+
let query = target?.query || q.CREATE.as
|
|
122
|
+
if (!target || target._unresolved) {
|
|
123
|
+
const entity = q.CREATE.entity
|
|
124
|
+
target = typeof entity === 'string' ? { name: entity } : q.CREATE.entity
|
|
125
|
+
}
|
|
126
|
+
|
|
121
127
|
const name = this.name(target.name)
|
|
122
128
|
// Don't allow place holders inside views
|
|
123
129
|
delete this.values
|
|
@@ -136,13 +142,15 @@ class CQN2SQLRenderer {
|
|
|
136
142
|
*/
|
|
137
143
|
CREATE_elements(elements) {
|
|
138
144
|
let sql = ''
|
|
145
|
+
let keys = ''
|
|
139
146
|
for (let e in elements) {
|
|
140
147
|
const definition = elements[e]
|
|
141
148
|
if (definition.isAssociation) continue
|
|
149
|
+
if (definition.key) keys = `${keys}, ${this.quote(definition.name)}`
|
|
142
150
|
const s = this.CREATE_element(definition)
|
|
143
|
-
if (s) sql +=
|
|
151
|
+
if (s) sql += `, ${s}`
|
|
144
152
|
}
|
|
145
|
-
return sql.slice(
|
|
153
|
+
return `${sql.slice(2)}${keys && `, PRIMARY KEY(${keys.slice(2)})`}`
|
|
146
154
|
}
|
|
147
155
|
|
|
148
156
|
/**
|
|
@@ -203,8 +211,9 @@ class CQN2SQLRenderer {
|
|
|
203
211
|
*/
|
|
204
212
|
DROP(q) {
|
|
205
213
|
const { target } = q
|
|
206
|
-
const isView = target
|
|
207
|
-
|
|
214
|
+
const isView = target?.query || target?.projection || q.DROP.view
|
|
215
|
+
const name = target?.name || q.DROP.table?.ref?.[0] || q.DROP.view?.ref?.[0]
|
|
216
|
+
return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(name))}`)
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
// SELECT Statements ------------------------------------------------
|
|
@@ -223,7 +232,7 @@ class CQN2SQLRenderer {
|
|
|
223
232
|
|
|
224
233
|
// REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
|
|
225
234
|
if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
|
|
226
|
-
|
|
235
|
+
const columns = this.SELECT_columns(q)
|
|
227
236
|
let sql = `SELECT`
|
|
228
237
|
if (distinct) sql += ` DISTINCT`
|
|
229
238
|
if (!_empty(columns)) sql += ` ${columns}`
|
|
@@ -484,8 +493,6 @@ class CQN2SQLRenderer {
|
|
|
484
493
|
*/
|
|
485
494
|
INSERT_entries(q) {
|
|
486
495
|
const { INSERT } = q
|
|
487
|
-
const entity = this.name(q.target?.name || INSERT.into.ref[0])
|
|
488
|
-
const alias = INSERT.into.as
|
|
489
496
|
const elements = q.elements || q.target?.elements
|
|
490
497
|
if (!elements && !INSERT.entries?.length) {
|
|
491
498
|
return // REVISIT: mtx sends an insert statement without entries and no reference entity
|
|
@@ -497,19 +504,14 @@ class CQN2SQLRenderer {
|
|
|
497
504
|
/** @type {string[]} */
|
|
498
505
|
this.columns = columns
|
|
499
506
|
|
|
507
|
+
const alias = INSERT.into.as
|
|
508
|
+
const entity = this.name(q.target?.name || INSERT.into.ref[0])
|
|
500
509
|
if (!elements) {
|
|
501
510
|
this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
|
|
502
511
|
const param = this.param.bind(this, { ref: ['?'] })
|
|
503
512
|
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
|
|
504
513
|
}
|
|
505
514
|
|
|
506
|
-
const extractions = this.managed(
|
|
507
|
-
columns.map(c => ({ name: c })),
|
|
508
|
-
elements,
|
|
509
|
-
!!q.UPSERT,
|
|
510
|
-
)
|
|
511
|
-
const extraction = extractions.map(c => c.sql)
|
|
512
|
-
|
|
513
515
|
// Include this.values for placeholders
|
|
514
516
|
/** @type {unknown[][]} */
|
|
515
517
|
this.entries = []
|
|
@@ -523,8 +525,9 @@ class CQN2SQLRenderer {
|
|
|
523
525
|
this.entries = [[...this.values, stream]]
|
|
524
526
|
}
|
|
525
527
|
|
|
528
|
+
const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
|
|
526
529
|
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
|
|
527
|
-
}) SELECT ${
|
|
530
|
+
}) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
|
|
528
531
|
}
|
|
529
532
|
|
|
530
533
|
async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
|
|
@@ -562,7 +565,7 @@ class CQN2SQLRenderer {
|
|
|
562
565
|
|
|
563
566
|
buffer += '"'
|
|
564
567
|
} else {
|
|
565
|
-
if (elements[key]?.type in BINARY_TYPES) {
|
|
568
|
+
if (elements[key]?.type in this.BINARY_TYPES) {
|
|
566
569
|
val = transformBase64(val)
|
|
567
570
|
}
|
|
568
571
|
buffer += `${keyJSON}${JSON.stringify(val)}`
|
|
@@ -610,7 +613,7 @@ class CQN2SQLRenderer {
|
|
|
610
613
|
|
|
611
614
|
buffer += '"'
|
|
612
615
|
} else {
|
|
613
|
-
if (elements[this.columns[key]]?.type in BINARY_TYPES) {
|
|
616
|
+
if (elements[this.columns[key]]?.type in this.BINARY_TYPES) {
|
|
614
617
|
val = transformBase64(val)
|
|
615
618
|
}
|
|
616
619
|
buffer += `${sepsub}${val === undefined ? 'null' : JSON.stringify(val)}`
|
|
@@ -639,18 +642,7 @@ class CQN2SQLRenderer {
|
|
|
639
642
|
const entity = this.name(q.target?.name || INSERT.into.ref[0])
|
|
640
643
|
const alias = INSERT.into.as
|
|
641
644
|
const elements = q.elements || q.target?.elements
|
|
642
|
-
const columns = INSERT.columns
|
|
643
|
-
|| cds.error`Cannot insert rows without columns or elements`
|
|
644
|
-
|
|
645
|
-
const inputConverter = this.class._convertInput
|
|
646
|
-
const extraction = columns.map((c, i) => {
|
|
647
|
-
const extract = `value->>'$[${i}]'`
|
|
648
|
-
const element = elements?.[c]
|
|
649
|
-
const converter = element?.[inputConverter]
|
|
650
|
-
return converter?.(extract, element) || extract
|
|
651
|
-
})
|
|
652
|
-
|
|
653
|
-
this.columns = columns
|
|
645
|
+
const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
|
|
654
646
|
|
|
655
647
|
if (!elements) {
|
|
656
648
|
this.entries = INSERT.rows
|
|
@@ -668,6 +660,10 @@ class CQN2SQLRenderer {
|
|
|
668
660
|
this.entries = [[...this.values, stream]]
|
|
669
661
|
}
|
|
670
662
|
|
|
663
|
+
const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
|
|
664
|
+
.slice(0, columns.length)
|
|
665
|
+
.map(c => c.converter(c.extract))
|
|
666
|
+
|
|
671
667
|
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
|
|
672
668
|
}) SELECT ${extraction} FROM json_each(?)`)
|
|
673
669
|
}
|
|
@@ -679,7 +675,7 @@ class CQN2SQLRenderer {
|
|
|
679
675
|
*/
|
|
680
676
|
INSERT_values(q) {
|
|
681
677
|
let { columns, values } = q.INSERT
|
|
682
|
-
return this.
|
|
678
|
+
return this.render({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
|
|
683
679
|
}
|
|
684
680
|
|
|
685
681
|
/**
|
|
@@ -730,14 +726,37 @@ class CQN2SQLRenderer {
|
|
|
730
726
|
*/
|
|
731
727
|
UPSERT(q) {
|
|
732
728
|
const { UPSERT } = q
|
|
733
|
-
|
|
729
|
+
|
|
734
730
|
let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
731
|
+
if (!q.target?.keys) return sql
|
|
732
|
+
const keys = []
|
|
733
|
+
for (const k of ObjectKeys(q.target?.keys)) {
|
|
734
|
+
const element = q.target.keys[k]
|
|
735
|
+
if (element.isAssociation || element.virtual) continue
|
|
736
|
+
keys.push(k)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const elements = q.target?.elements || {}
|
|
740
|
+
// temporal data
|
|
741
|
+
for (const k of ObjectKeys(elements)) {
|
|
742
|
+
if (elements[k]['@cds.valid.from']) keys.push(k)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const keyCompare = keys
|
|
746
|
+
.map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
|
|
747
|
+
.join(' AND ')
|
|
748
|
+
|
|
749
|
+
const columns = this.columns // this.columns is computed as part of this.INSERT
|
|
750
|
+
const managed = this._managed.slice(0, columns.length)
|
|
751
|
+
|
|
752
|
+
const extractkeys = managed
|
|
753
|
+
.filter(c => keys.includes(c.name))
|
|
754
|
+
.map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
|
|
738
755
|
|
|
739
|
-
|
|
740
|
-
|
|
756
|
+
const entity = this.name(q.target?.name || UPSERT.into.ref[0])
|
|
757
|
+
sql = `SELECT ${managed.map(c => c.upsert)} FROM (SELECT value, ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`
|
|
758
|
+
|
|
759
|
+
const updateColumns = columns.filter(c => {
|
|
741
760
|
if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
|
|
742
761
|
let e = elements[c]
|
|
743
762
|
if (!e) return true //> pass through to native SQL columns not in CDS model
|
|
@@ -747,14 +766,8 @@ class CQN2SQLRenderer {
|
|
|
747
766
|
else return true
|
|
748
767
|
}).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
|
|
749
768
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
keys = keys.map(k => this.quote(k))
|
|
754
|
-
const conflict = updateColumns.length
|
|
755
|
-
? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
|
|
756
|
-
: `ON CONFLICT(${keys}) DO NOTHING`
|
|
757
|
-
return (this.sql = `${sql} WHERE true ${conflict}`)
|
|
769
|
+
return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
|
|
770
|
+
} WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
|
|
758
771
|
}
|
|
759
772
|
|
|
760
773
|
// UPDATE Statements ------------------------------------------------
|
|
@@ -783,7 +796,9 @@ class CQN2SQLRenderer {
|
|
|
783
796
|
}
|
|
784
797
|
}
|
|
785
798
|
|
|
786
|
-
const extraction = this.managed(columns, elements
|
|
799
|
+
const extraction = this.managed(columns, elements)
|
|
800
|
+
.filter((c, i) => columns[i] || c.onUpdate)
|
|
801
|
+
.map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)
|
|
787
802
|
|
|
788
803
|
sql += ` SET ${extraction}`
|
|
789
804
|
if (where) sql += ` WHERE ${this.where(where)}`
|
|
@@ -1035,56 +1050,104 @@ class CQN2SQLRenderer {
|
|
|
1035
1050
|
}
|
|
1036
1051
|
|
|
1037
1052
|
/**
|
|
1038
|
-
*
|
|
1053
|
+
* Converts the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
|
|
1039
1054
|
* @param {object[]} columns
|
|
1040
1055
|
* @param {import('./infer/cqn').elements} elements
|
|
1041
1056
|
* @param {Boolean} isUpdate
|
|
1042
1057
|
* @returns {string[]} Array of SQL expressions for processing input JSON data
|
|
1043
1058
|
*/
|
|
1044
|
-
managed(columns, elements
|
|
1045
|
-
const
|
|
1059
|
+
managed(columns, elements) {
|
|
1060
|
+
const cdsOnInsert = '@cds.on.insert'
|
|
1061
|
+
const cdsOnUpdate = '@cds.on.update'
|
|
1062
|
+
|
|
1046
1063
|
const { _convertInput } = this.class
|
|
1047
1064
|
// Ensure that missing managed columns are added
|
|
1048
1065
|
const requiredColumns = !elements
|
|
1049
1066
|
? []
|
|
1050
|
-
:
|
|
1051
|
-
.filter(
|
|
1052
|
-
e
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1067
|
+
: ObjectKeys(elements)
|
|
1068
|
+
.filter(e => {
|
|
1069
|
+
const element = elements[e]
|
|
1070
|
+
// Actual mandatory check
|
|
1071
|
+
if (!(element.default || element[cdsOnInsert] || element[cdsOnUpdate])) return false
|
|
1072
|
+
// Physical column check
|
|
1073
|
+
if (!element || element.virtual || element.isAssociation) return false
|
|
1074
|
+
// Existence check
|
|
1075
|
+
if (columns.find(c => c.name === e)) return false
|
|
1076
|
+
return true
|
|
1077
|
+
})
|
|
1056
1078
|
.map(name => ({ name, sql: 'NULL' }))
|
|
1057
1079
|
|
|
1080
|
+
const keys = ObjectKeys(elements).filter(e => elements[e].key && !elements[e].isAssociation)
|
|
1081
|
+
const keyZero = keys[0] && this.quote(keys[0])
|
|
1082
|
+
|
|
1058
1083
|
return [...columns, ...requiredColumns].map(({ name, sql }) => {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
let
|
|
1063
|
-
if (
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
else if (!isUpdate && element.default) {
|
|
1068
|
-
const d = element.default
|
|
1069
|
-
if (d.val !== undefined || d.ref?.[0] === '$now') {
|
|
1070
|
-
// REVISIT: d.ref is not used afterwards
|
|
1071
|
-
sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
|
|
1072
|
-
} ELSE ${sql} END)`
|
|
1073
|
-
}
|
|
1084
|
+
const element = elements?.[name] || {}
|
|
1085
|
+
|
|
1086
|
+
const converter = a => element[_convertInput]?.(a, element) || a
|
|
1087
|
+
let extract
|
|
1088
|
+
if (!sql) {
|
|
1089
|
+
({ sql, extract } = this.managed_extract(name, element, converter))
|
|
1090
|
+
} else {
|
|
1091
|
+
extract = sql = converter(sql)
|
|
1074
1092
|
}
|
|
1093
|
+
// if (sql[0] !== '$') sql = converter(sql, element)
|
|
1094
|
+
|
|
1095
|
+
let onInsert = this.managed_session_context(element[cdsOnInsert]?.['='])
|
|
1096
|
+
|| this.managed_session_context(element.default?.ref?.[0])
|
|
1097
|
+
|| (element.default?.val !== undefined && { val: element.default.val, param: false })
|
|
1098
|
+
let onUpdate = this.managed_session_context(element[cdsOnUpdate]?.['='])
|
|
1099
|
+
|
|
1100
|
+
if (onInsert) onInsert = this.expr(onInsert)
|
|
1101
|
+
if (onUpdate) onUpdate = this.expr(onUpdate)
|
|
1102
|
+
|
|
1103
|
+
const qname = this.quote(name)
|
|
1104
|
+
|
|
1105
|
+
const insert = onInsert ? this.managed_default(name, converter(onInsert), sql) : sql
|
|
1106
|
+
const update = onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql
|
|
1107
|
+
const upsert = keyZero && (
|
|
1108
|
+
// upsert requires the keys to be provided for the existance join (default values optional)
|
|
1109
|
+
element.key
|
|
1110
|
+
// If both insert and update have the same managed definition exclude the old value check
|
|
1111
|
+
|| (onInsert && onUpdate && insert === update)
|
|
1112
|
+
? `${insert} as ${qname}`
|
|
1113
|
+
: `CASE WHEN OLD.${keyZero} IS NULL THEN ${
|
|
1114
|
+
// If key of old is null execute insert
|
|
1115
|
+
insert
|
|
1116
|
+
} ELSE ${
|
|
1117
|
+
// Else execute managed update or keep old if no new data if provided
|
|
1118
|
+
onUpdate ? update : this.managed_default(name, `OLD.${qname}`, update)
|
|
1119
|
+
} END as ${qname}`
|
|
1120
|
+
)
|
|
1075
1121
|
|
|
1076
|
-
return {
|
|
1122
|
+
return {
|
|
1123
|
+
name, // Element name
|
|
1124
|
+
sql, // Reference SQL
|
|
1125
|
+
extract, // Source SQL
|
|
1126
|
+
converter, // Converter logic
|
|
1127
|
+
// action specific full logic
|
|
1128
|
+
insert, update, upsert,
|
|
1129
|
+
// action specific isolated logic
|
|
1130
|
+
onInsert, onUpdate
|
|
1131
|
+
}
|
|
1077
1132
|
})
|
|
1078
1133
|
}
|
|
1079
1134
|
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1135
|
+
managed_extract(name, element, converter) {
|
|
1136
|
+
const { UPSERT, INSERT } = this.cqn
|
|
1137
|
+
const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)
|
|
1138
|
+
? `value->>'$[${this.columns.indexOf(name)}]'`
|
|
1139
|
+
: `value->>'$."${name.replace(/"/g, '""')}"'`
|
|
1140
|
+
const sql = converter?.(extract) || extract
|
|
1141
|
+
return { extract, sql }
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
managed_session_context(src) {
|
|
1145
|
+
const val = _managed[src]
|
|
1146
|
+
return val && { func: 'session_context', args: [{ val, param: false }] }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
managed_default(name, managed, src) {
|
|
1150
|
+
return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)`
|
|
1088
1151
|
}
|
|
1089
1152
|
}
|
|
1090
1153
|
|
package/lib/cqn4sql.js
CHANGED
|
@@ -4,7 +4,7 @@ const cds = require('@sap/cds')
|
|
|
4
4
|
|
|
5
5
|
const infer = require('./infer')
|
|
6
6
|
const { computeColumnsToBeSearched } = require('./search')
|
|
7
|
-
const { prettyPrintRef } = require('./utils')
|
|
7
|
+
const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement } = require('./utils')
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
|
|
@@ -317,10 +317,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
317
317
|
}
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
function isCalculatedOnRead(def) {
|
|
321
|
-
return def?.value && !def.value.stored
|
|
322
|
-
}
|
|
323
|
-
|
|
324
320
|
/**
|
|
325
321
|
* Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
|
|
326
322
|
*
|
|
@@ -809,7 +805,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
809
805
|
const subqueryBase = {}
|
|
810
806
|
for (const [key, value] of Object.entries(column)) {
|
|
811
807
|
if (!(key in { ref: true, expand: true })) {
|
|
812
|
-
|
|
808
|
+
subqueryBase[key] = value
|
|
813
809
|
}
|
|
814
810
|
}
|
|
815
811
|
const subquery = {
|
|
@@ -1365,20 +1361,17 @@ function cqn4sql(originalQuery, model) {
|
|
|
1365
1361
|
|
|
1366
1362
|
const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
|
|
1367
1363
|
next.alias = as
|
|
1368
|
-
if (next.definition.value) {
|
|
1369
|
-
throw new Error(
|
|
1370
|
-
`Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
|
|
1371
|
-
.map(idOnly)
|
|
1372
|
-
.join('.')}”`,
|
|
1373
|
-
)
|
|
1374
|
-
}
|
|
1375
1364
|
if (!next.definition.target) {
|
|
1365
|
+
let type = next.definition.type
|
|
1366
|
+
if (isCalculatedElement(next.definition)) {
|
|
1367
|
+
// try to infer the type at the leaf for better error message
|
|
1368
|
+
const { $refLinks } = next.definition.value
|
|
1369
|
+
type = $refLinks?.at(-1).definition.type || 'expression'
|
|
1370
|
+
}
|
|
1376
1371
|
throw new Error(
|
|
1377
1372
|
`Expecting path “${tokenStream[i + 1].ref
|
|
1378
1373
|
.map(idOnly)
|
|
1379
|
-
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${
|
|
1380
|
-
next.definition.type
|
|
1381
|
-
}”`,
|
|
1374
|
+
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
|
|
1382
1375
|
)
|
|
1383
1376
|
}
|
|
1384
1377
|
const { definition: fkSource } = next
|
package/lib/infer/index.js
CHANGED
|
@@ -4,6 +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 } = require('../utils')
|
|
7
8
|
const cdsTypes = cds.linked({
|
|
8
9
|
definitions: {
|
|
9
10
|
Timestamp: { type: 'cds.Timestamp' },
|
|
@@ -746,7 +747,7 @@ function infer(originalQuery, model) {
|
|
|
746
747
|
joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
|
|
747
748
|
}
|
|
748
749
|
}
|
|
749
|
-
if (leafArt
|
|
750
|
+
if (isCalculatedOnRead(leafArt)) {
|
|
750
751
|
linkCalculatedElement(column, $baseLink, baseColumn)
|
|
751
752
|
}
|
|
752
753
|
|
|
@@ -1054,7 +1055,7 @@ function infer(originalQuery, model) {
|
|
|
1054
1055
|
if (element.type !== 'cds.LargeBinary') {
|
|
1055
1056
|
queryElements[k] = element
|
|
1056
1057
|
}
|
|
1057
|
-
if (element
|
|
1058
|
+
if (isCalculatedOnRead(element)) {
|
|
1058
1059
|
linkCalculatedElement(element)
|
|
1059
1060
|
}
|
|
1060
1061
|
}
|
|
@@ -1071,7 +1072,7 @@ function infer(originalQuery, model) {
|
|
|
1071
1072
|
if (exclude(name) || name in queryElements) return true
|
|
1072
1073
|
const element = tableAliases[0].tableAlias.elements[name]
|
|
1073
1074
|
if (element.type !== 'cds.LargeBinary') queryElements[name] = element
|
|
1074
|
-
if (element
|
|
1075
|
+
if (isCalculatedOnRead(element)) {
|
|
1075
1076
|
linkCalculatedElement(element)
|
|
1076
1077
|
}
|
|
1077
1078
|
})
|
package/lib/utils.js
CHANGED
|
@@ -21,7 +21,26 @@ function prettyPrintRef(ref, model = null) {
|
|
|
21
21
|
}, '')
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Determines if a definition is calculated on read.
|
|
26
|
+
* - Stored calculated elements are not unfolded
|
|
27
|
+
* - Association like calculated elements have been re-written by the compiler
|
|
28
|
+
* they essentially behave like unmanaged associations as their calculations
|
|
29
|
+
* have been incorporated into an on-condition which is handled elsewhere
|
|
30
|
+
*
|
|
31
|
+
* @param {Object} def - The definition to check.
|
|
32
|
+
* @returns {boolean} - Returns true if the definition is calculated on read, otherwise false.
|
|
33
|
+
*/
|
|
34
|
+
function isCalculatedOnRead(def) {
|
|
35
|
+
return isCalculatedElement(def) && !def.value.stored && !def.on
|
|
36
|
+
}
|
|
37
|
+
function isCalculatedElement(def) {
|
|
38
|
+
return def?.value
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
// export the function to be used in other modules
|
|
25
42
|
module.exports = {
|
|
26
43
|
prettyPrintRef,
|
|
44
|
+
isCalculatedOnRead,
|
|
45
|
+
isCalculatedElement
|
|
27
46
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/db-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.1",
|
|
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": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"CHANGELOG.md"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "
|
|
25
|
+
"test": "cds-test"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"generic-pool": "^3.9.0"
|