@cap-js/db-service 1.12.1 → 1.14.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 +27 -0
- package/lib/SQLService.js +4 -0
- package/lib/cql-functions.js +3 -1
- package/lib/cqn2sql.js +30 -12
- package/lib/cqn4sql.js +72 -67
- package/lib/deep-queries.js +1 -1
- package/lib/infer/index.js +15 -10
- package/lib/infer/join-tree.js +9 -2
- package/lib/utils.js +46 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@
|
|
|
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.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.13.0...db-service-v1.14.0) (2024-10-15)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* 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))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
* 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))
|
|
18
|
+
* 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))
|
|
19
|
+
|
|
20
|
+
## [1.13.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.1...db-service-v1.13.0) (2024-10-01)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
* Add quoted mode support ([#681](https://github.com/cap-js/cds-dbs/issues/681)) ([43c7a6c](https://github.com/cap-js/cds-dbs/commit/43c7a6c1bed836a1210eb9c2ff5c7ffc0e498d76))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
* **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))
|
|
31
|
+
* 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))
|
|
32
|
+
* 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))
|
|
33
|
+
|
|
7
34
|
## [1.12.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.12.0...db-service-v1.12.1) (2024-09-03)
|
|
8
35
|
|
|
9
36
|
|
package/lib/SQLService.js
CHANGED
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
|
@@ -18,8 +18,8 @@ const DEBUG = (() => {
|
|
|
18
18
|
return cds.debug('sql|sqlite')
|
|
19
19
|
//if (DEBUG) {
|
|
20
20
|
// return DEBUG
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
// FIXME: looses closing ) on INSERT queries
|
|
23
23
|
//}
|
|
24
24
|
})()
|
|
25
25
|
|
|
@@ -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) {
|
|
@@ -82,6 +88,7 @@ class CQN2SQLRenderer {
|
|
|
82
88
|
this.values = [] // prepare values, filled in by subroutines
|
|
83
89
|
this[kind]((this.cqn = q)) // actual sql rendering happens here
|
|
84
90
|
if (vars?.length && !this.values?.length) this.values = vars
|
|
91
|
+
if (vars && Object.keys(vars).length && !this.values?.length) this.values = vars
|
|
85
92
|
const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
|
|
86
93
|
DEBUG?.(
|
|
87
94
|
this.sql,
|
|
@@ -110,8 +117,13 @@ class CQN2SQLRenderer {
|
|
|
110
117
|
* @param {import('./infer/cqn').CREATE} q
|
|
111
118
|
*/
|
|
112
119
|
CREATE(q) {
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
|
|
115
127
|
const name = this.name(target.name)
|
|
116
128
|
// Don't allow place holders inside views
|
|
117
129
|
delete this.values
|
|
@@ -197,8 +209,9 @@ class CQN2SQLRenderer {
|
|
|
197
209
|
*/
|
|
198
210
|
DROP(q) {
|
|
199
211
|
const { target } = q
|
|
200
|
-
const isView = target
|
|
201
|
-
|
|
212
|
+
const isView = target?.query || target?.projection || q.DROP.view
|
|
213
|
+
const name = target?.name || q.DROP.table?.ref?.[0] || q.DROP.view?.ref?.[0]
|
|
214
|
+
return (this.sql = `DROP ${isView ? 'VIEW' : 'TABLE'} IF EXISTS ${this.quote(this.name(name))}`)
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
// SELECT Statements ------------------------------------------------
|
|
@@ -217,11 +230,11 @@ class CQN2SQLRenderer {
|
|
|
217
230
|
|
|
218
231
|
// REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
|
|
219
232
|
if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
|
|
220
|
-
|
|
233
|
+
const columns = this.SELECT_columns(q)
|
|
221
234
|
let sql = `SELECT`
|
|
222
235
|
if (distinct) sql += ` DISTINCT`
|
|
223
236
|
if (!_empty(columns)) sql += ` ${columns}`
|
|
224
|
-
if (!_empty(from)) sql += ` FROM ${this.from(from)}`
|
|
237
|
+
if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
|
|
225
238
|
else sql += this.from_dummy()
|
|
226
239
|
if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
|
|
227
240
|
if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
|
|
@@ -311,8 +324,8 @@ class CQN2SQLRenderer {
|
|
|
311
324
|
*/
|
|
312
325
|
column_expr(x, q) {
|
|
313
326
|
if (x === '*') return '*'
|
|
314
|
-
|
|
315
|
-
let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
|
|
327
|
+
|
|
328
|
+
let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }) : this.expr(x)
|
|
316
329
|
let alias = this.column_alias4(x, q)
|
|
317
330
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
318
331
|
return sql
|
|
@@ -332,11 +345,16 @@ class CQN2SQLRenderer {
|
|
|
332
345
|
* @param {import('./infer/cqn').source} from
|
|
333
346
|
* @returns {string} SQL
|
|
334
347
|
*/
|
|
335
|
-
from(from) {
|
|
348
|
+
from(from, q) {
|
|
336
349
|
const { ref, as } = from
|
|
337
350
|
const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
|
|
338
351
|
if (ref) {
|
|
339
|
-
|
|
352
|
+
let z = ref[0]
|
|
353
|
+
if (cds.env.sql.names === 'quoted') {
|
|
354
|
+
// use SELECT.from to infer query, cds.infer also expects a query
|
|
355
|
+
const { target } = q || SELECT.from(from)
|
|
356
|
+
z = target?.['@cds.persistence.name'] || ref[0]
|
|
357
|
+
}
|
|
340
358
|
if (z.args) {
|
|
341
359
|
return _aliased(`${this.quote(this.name(z))}${this.from_args(z.args)}`)
|
|
342
360
|
}
|
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, isCalculatedOnRead, isCalculatedElement } = 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
|
|
|
@@ -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
|
*
|
|
@@ -763,6 +759,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
763
759
|
function expandColumn(column) {
|
|
764
760
|
let outerAlias
|
|
765
761
|
let subqueryFromRef
|
|
762
|
+
const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
|
|
763
|
+
const assoc = column.$refLinks.at(-1)
|
|
764
|
+
ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
|
|
766
765
|
if (column.isJoinRelevant) {
|
|
767
766
|
// all n-1 steps of the expand column are already transformed into joins
|
|
768
767
|
// find the last join relevant association. That is the n-1 assoc in the ref path.
|
|
@@ -782,24 +781,17 @@ function cqn4sql(originalQuery, model) {
|
|
|
782
781
|
outerAlias = transformedQuery.SELECT.from.as
|
|
783
782
|
subqueryFromRef = [
|
|
784
783
|
...(transformedQuery.SELECT.from.ref || /* subq in from */ [transformedQuery.SELECT.from.target.name]),
|
|
785
|
-
...
|
|
784
|
+
...ref,
|
|
786
785
|
]
|
|
787
786
|
}
|
|
788
787
|
|
|
789
788
|
// 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('_'))
|
|
789
|
+
const columnAlias = column.as || ref.map(idOnly).join('_')
|
|
795
790
|
|
|
796
791
|
// if there is a group by on the main query, all
|
|
797
792
|
// columns of the expand must be in the groupBy
|
|
798
793
|
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
|
|
794
|
+
const baseRef = column.$refLinks[0].definition.SELECT || ref
|
|
803
795
|
|
|
804
796
|
return _subqueryForGroupBy(column, baseRef, columnAlias)
|
|
805
797
|
}
|
|
@@ -810,10 +802,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
810
802
|
|
|
811
803
|
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
|
|
812
804
|
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
|
|
813
|
-
const subqueryBase =
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
805
|
+
const subqueryBase = {}
|
|
806
|
+
for (const [key, value] of Object.entries(column)) {
|
|
807
|
+
if (!(key in { ref: true, expand: true })) {
|
|
808
|
+
subqueryBase[key] = value
|
|
809
|
+
}
|
|
810
|
+
}
|
|
817
811
|
const subquery = {
|
|
818
812
|
SELECT: {
|
|
819
813
|
...subqueryBase,
|
|
@@ -1067,13 +1061,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1067
1061
|
*/
|
|
1068
1062
|
function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
|
|
1069
1063
|
const wildcardColumns = []
|
|
1070
|
-
Object.keys(inferred.$combinedElements)
|
|
1071
|
-
|
|
1072
|
-
.forEach(k => {
|
|
1064
|
+
for (const k of Object.keys(inferred.$combinedElements)) {
|
|
1065
|
+
if (!exclude.includes(k)) {
|
|
1073
1066
|
const { index, tableAlias } = inferred.$combinedElements[k][0]
|
|
1074
1067
|
const element = tableAlias.elements[k]
|
|
1075
1068
|
// ignore FK for odata csn / ignore blobs from wildcard expansion
|
|
1076
|
-
if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary')
|
|
1069
|
+
if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
|
|
1077
1070
|
// for wildcard on subquery in from, just reference the elements
|
|
1078
1071
|
if (tableAlias.SELECT && !element.elements && !element.target) {
|
|
1079
1072
|
wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
|
|
@@ -1089,7 +1082,8 @@ function cqn4sql(originalQuery, model) {
|
|
|
1089
1082
|
)
|
|
1090
1083
|
wildcardColumns.push(...flatColumns)
|
|
1091
1084
|
}
|
|
1092
|
-
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1093
1087
|
return wildcardColumns
|
|
1094
1088
|
|
|
1095
1089
|
/**
|
|
@@ -1367,22 +1361,21 @@ function cqn4sql(originalQuery, model) {
|
|
|
1367
1361
|
|
|
1368
1362
|
const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
|
|
1369
1363
|
next.alias = as
|
|
1370
|
-
if (next.definition.value) {
|
|
1371
|
-
throw new Error(
|
|
1372
|
-
`Calculated elements cannot be used in “exists” predicates in: “exists ${tokenStream[i + 1].ref
|
|
1373
|
-
.map(idOnly)
|
|
1374
|
-
.join('.')}”`,
|
|
1375
|
-
)
|
|
1376
|
-
}
|
|
1377
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
|
+
}
|
|
1378
1371
|
throw new Error(
|
|
1379
1372
|
`Expecting path “${tokenStream[i + 1].ref
|
|
1380
1373
|
.map(idOnly)
|
|
1381
|
-
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${
|
|
1382
|
-
next.definition.type
|
|
1383
|
-
}”`,
|
|
1374
|
+
.join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
|
|
1384
1375
|
)
|
|
1385
1376
|
}
|
|
1377
|
+
const { definition: fkSource } = next
|
|
1378
|
+
ensureValidForeignKeys(fkSource, ref)
|
|
1386
1379
|
whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true, step.args))
|
|
1387
1380
|
}
|
|
1388
1381
|
|
|
@@ -1419,12 +1412,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1419
1412
|
const keys = def.keys // use key aspect on entity
|
|
1420
1413
|
const keyValComparisons = []
|
|
1421
1414
|
const flatKeys = []
|
|
1422
|
-
Object.values(keys)
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
.forEach(v => {
|
|
1415
|
+
for (const v of Object.values(keys)) {
|
|
1416
|
+
if (v !== backlinkFor($baseLink.definition)?.[0]) {
|
|
1417
|
+
// up__ID already part of inner where exists, no need to add it explicitly here
|
|
1426
1418
|
flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
|
|
1427
|
-
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1428
1421
|
if (flatKeys.length > 1)
|
|
1429
1422
|
throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
|
|
1430
1423
|
flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
|
|
@@ -1584,10 +1577,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1584
1577
|
if (!def.$refLinks) return def
|
|
1585
1578
|
const leaf = def.$refLinks[def.$refLinks.length - 1]
|
|
1586
1579
|
const first = def.$refLinks[0]
|
|
1587
|
-
const tableAlias = getTableAlias(
|
|
1588
|
-
def,
|
|
1589
|
-
def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
|
|
1590
|
-
)
|
|
1580
|
+
const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
|
|
1591
1581
|
if (leaf.definition.parent.kind !== 'entity')
|
|
1592
1582
|
// we need the base name
|
|
1593
1583
|
return getFlatColumnsFor(leaf.definition, {
|
|
@@ -1671,23 +1661,23 @@ function cqn4sql(originalQuery, model) {
|
|
|
1671
1661
|
const refReverse = [...from.ref].reverse()
|
|
1672
1662
|
const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
|
|
1673
1663
|
for (let i = 0; i < refReverse.length; i += 1) {
|
|
1674
|
-
const
|
|
1664
|
+
const current = $refLinksReverse[i]
|
|
1675
1665
|
|
|
1676
|
-
let
|
|
1666
|
+
let next = $refLinksReverse[i + 1]
|
|
1677
1667
|
const nextStep = refReverse[i + 1] // only because we want the filter condition
|
|
1678
1668
|
|
|
1679
|
-
if (
|
|
1669
|
+
if (current.definition.target && next) {
|
|
1680
1670
|
const { where, args } = nextStep
|
|
1681
|
-
if (isStructured(
|
|
1671
|
+
if (isStructured(next.definition)) {
|
|
1682
1672
|
// find next association / entity in the ref because this is actually our real nextStep
|
|
1683
1673
|
const nextStepIndex =
|
|
1684
1674
|
2 +
|
|
1685
1675
|
$refLinksReverse
|
|
1686
1676
|
.slice(i + 2)
|
|
1687
1677
|
.findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
|
|
1688
|
-
|
|
1678
|
+
next = $refLinksReverse[nextStepIndex]
|
|
1689
1679
|
}
|
|
1690
|
-
let as = getLastStringSegment(
|
|
1680
|
+
let as = getLastStringSegment(next.alias)
|
|
1691
1681
|
/**
|
|
1692
1682
|
* for an `expand` subquery, we do not need to add
|
|
1693
1683
|
* the table alias of the `expand` host to the join tree
|
|
@@ -1697,8 +1687,10 @@ function cqn4sql(originalQuery, model) {
|
|
|
1697
1687
|
if (!(inferred.SELECT?.expand === true)) {
|
|
1698
1688
|
as = getNextAvailableTableAlias(as)
|
|
1699
1689
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1690
|
+
next.alias = as
|
|
1691
|
+
const { definition: fkSource } = current
|
|
1692
|
+
ensureValidForeignKeys(fkSource, from.ref)
|
|
1693
|
+
whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, false, args))
|
|
1702
1694
|
}
|
|
1703
1695
|
}
|
|
1704
1696
|
|
|
@@ -1743,6 +1735,14 @@ function cqn4sql(originalQuery, model) {
|
|
|
1743
1735
|
}
|
|
1744
1736
|
}
|
|
1745
1737
|
|
|
1738
|
+
function ensureValidForeignKeys(fkSource, ref, kind = null) {
|
|
1739
|
+
if (fkSource.keys && fkSource.keys.length === 0) {
|
|
1740
|
+
const path = prettyPrintRef(ref, model)
|
|
1741
|
+
if (kind === 'expand') throw new Error(`Can't expand “${fkSource.name}” as it has no foreign keys`)
|
|
1742
|
+
throw new Error(`Path step “${fkSource.name}” of “${path}” has no foreign keys`)
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
1746
|
function whereExistsSubqueries(whereExistsSubSelects) {
|
|
1747
1747
|
if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
|
|
1748
1748
|
whereExistsSubSelects.reduce((prev, cur) => {
|
|
@@ -1819,7 +1819,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
1819
1819
|
const { on, keys } = assocRefLink.definition
|
|
1820
1820
|
const target = getDefinition(assocRefLink.definition.target)
|
|
1821
1821
|
let res
|
|
1822
|
-
// technically we could have multiple backlinks
|
|
1823
1822
|
if (keys) {
|
|
1824
1823
|
const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
|
|
1825
1824
|
const transformedOn = []
|
|
@@ -1853,7 +1852,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1853
1852
|
result[i] = asXpr(xpr)
|
|
1854
1853
|
continue
|
|
1855
1854
|
}
|
|
1856
|
-
if(lhs.args) {
|
|
1855
|
+
if (lhs.args) {
|
|
1857
1856
|
const args = calculateOnCondition(lhs.args)
|
|
1858
1857
|
result[i] = { ...lhs, args }
|
|
1859
1858
|
continue
|
|
@@ -1952,6 +1951,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
1952
1951
|
}
|
|
1953
1952
|
})
|
|
1954
1953
|
} else if (backlink.keys) {
|
|
1954
|
+
// sanity check: error out if we can't produce a join
|
|
1955
|
+
if (backlink.keys.length === 0) {
|
|
1956
|
+
throw new Error(
|
|
1957
|
+
`Path step “${assocRefLink.alias}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
|
|
1958
|
+
)
|
|
1959
|
+
}
|
|
1955
1960
|
// managed backlink -> calculate fk-pk pairs
|
|
1956
1961
|
const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
|
|
1957
1962
|
fkPkPairs.forEach((pair, j) => {
|
|
@@ -2270,13 +2275,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
2270
2275
|
}
|
|
2271
2276
|
}
|
|
2272
2277
|
|
|
2273
|
-
module.exports = Object.assign(cqn4sql, {
|
|
2274
|
-
// for own tests only:
|
|
2275
|
-
eqOps,
|
|
2276
|
-
notEqOps,
|
|
2277
|
-
notSupportedOps,
|
|
2278
|
-
})
|
|
2279
|
-
|
|
2280
2278
|
function calculateElementName(token) {
|
|
2281
2279
|
const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
|
|
2282
2280
|
let name
|
|
@@ -2364,3 +2362,10 @@ const refWithConditions = step => {
|
|
|
2364
2362
|
return appendix ? step.id + appendix : step
|
|
2365
2363
|
}
|
|
2366
2364
|
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
|
|
2365
|
+
|
|
2366
|
+
module.exports = Object.assign(cqn4sql, {
|
|
2367
|
+
// for own tests only:
|
|
2368
|
+
eqOps,
|
|
2369
|
+
notEqOps,
|
|
2370
|
+
notSupportedOps,
|
|
2371
|
+
})
|
package/lib/deep-queries.js
CHANGED
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' },
|
|
@@ -170,7 +171,8 @@ function infer(originalQuery, model) {
|
|
|
170
171
|
if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
|
|
171
172
|
if (!ref) return
|
|
172
173
|
init$refLinks(arg)
|
|
173
|
-
|
|
174
|
+
let i = 0
|
|
175
|
+
for (const step of ref) {
|
|
174
176
|
const id = step.id || step
|
|
175
177
|
if (i === 0) {
|
|
176
178
|
// infix filter never have table alias
|
|
@@ -231,7 +233,8 @@ function infer(originalQuery, model) {
|
|
|
231
233
|
step.where.forEach(walkTokenStream)
|
|
232
234
|
} else throw new Error('A filter can only be provided when navigating along associations')
|
|
233
235
|
}
|
|
234
|
-
|
|
236
|
+
i += 1
|
|
237
|
+
}
|
|
235
238
|
const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
|
|
236
239
|
if (definition.value) {
|
|
237
240
|
// nested calculated element
|
|
@@ -744,7 +747,7 @@ function infer(originalQuery, model) {
|
|
|
744
747
|
joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
|
|
745
748
|
}
|
|
746
749
|
}
|
|
747
|
-
if (leafArt
|
|
750
|
+
if (isCalculatedOnRead(leafArt)) {
|
|
748
751
|
linkCalculatedElement(column, $baseLink, baseColumn)
|
|
749
752
|
}
|
|
750
753
|
|
|
@@ -1046,15 +1049,17 @@ function infer(originalQuery, model) {
|
|
|
1046
1049
|
if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
|
|
1047
1050
|
const { elements } = getDefinitionFromSources(sources, aliases[0])
|
|
1048
1051
|
// only one query source and no overwritten columns
|
|
1049
|
-
Object.keys(elements)
|
|
1050
|
-
|
|
1051
|
-
.forEach(k => {
|
|
1052
|
+
for (const k of Object.keys(elements)) {
|
|
1053
|
+
if (!exclude(k)) {
|
|
1052
1054
|
const element = elements[k]
|
|
1053
|
-
if (element.type !== 'cds.LargeBinary')
|
|
1054
|
-
|
|
1055
|
+
if (element.type !== 'cds.LargeBinary') {
|
|
1056
|
+
queryElements[k] = element
|
|
1057
|
+
}
|
|
1058
|
+
if (isCalculatedOnRead(element)) {
|
|
1055
1059
|
linkCalculatedElement(element)
|
|
1056
1060
|
}
|
|
1057
|
-
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1058
1063
|
return
|
|
1059
1064
|
}
|
|
1060
1065
|
|
|
@@ -1067,7 +1072,7 @@ function infer(originalQuery, model) {
|
|
|
1067
1072
|
if (exclude(name) || name in queryElements) return true
|
|
1068
1073
|
const element = tableAliases[0].tableAlias.elements[name]
|
|
1069
1074
|
if (element.type !== 'cds.LargeBinary') queryElements[name] = element
|
|
1070
|
-
if (element
|
|
1075
|
+
if (isCalculatedOnRead(element)) {
|
|
1071
1076
|
linkCalculatedElement(element)
|
|
1072
1077
|
}
|
|
1073
1078
|
})
|
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,46 @@
|
|
|
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
|
+
/**
|
|
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
|
+
|
|
41
|
+
// export the function to be used in other modules
|
|
42
|
+
module.exports = {
|
|
43
|
+
prettyPrintRef,
|
|
44
|
+
isCalculatedOnRead,
|
|
45
|
+
isCalculatedElement
|
|
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.0",
|
|
4
4
|
"description": "CDS base database service",
|
|
5
5
|
"homepage": "https://github.com/cap-js/cds-dbs/tree/main/db-service#cds-base-database-service",
|
|
6
6
|
"repository": {
|
|
@@ -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"
|