@cap-js/db-service 1.12.0 → 1.13.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 +29 -4
- package/lib/cql-functions.js +12 -6
- package/lib/cqn2sql.js +22 -11
- package/lib/cqn4sql.js +71 -53
- package/lib/deep-queries.js +62 -51
- package/lib/infer/index.js +11 -7
- package/lib/infer/join-tree.js +9 -2
- package/lib/utils.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,19 +4,44 @@
|
|
|
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.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* Add quoted mode support ([#681](https://github.com/cap-js/cds-dbs/issues/681)) ([43c7a6c](https://github.com/cap-js/cds-dbs/commit/43c7a6c1bed836a1210eb9c2ff5c7ffc0e498d76))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
* **deep-queries:** properly return insert result ([#803](https://github.com/cap-js/cds-dbs/issues/803)) ([8d800e2](https://github.com/cap-js/cds-dbs/commit/8d800e2858f02663b8ed3845909e5891af390517))
|
|
18
|
+
* dont use virtual key for `UPDATE … where (<key>) in <subquery>` ([#800](https://github.com/cap-js/cds-dbs/issues/800)) ([d25af70](https://github.com/cap-js/cds-dbs/commit/d25af70b23688cd22c7b87f20a7848c9653ae9a9))
|
|
19
|
+
* reject all path expressions w/o foreign keys ([#806](https://github.com/cap-js/cds-dbs/issues/806)) ([cd271a8](https://github.com/cap-js/cds-dbs/commit/cd271a8aa973afe0926ac1fa956cc64260140b98))
|
|
20
|
+
|
|
21
|
+
## [1.12.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.0...db-service-v1.12.1) (2024-09-03)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
* deep `groupby` expand queries ([#768](https://github.com/cap-js/cds-dbs/issues/768)) ([5423cf3](https://github.com/cap-js/cds-dbs/commit/5423cf38574962c09b94febab95f2e3dc118d2c9))
|
|
27
|
+
* **deep:** prevent false unique constraint errors and combine delete queries ([#781](https://github.com/cap-js/cds-dbs/issues/781)) ([01de95f](https://github.com/cap-js/cds-dbs/commit/01de95f5050a1d3325459ccb78a4e9a1e0dbcfde))
|
|
28
|
+
* **logging:** from changes in @sap/cds ([#791](https://github.com/cap-js/cds-dbs/issues/791)) ([1e8bf06](https://github.com/cap-js/cds-dbs/commit/1e8bf06c9ae92ba55d13fe9e3297d6a54c4fc8fe))
|
|
29
|
+
* prepend aliases to refs within function args in on conditions ([#795](https://github.com/cap-js/cds-dbs/issues/795)) ([9b34314](https://github.com/cap-js/cds-dbs/commit/9b34314d1ef8c6fd7e77451fe9bf0abdc12c27ea)), closes [#779](https://github.com/cap-js/cds-dbs/issues/779)
|
|
30
|
+
* prevent $search queries from throwing ([#772](https://github.com/cap-js/cds-dbs/issues/772)) ([cdf4d37](https://github.com/cap-js/cds-dbs/commit/cdf4d37590c2949cdfd6c6533370bc96cd8fd0fc))
|
|
31
|
+
|
|
7
32
|
## [1.12.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.11.0...db-service-v1.12.0) (2024-07-25)
|
|
8
33
|
|
|
9
34
|
|
|
10
35
|
### Fixed
|
|
11
36
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
37
|
+
* add placeholder for string values ([#733](https://github.com/cap-js/cds-dbs/issues/733)) ([8136a45](https://github.com/cap-js/cds-dbs/commit/8136a4526f596b67932908b8ab1336cb052100f3))
|
|
38
|
+
* for aggregated `expand` always set explicit alias ([#739](https://github.com/cap-js/cds-dbs/issues/739)) ([53a8075](https://github.com/cap-js/cds-dbs/commit/53a8075a609666a896296401a28b6183ff5aa487)), closes [#708](https://github.com/cap-js/cds-dbs/issues/708)
|
|
39
|
+
* quotations in vals ([#754](https://github.com/cap-js/cds-dbs/issues/754)) ([94d8e97](https://github.com/cap-js/cds-dbs/commit/94d8e977ed00776ff494287ce505d6b7e8017d2e))
|
|
15
40
|
|
|
16
41
|
|
|
17
42
|
### Changed
|
|
18
43
|
|
|
19
|
-
|
|
44
|
+
* generic-pool as real dep ([#750](https://github.com/cap-js/cds-dbs/issues/750)) ([b50c907](https://github.com/cap-js/cds-dbs/commit/b50c907880455a41a73826a736bc17ca17e5b9ae))
|
|
20
45
|
|
|
21
46
|
|
|
22
47
|
## [1.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
|
package/lib/cql-functions.js
CHANGED
|
@@ -23,10 +23,16 @@ const StandardFunctions = {
|
|
|
23
23
|
search: function (ref, arg) {
|
|
24
24
|
if (!('val' in arg)) throw new Error(`Only single value arguments are allowed for $search`)
|
|
25
25
|
// only apply first search term, rest is ignored
|
|
26
|
-
const sub= /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
const sub = /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
|
|
27
|
+
let val
|
|
28
|
+
try {
|
|
29
|
+
val = (sub[2] ? JSON.parse(sub[2]) : sub[3]) || ''
|
|
30
|
+
} catch {
|
|
31
|
+
val = sub[2] || sub[3] || ''
|
|
32
|
+
}
|
|
33
|
+
arg.val = arg.__proto__.val = val
|
|
34
|
+
const refs = ref.list || [ref]
|
|
35
|
+
const { toString } = ref
|
|
30
36
|
return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
|
|
31
37
|
},
|
|
32
38
|
/**
|
|
@@ -159,8 +165,8 @@ const StandardFunctions = {
|
|
|
159
165
|
* Generates SQL statement that produces current point in time (date and time with time zone)
|
|
160
166
|
* @returns {string}
|
|
161
167
|
*/
|
|
162
|
-
|
|
163
|
-
return this.session_context({val: '$now'})
|
|
168
|
+
now: function () {
|
|
169
|
+
return this.session_context({ val: '$now' })
|
|
164
170
|
},
|
|
165
171
|
/**
|
|
166
172
|
* Generates SQL statement that produces the year of a given timestamp
|
package/lib/cqn2sql.js
CHANGED
|
@@ -13,14 +13,14 @@ const BINARY_TYPES = {
|
|
|
13
13
|
const { Readable } = require('stream')
|
|
14
14
|
|
|
15
15
|
const DEBUG = (() => {
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
if (DEBUG) {
|
|
20
|
-
|
|
16
|
+
const LOG = cds.log('sql-json')
|
|
17
|
+
if (LOG._debug) return cds.debug('sql-json')
|
|
18
|
+
return cds.debug('sql|sqlite')
|
|
19
|
+
//if (DEBUG) {
|
|
20
|
+
// return DEBUG
|
|
21
21
|
// (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)
|
|
22
22
|
// FIXME: looses closing ) on INSERT queries
|
|
23
|
-
}
|
|
23
|
+
//}
|
|
24
24
|
})()
|
|
25
25
|
|
|
26
26
|
class CQN2SQLRenderer {
|
|
@@ -34,6 +34,12 @@ class CQN2SQLRenderer {
|
|
|
34
34
|
this.class = new.target // for IntelliSense
|
|
35
35
|
this.class._init() // is a noop for subsequent calls
|
|
36
36
|
this.model = srv?.model
|
|
37
|
+
|
|
38
|
+
// Overwrite smart quoting
|
|
39
|
+
if (cds.env.sql.names === 'quoted') {
|
|
40
|
+
this.class.prototype.name = (name) => name.id || name
|
|
41
|
+
this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
|
|
42
|
+
}
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
static _add_mixins(aspect, mixins) {
|
|
@@ -221,7 +227,7 @@ class CQN2SQLRenderer {
|
|
|
221
227
|
let sql = `SELECT`
|
|
222
228
|
if (distinct) sql += ` DISTINCT`
|
|
223
229
|
if (!_empty(columns)) sql += ` ${columns}`
|
|
224
|
-
if (!_empty(from)) sql += ` FROM ${this.from(from)}`
|
|
230
|
+
if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
|
|
225
231
|
else sql += this.from_dummy()
|
|
226
232
|
if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
|
|
227
233
|
if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
|
|
@@ -311,8 +317,8 @@ class CQN2SQLRenderer {
|
|
|
311
317
|
*/
|
|
312
318
|
column_expr(x, q) {
|
|
313
319
|
if (x === '*') return '*'
|
|
314
|
-
|
|
315
|
-
let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
|
|
320
|
+
|
|
321
|
+
let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }) : this.expr(x)
|
|
316
322
|
let alias = this.column_alias4(x, q)
|
|
317
323
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
318
324
|
return sql
|
|
@@ -332,11 +338,16 @@ class CQN2SQLRenderer {
|
|
|
332
338
|
* @param {import('./infer/cqn').source} from
|
|
333
339
|
* @returns {string} SQL
|
|
334
340
|
*/
|
|
335
|
-
from(from) {
|
|
341
|
+
from(from, q) {
|
|
336
342
|
const { ref, as } = from
|
|
337
343
|
const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
|
|
338
344
|
if (ref) {
|
|
339
|
-
|
|
345
|
+
let z = ref[0]
|
|
346
|
+
if (cds.env.sql.names === 'quoted') {
|
|
347
|
+
// use SELECT.from to infer query, cds.infer also expects a query
|
|
348
|
+
const { target } = q || SELECT.from(from)
|
|
349
|
+
z = target?.['@cds.persistence.name'] || ref[0]
|
|
350
|
+
}
|
|
340
351
|
if (z.args) {
|
|
341
352
|
return _aliased(`${this.quote(this.name(z))}${this.from_args(z.args)}`)
|
|
342
353
|
}
|
package/lib/cqn4sql.js
CHANGED
|
@@ -4,6 +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
8
|
|
|
8
9
|
/**
|
|
9
10
|
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
|
|
@@ -130,15 +131,14 @@ function cqn4sql(originalQuery, model) {
|
|
|
130
131
|
// calculate the primary keys of the target entity, there is always exactly
|
|
131
132
|
// one query source for UPDATE / DELETE
|
|
132
133
|
const queryTarget = Object.values(inferred.sources)[0].definition
|
|
133
|
-
const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
|
|
134
134
|
const primaryKey = { list: [] }
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
135
|
+
for (const k of Object.keys(queryTarget.elements)) {
|
|
136
|
+
const e = queryTarget.elements[k]
|
|
137
|
+
if (e.key === true && !e.virtual) {
|
|
138
|
+
subquery.SELECT.columns.push({ ref: [e.name] })
|
|
139
|
+
primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
142
|
|
|
143
143
|
const transformedSubquery = cqn4sql(subquery, model)
|
|
144
144
|
|
|
@@ -530,7 +530,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
530
530
|
res = getTransformedTokenStream([value], baseLink)[0]
|
|
531
531
|
} else if (xpr) {
|
|
532
532
|
res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
|
|
533
|
-
} else if (val) {
|
|
533
|
+
} else if (val !== undefined) {
|
|
534
534
|
res = { val }
|
|
535
535
|
} else if (func) {
|
|
536
536
|
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
|
|
@@ -763,6 +763,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
763
763
|
function expandColumn(column) {
|
|
764
764
|
let outerAlias
|
|
765
765
|
let subqueryFromRef
|
|
766
|
+
const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
|
|
767
|
+
const assoc = column.$refLinks.at(-1)
|
|
768
|
+
ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
|
|
766
769
|
if (column.isJoinRelevant) {
|
|
767
770
|
// all n-1 steps of the expand column are already transformed into joins
|
|
768
771
|
// find the last join relevant association. That is the n-1 assoc in the ref path.
|
|
@@ -782,24 +785,17 @@ function cqn4sql(originalQuery, model) {
|
|
|
782
785
|
outerAlias = transformedQuery.SELECT.from.as
|
|
783
786
|
subqueryFromRef = [
|
|
784
787
|
...(transformedQuery.SELECT.from.ref || /* subq in from */ [transformedQuery.SELECT.from.target.name]),
|
|
785
|
-
...
|
|
788
|
+
...ref,
|
|
786
789
|
]
|
|
787
790
|
}
|
|
788
791
|
|
|
789
792
|
// this is the alias of the column which holds the correlated subquery
|
|
790
|
-
const columnAlias =
|
|
791
|
-
column.as ||
|
|
792
|
-
(column.$refLinks[0].definition.kind === 'entity'
|
|
793
|
-
? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
|
|
794
|
-
: column.ref.map(idOnly).join('_'))
|
|
793
|
+
const columnAlias = column.as || ref.map(idOnly).join('_')
|
|
795
794
|
|
|
796
795
|
// if there is a group by on the main query, all
|
|
797
796
|
// columns of the expand must be in the groupBy
|
|
798
797
|
if (transformedQuery.SELECT.groupBy) {
|
|
799
|
-
const baseRef =
|
|
800
|
-
column.$refLinks[0].definition.SELECT || column.$refLinks[0].definition.kind === 'entity'
|
|
801
|
-
? column.ref.slice(1)
|
|
802
|
-
: column.ref
|
|
798
|
+
const baseRef = column.$refLinks[0].definition.SELECT || ref
|
|
803
799
|
|
|
804
800
|
return _subqueryForGroupBy(column, baseRef, columnAlias)
|
|
805
801
|
}
|
|
@@ -810,10 +806,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
810
806
|
|
|
811
807
|
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
|
|
812
808
|
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
|
|
813
|
-
const subqueryBase =
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
809
|
+
const subqueryBase = {}
|
|
810
|
+
for (const [key, value] of Object.entries(column)) {
|
|
811
|
+
if (!(key in { ref: true, expand: true })) {
|
|
812
|
+
subqueryBase[key] = value;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
817
815
|
const subquery = {
|
|
818
816
|
SELECT: {
|
|
819
817
|
...subqueryBase,
|
|
@@ -884,6 +882,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
884
882
|
|
|
885
883
|
if (expand.expand) {
|
|
886
884
|
const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
|
|
885
|
+
setElementOnColumns(nested, expand.element)
|
|
887
886
|
elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
|
|
888
887
|
return nested
|
|
889
888
|
}
|
|
@@ -1066,13 +1065,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1066
1065
|
*/
|
|
1067
1066
|
function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
|
|
1068
1067
|
const wildcardColumns = []
|
|
1069
|
-
Object.keys(inferred.$combinedElements)
|
|
1070
|
-
|
|
1071
|
-
.forEach(k => {
|
|
1068
|
+
for (const k of Object.keys(inferred.$combinedElements)) {
|
|
1069
|
+
if (!exclude.includes(k)) {
|
|
1072
1070
|
const { index, tableAlias } = inferred.$combinedElements[k][0]
|
|
1073
1071
|
const element = tableAlias.elements[k]
|
|
1074
1072
|
// ignore FK for odata csn / ignore blobs from wildcard expansion
|
|
1075
|
-
if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary')
|
|
1073
|
+
if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
|
|
1076
1074
|
// for wildcard on subquery in from, just reference the elements
|
|
1077
1075
|
if (tableAlias.SELECT && !element.elements && !element.target) {
|
|
1078
1076
|
wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
|
|
@@ -1088,7 +1086,8 @@ function cqn4sql(originalQuery, model) {
|
|
|
1088
1086
|
)
|
|
1089
1087
|
wildcardColumns.push(...flatColumns)
|
|
1090
1088
|
}
|
|
1091
|
-
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1092
1091
|
return wildcardColumns
|
|
1093
1092
|
|
|
1094
1093
|
/**
|
|
@@ -1382,6 +1381,8 @@ function cqn4sql(originalQuery, model) {
|
|
|
1382
1381
|
}”`,
|
|
1383
1382
|
)
|
|
1384
1383
|
}
|
|
1384
|
+
const { definition: fkSource } = next
|
|
1385
|
+
ensureValidForeignKeys(fkSource, ref)
|
|
1385
1386
|
whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
|
|
1386
1387
|
}
|
|
1387
1388
|
|
|
@@ -1418,12 +1419,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1418
1419
|
const keys = def.keys // use key aspect on entity
|
|
1419
1420
|
const keyValComparisons = []
|
|
1420
1421
|
const flatKeys = []
|
|
1421
|
-
Object.values(keys)
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
.forEach(v => {
|
|
1422
|
+
for (const v of Object.values(keys)) {
|
|
1423
|
+
if (v !== backlinkFor($baseLink.definition)?.[0]) {
|
|
1424
|
+
// up__ID already part of inner where exists, no need to add it explicitly here
|
|
1425
1425
|
flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
|
|
1426
|
-
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1427
1428
|
if (flatKeys.length > 1)
|
|
1428
1429
|
throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
|
|
1429
1430
|
flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
|
|
@@ -1583,10 +1584,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1583
1584
|
if (!def.$refLinks) return def
|
|
1584
1585
|
const leaf = def.$refLinks[def.$refLinks.length - 1]
|
|
1585
1586
|
const first = def.$refLinks[0]
|
|
1586
|
-
const tableAlias = getTableAlias(
|
|
1587
|
-
def,
|
|
1588
|
-
def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
|
|
1589
|
-
)
|
|
1587
|
+
const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
|
|
1590
1588
|
if (leaf.definition.parent.kind !== 'entity')
|
|
1591
1589
|
// we need the base name
|
|
1592
1590
|
return getFlatColumnsFor(leaf.definition, {
|
|
@@ -1670,23 +1668,23 @@ function cqn4sql(originalQuery, model) {
|
|
|
1670
1668
|
const refReverse = [...from.ref].reverse()
|
|
1671
1669
|
const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
|
|
1672
1670
|
for (let i = 0; i < refReverse.length; i += 1) {
|
|
1673
|
-
const
|
|
1671
|
+
const current = $refLinksReverse[i]
|
|
1674
1672
|
|
|
1675
|
-
let
|
|
1673
|
+
let next = $refLinksReverse[i + 1]
|
|
1676
1674
|
const nextStep = refReverse[i + 1] // only because we want the filter condition
|
|
1677
1675
|
|
|
1678
|
-
if (
|
|
1676
|
+
if (current.definition.target && next) {
|
|
1679
1677
|
const { where, args } = nextStep
|
|
1680
|
-
if (isStructured(
|
|
1678
|
+
if (isStructured(next.definition)) {
|
|
1681
1679
|
// find next association / entity in the ref because this is actually our real nextStep
|
|
1682
1680
|
const nextStepIndex =
|
|
1683
1681
|
2 +
|
|
1684
1682
|
$refLinksReverse
|
|
1685
1683
|
.slice(i + 2)
|
|
1686
1684
|
.findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
|
|
1687
|
-
|
|
1685
|
+
next = $refLinksReverse[nextStepIndex]
|
|
1688
1686
|
}
|
|
1689
|
-
let as = getLastStringSegment(
|
|
1687
|
+
let as = getLastStringSegment(next.alias)
|
|
1690
1688
|
/**
|
|
1691
1689
|
* for an `expand` subquery, we do not need to add
|
|
1692
1690
|
* the table alias of the `expand` host to the join tree
|
|
@@ -1696,8 +1694,10 @@ function cqn4sql(originalQuery, model) {
|
|
|
1696
1694
|
if (!(inferred.SELECT?.expand === true)) {
|
|
1697
1695
|
as = getNextAvailableTableAlias(as)
|
|
1698
1696
|
}
|
|
1699
|
-
|
|
1700
|
-
|
|
1697
|
+
next.alias = as
|
|
1698
|
+
const { definition: fkSource } = current
|
|
1699
|
+
ensureValidForeignKeys(fkSource, from.ref)
|
|
1700
|
+
whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, false, args))
|
|
1701
1701
|
}
|
|
1702
1702
|
}
|
|
1703
1703
|
|
|
@@ -1742,6 +1742,14 @@ function cqn4sql(originalQuery, model) {
|
|
|
1742
1742
|
}
|
|
1743
1743
|
}
|
|
1744
1744
|
|
|
1745
|
+
function ensureValidForeignKeys(fkSource, ref, kind = null) {
|
|
1746
|
+
if (fkSource.keys && fkSource.keys.length === 0) {
|
|
1747
|
+
const path = prettyPrintRef(ref, model)
|
|
1748
|
+
if (kind === 'expand') throw new Error(`Can't expand “${fkSource.name}” as it has no foreign keys`)
|
|
1749
|
+
throw new Error(`Path step “${fkSource.name}” of “${path}” has no foreign keys`)
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1745
1753
|
function whereExistsSubqueries(whereExistsSubSelects) {
|
|
1746
1754
|
if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
|
|
1747
1755
|
whereExistsSubSelects.reduce((prev, cur) => {
|
|
@@ -1818,7 +1826,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
1818
1826
|
const { on, keys } = assocRefLink.definition
|
|
1819
1827
|
const target = getDefinition(assocRefLink.definition.target)
|
|
1820
1828
|
let res
|
|
1821
|
-
// technically we could have multiple backlinks
|
|
1822
1829
|
if (keys) {
|
|
1823
1830
|
const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
|
|
1824
1831
|
const transformedOn = []
|
|
@@ -1852,6 +1859,11 @@ function cqn4sql(originalQuery, model) {
|
|
|
1852
1859
|
result[i] = asXpr(xpr)
|
|
1853
1860
|
continue
|
|
1854
1861
|
}
|
|
1862
|
+
if (lhs.args) {
|
|
1863
|
+
const args = calculateOnCondition(lhs.args)
|
|
1864
|
+
result[i] = { ...lhs, args }
|
|
1865
|
+
continue
|
|
1866
|
+
}
|
|
1855
1867
|
const rhs = result[i + 2]
|
|
1856
1868
|
if (rhs?.ref || lhs.ref) {
|
|
1857
1869
|
// if we have refs on each side of the comparison, we might need to perform tuple expansion
|
|
@@ -1946,6 +1958,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1946
1958
|
}
|
|
1947
1959
|
})
|
|
1948
1960
|
} else if (backlink.keys) {
|
|
1961
|
+
// sanity check: error out if we can't produce a join
|
|
1962
|
+
if (backlink.keys.length === 0) {
|
|
1963
|
+
throw new Error(
|
|
1964
|
+
`Path step “${assocRefLink.alias}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
|
|
1965
|
+
)
|
|
1966
|
+
}
|
|
1949
1967
|
// managed backlink -> calculate fk-pk pairs
|
|
1950
1968
|
const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
|
|
1951
1969
|
fkPkPairs.forEach((pair, j) => {
|
|
@@ -2264,13 +2282,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
2264
2282
|
}
|
|
2265
2283
|
}
|
|
2266
2284
|
|
|
2267
|
-
module.exports = Object.assign(cqn4sql, {
|
|
2268
|
-
// for own tests only:
|
|
2269
|
-
eqOps,
|
|
2270
|
-
notEqOps,
|
|
2271
|
-
notSupportedOps,
|
|
2272
|
-
})
|
|
2273
|
-
|
|
2274
2285
|
function calculateElementName(token) {
|
|
2275
2286
|
const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
|
|
2276
2287
|
let name
|
|
@@ -2358,3 +2369,10 @@ const refWithConditions = step => {
|
|
|
2358
2369
|
return appendix ? step.id + appendix : step
|
|
2359
2370
|
}
|
|
2360
2371
|
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
|
|
2372
|
+
|
|
2373
|
+
module.exports = Object.assign(cqn4sql, {
|
|
2374
|
+
// for own tests only:
|
|
2375
|
+
eqOps,
|
|
2376
|
+
notEqOps,
|
|
2377
|
+
notSupportedOps,
|
|
2378
|
+
})
|
package/lib/deep-queries.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
2
|
const { _target_name4 } = require('./SQLService')
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
const ROOT = Symbol('root')
|
|
4
5
|
|
|
5
6
|
// REVISIT: remove old path with cds^8
|
|
6
7
|
let _compareJson
|
|
@@ -45,20 +46,22 @@ async function onDeep(req, next) {
|
|
|
45
46
|
if (query.UPDATE && !beforeData.length) return 0
|
|
46
47
|
|
|
47
48
|
const queries = getDeepQueries(query, beforeData, target)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
49
|
+
|
|
50
|
+
// first delete, then update, then insert because of potential unique constraints:
|
|
51
|
+
// - deletes never trigger unique constraints, but can prevent them -> execute first
|
|
52
|
+
// - updates can trigger and prevent unique constraints -> execute second
|
|
53
|
+
// - inserts can only trigger unique constraints -> execute last
|
|
54
|
+
await Promise.all(Array.from(queries.deletes.values()).map(query => this.onSIMPLE({ query })))
|
|
55
|
+
await Promise.all(queries.updates.map(query => this.onUPDATE({ query })))
|
|
56
|
+
|
|
57
|
+
const rootQuery = queries.inserts.get(ROOT)
|
|
58
|
+
queries.inserts.delete(ROOT)
|
|
59
|
+
const [rootResult] = await Promise.all([
|
|
60
|
+
rootQuery && this.onINSERT({ query: rootQuery }),
|
|
61
|
+
...Array.from(queries.inserts.values()).map(query => this.onINSERT({ query })),
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
return rootResult ?? beforeData.length
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
const hasDeep = (q, target) => {
|
|
@@ -195,7 +198,7 @@ const getDeepQueries = (query, dbData, target) => {
|
|
|
195
198
|
diff = [diff]
|
|
196
199
|
}
|
|
197
200
|
|
|
198
|
-
return _getDeepQueries(diff, target
|
|
201
|
+
return _getDeepQueries(diff, target)
|
|
199
202
|
}
|
|
200
203
|
|
|
201
204
|
const _hasManagedElements = target => {
|
|
@@ -205,16 +208,19 @@ const _hasManagedElements = target => {
|
|
|
205
208
|
/**
|
|
206
209
|
* @param {unknown[]} diff
|
|
207
210
|
* @param {import('@sap/cds/apis/csn').Definition} target
|
|
208
|
-
* @param {
|
|
209
|
-
* @
|
|
211
|
+
* @param {Map<String, Object>} deletes
|
|
212
|
+
* @param {Map<String, Object>} inserts
|
|
213
|
+
* @param {Object[]} updates
|
|
214
|
+
* @param {boolean} [root=true]
|
|
215
|
+
* @returns {Object|Boolean}
|
|
210
216
|
*/
|
|
211
|
-
const _getDeepQueries = (diff, target, root =
|
|
212
|
-
|
|
213
|
-
|
|
217
|
+
const _getDeepQueries = (diff, target, deletes = new Map(), inserts = new Map(), updates = [], root = true) => {
|
|
218
|
+
// flag to determine if queries were created
|
|
219
|
+
let dirty = false
|
|
214
220
|
for (const diffEntry of diff) {
|
|
215
221
|
if (diffEntry === undefined) continue
|
|
216
|
-
const subQueries = []
|
|
217
222
|
|
|
223
|
+
let childrenDirty = false
|
|
218
224
|
for (const prop in diffEntry) {
|
|
219
225
|
// handle deep operations
|
|
220
226
|
|
|
@@ -224,9 +230,12 @@ const _getDeepQueries = (diff, target, root = false) => {
|
|
|
224
230
|
delete diffEntry[prop]
|
|
225
231
|
} else if (target.compositions?.[prop]) {
|
|
226
232
|
const arrayed = Array.isArray(propData) ? propData : [propData]
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
233
|
+
childrenDirty =
|
|
234
|
+
arrayed
|
|
235
|
+
.map(subEntry =>
|
|
236
|
+
_getDeepQueries([subEntry], target.elements[prop]._target, deletes, inserts, updates, false),
|
|
237
|
+
)
|
|
238
|
+
.some(a => a) || childrenDirty
|
|
230
239
|
delete diffEntry[prop]
|
|
231
240
|
} else if (diffEntry[prop] === undefined) {
|
|
232
241
|
// restore current behavior, if property is undefined, not part of payload
|
|
@@ -242,12 +251,32 @@ const _getDeepQueries = (diff, target, root = false) => {
|
|
|
242
251
|
delete diffEntry._old
|
|
243
252
|
}
|
|
244
253
|
|
|
245
|
-
// first calculate subqueries and rm their properties, then build root query
|
|
246
254
|
if (op === 'create') {
|
|
247
|
-
|
|
255
|
+
dirty = true
|
|
256
|
+
const id = root ? ROOT : target.name
|
|
257
|
+
const insert = inserts.get(id)
|
|
258
|
+
if (insert) {
|
|
259
|
+
insert.INSERT.entries.push(diffEntry)
|
|
260
|
+
} else {
|
|
261
|
+
const q = INSERT.into(target).entries(diffEntry)
|
|
262
|
+
inserts.set(id, q)
|
|
263
|
+
}
|
|
248
264
|
} else if (op === 'delete') {
|
|
249
|
-
|
|
250
|
-
|
|
265
|
+
dirty = true
|
|
266
|
+
const keys = cds.utils
|
|
267
|
+
.Object_keys(target.keys)
|
|
268
|
+
.filter(key => !target.keys[key].virtual && !target.keys[key].isAssociation)
|
|
269
|
+
|
|
270
|
+
const keyVals = keys.map(k => ({ val: diffEntry[k] }))
|
|
271
|
+
const currDelete = deletes.get(target.name)
|
|
272
|
+
if (currDelete) currDelete.DELETE.where[2].list.push({ list: keyVals })
|
|
273
|
+
else {
|
|
274
|
+
const left = { list: keys.map(k => ({ ref: [k] })) }
|
|
275
|
+
const right = { list: [{ list: keyVals }] }
|
|
276
|
+
deletes.set(target.name, DELETE.from(target).where([left, 'in', right]))
|
|
277
|
+
}
|
|
278
|
+
} else if (op === 'update' || (op === undefined && (root || childrenDirty) && _hasManagedElements(target))) {
|
|
279
|
+
dirty = true
|
|
251
280
|
// TODO do we need the where here?
|
|
252
281
|
const keys = target.keys
|
|
253
282
|
const cqn = UPDATE(target).with(diffEntry)
|
|
@@ -259,34 +288,16 @@ const _getDeepQueries = (diff, target, root = false) => {
|
|
|
259
288
|
delete diffEntry[key]
|
|
260
289
|
}
|
|
261
290
|
cqn.with(diffEntry)
|
|
262
|
-
|
|
291
|
+
updates.push(cqn)
|
|
263
292
|
}
|
|
264
|
-
|
|
265
|
-
for (const q of subQueries) queries.push(q)
|
|
266
293
|
}
|
|
267
294
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
return queries.map(q => {
|
|
271
|
-
// Merge all INSERT statements for each target
|
|
272
|
-
if (q.INSERT) {
|
|
273
|
-
const target = q.target
|
|
274
|
-
if (insertQueries.has(target)) {
|
|
275
|
-
insertQueries.get(target).INSERT.entries.push(...q.INSERT.entries)
|
|
276
|
-
return
|
|
277
|
-
} else {
|
|
278
|
-
insertQueries.set(target, q)
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
Object.defineProperty(q, handledDeep, { value: true })
|
|
282
|
-
return q
|
|
283
|
-
})
|
|
284
|
-
.filter(a => a)
|
|
295
|
+
return root ? { updates, inserts, deletes } : dirty
|
|
285
296
|
}
|
|
286
297
|
|
|
287
298
|
module.exports = {
|
|
288
299
|
onDeep,
|
|
289
|
-
getDeepQueries,
|
|
290
|
-
getExpandForDeep,
|
|
291
300
|
hasDeep,
|
|
301
|
+
getDeepQueries, // only for testing
|
|
302
|
+
getExpandForDeep, // only for testing
|
|
292
303
|
}
|
package/lib/infer/index.js
CHANGED
|
@@ -170,7 +170,8 @@ function infer(originalQuery, model) {
|
|
|
170
170
|
if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
|
|
171
171
|
if (!ref) return
|
|
172
172
|
init$refLinks(arg)
|
|
173
|
-
|
|
173
|
+
let i = 0
|
|
174
|
+
for (const step of ref) {
|
|
174
175
|
const id = step.id || step
|
|
175
176
|
if (i === 0) {
|
|
176
177
|
// infix filter never have table alias
|
|
@@ -231,7 +232,8 @@ function infer(originalQuery, model) {
|
|
|
231
232
|
step.where.forEach(walkTokenStream)
|
|
232
233
|
} else throw new Error('A filter can only be provided when navigating along associations')
|
|
233
234
|
}
|
|
234
|
-
|
|
235
|
+
i += 1
|
|
236
|
+
}
|
|
235
237
|
const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
|
|
236
238
|
if (definition.value) {
|
|
237
239
|
// nested calculated element
|
|
@@ -1046,15 +1048,17 @@ function infer(originalQuery, model) {
|
|
|
1046
1048
|
if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
|
|
1047
1049
|
const { elements } = getDefinitionFromSources(sources, aliases[0])
|
|
1048
1050
|
// only one query source and no overwritten columns
|
|
1049
|
-
Object.keys(elements)
|
|
1050
|
-
|
|
1051
|
-
.forEach(k => {
|
|
1051
|
+
for (const k of Object.keys(elements)) {
|
|
1052
|
+
if (!exclude(k)) {
|
|
1052
1053
|
const element = elements[k]
|
|
1053
|
-
if (element.type !== 'cds.LargeBinary')
|
|
1054
|
+
if (element.type !== 'cds.LargeBinary') {
|
|
1055
|
+
queryElements[k] = element
|
|
1056
|
+
}
|
|
1054
1057
|
if (element.value) {
|
|
1055
1058
|
linkCalculatedElement(element)
|
|
1056
1059
|
}
|
|
1057
|
-
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1058
1062
|
return
|
|
1059
1063
|
}
|
|
1060
1064
|
|
package/lib/infer/join-tree.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { prettyPrintRef } = require('../utils')
|
|
4
|
+
|
|
3
5
|
// REVISIT: define following unknown types
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -177,13 +179,19 @@ class JoinTree {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
// if no root node was found, the column is selected from a subquery
|
|
180
|
-
if(!node) return
|
|
182
|
+
if (!node) return
|
|
181
183
|
while (i < col.ref.length) {
|
|
182
184
|
const step = col.ref[i]
|
|
183
185
|
const { where, args } = step
|
|
184
186
|
const id = joinId(step, args, where)
|
|
185
187
|
const next = node.children.get(id)
|
|
186
188
|
const $refLink = col.$refLinks[i]
|
|
189
|
+
// sanity check: error out if we can't produce a join
|
|
190
|
+
if ($refLink.definition.keys && $refLink.definition.keys.length === 0) {
|
|
191
|
+
const path = prettyPrintRef(col.ref)
|
|
192
|
+
throw new Error(`Path step “${$refLink.alias}” of “${path}” has no foreign keys`)
|
|
193
|
+
}
|
|
194
|
+
|
|
187
195
|
if (next) {
|
|
188
196
|
// step already seen before
|
|
189
197
|
node = next
|
|
@@ -208,7 +216,6 @@ class JoinTree {
|
|
|
208
216
|
}
|
|
209
217
|
child.$refLink.alias = this.addNextAvailableTableAlias($refLink.alias, outerQueries)
|
|
210
218
|
}
|
|
211
|
-
//> REVISIT: remove fallback once UCSN is standard
|
|
212
219
|
const elements =
|
|
213
220
|
node.$refLink?.definition.isAssociation &&
|
|
214
221
|
(node.$refLink.definition.elements || node.$refLink.definition.foreignKeys)
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats a ref array into a string representation.
|
|
5
|
+
* If the first step is an entity, the separator is a colon, otherwise a dot.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array} ref - The reference array to be formatted.
|
|
8
|
+
* @param {Object} model - The model object containing definitions.
|
|
9
|
+
* @returns {string} The formatted string representation of the reference.
|
|
10
|
+
*/
|
|
11
|
+
function prettyPrintRef(ref, model = null) {
|
|
12
|
+
return ref.reduce((acc, curr, j) => {
|
|
13
|
+
if (j > 0) {
|
|
14
|
+
if (j === 1 && model?.definitions[ref[0]]?.kind === 'entity') {
|
|
15
|
+
acc += ':'
|
|
16
|
+
} else {
|
|
17
|
+
acc += '.'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return acc + `${curr.id ? curr.id + '[…]' : curr}`
|
|
21
|
+
}, '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// export the function to be used in other modules
|
|
25
|
+
module.exports = {
|
|
26
|
+
prettyPrintRef,
|
|
27
|
+
}
|
package/package.json
CHANGED