@cap-js/db-service 2.5.1 → 2.7.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/cql-functions.js +42 -0
- package/lib/cqn2sql.js +9 -3
- package/lib/cqn4sql.js +71 -48
- package/lib/deep-queries.js +1 -14
- package/lib/infer/index.js +55 -14
- package/lib/search.js +3 -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
|
+
## [2.7.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.6.0...db-service-v2.7.0) (2025-11-26)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* `error` standard function ([#1421](https://github.com/cap-js/cds-dbs/issues/1421)) ([b1b0fca](https://github.com/cap-js/cds-dbs/commit/b1b0fca00387c45ed91280b2df4282be90ea0a6e))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
* LimitedRank with compositions ([#1391](https://github.com/cap-js/cds-dbs/issues/1391)) ([31766cd](https://github.com/cap-js/cds-dbs/commit/31766cd8f9b626d090129b174ac9a04b4d578c21))
|
|
18
|
+
* reject nested projection if duplicated ([#1411](https://github.com/cap-js/cds-dbs/issues/1411)) ([6e924c9](https://github.com/cap-js/cds-dbs/commit/6e924c9942de6e6a4abf7b2c168d4378efcaefa9))
|
|
19
|
+
|
|
20
|
+
## [2.6.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.1...db-service-v2.6.0) (2025-10-23)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
* **`flattening`:** allow flattening of `n`-ary structures in `list` ([#1337](https://github.com/cap-js/cds-dbs/issues/1337)) ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
|
|
26
|
+
* **`flattening`:** allow flattening of structures with exactly one leaf ([7ec18f2](https://github.com/cap-js/cds-dbs/commit/7ec18f24dba80ba31ad4e46f816c17fa64cba91a))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
* **`@cds.search`:** properly exclude an association from being searched ([#1385](https://github.com/cap-js/cds-dbs/issues/1385)) ([9ed4245](https://github.com/cap-js/cds-dbs/commit/9ed42458417c15dc33409befd0ef40889f04a69f))
|
|
32
|
+
* tree table with expand ([#1363](https://github.com/cap-js/cds-dbs/issues/1363)) ([bdad412](https://github.com/cap-js/cds-dbs/commit/bdad412f0362165b532ce35261773e5ecc7c696a))
|
|
33
|
+
|
|
7
34
|
## [2.5.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.0...db-service-v2.5.1) (2025-09-30)
|
|
8
35
|
|
|
9
36
|
|
package/lib/cql-functions.js
CHANGED
|
@@ -4,6 +4,48 @@ const cds = require('@sap/cds')
|
|
|
4
4
|
|
|
5
5
|
// OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions
|
|
6
6
|
const StandardFunctions = {
|
|
7
|
+
/**
|
|
8
|
+
* Generates SQL statement that produces a runtime compatible error object
|
|
9
|
+
* @param {string|object} message - The i18n key or message of the error object
|
|
10
|
+
* @param {Array<xpr>} args - The arguments to apply to the i18n string
|
|
11
|
+
* @param {Array<xpr>} targets - The name of the element that the error is related to
|
|
12
|
+
* @return {string} - SQL statement
|
|
13
|
+
*/
|
|
14
|
+
error: function (message, args, targets) {
|
|
15
|
+
targets = targets && (targets.list || (targets.val || targets.ref) && [targets])
|
|
16
|
+
if (Array.isArray(targets)) targets = targets.map(e => e.ref && { val: e.ref.at(-1) } || e)
|
|
17
|
+
args = args && (args.list || (args.val || args.ref) && [args])
|
|
18
|
+
|
|
19
|
+
return `(${this.SELECT({
|
|
20
|
+
SELECT: {
|
|
21
|
+
expand: 'root',
|
|
22
|
+
columns: [
|
|
23
|
+
{
|
|
24
|
+
__proto__: (message || { val: null }),
|
|
25
|
+
as: 'message',
|
|
26
|
+
},
|
|
27
|
+
args ? {
|
|
28
|
+
func: 'json_array',
|
|
29
|
+
args: args,
|
|
30
|
+
as: 'args',
|
|
31
|
+
element: cds.builtin.types.Map,
|
|
32
|
+
} : { val: null, as: 'args' },
|
|
33
|
+
targets ? {
|
|
34
|
+
func: 'json_array',
|
|
35
|
+
args: targets,
|
|
36
|
+
as: 'targets',
|
|
37
|
+
element: cds.builtin.types.Map,
|
|
38
|
+
} : { val: null, as: 'targets' },
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
elements: {
|
|
42
|
+
message: cds.builtin.types.String,
|
|
43
|
+
args: cds.builtin.types.Map,
|
|
44
|
+
targets: cds.builtin.types.Map,
|
|
45
|
+
}
|
|
46
|
+
})})`
|
|
47
|
+
},
|
|
48
|
+
|
|
7
49
|
/**
|
|
8
50
|
* Generates SQL statement that produces a boolean value indicating whether the search term is contained in the given columns
|
|
9
51
|
* @param {string} ref - The reference object containing column information
|
package/lib/cqn2sql.js
CHANGED
|
@@ -325,7 +325,7 @@ class CQN2SQLRenderer {
|
|
|
325
325
|
DistanceFromRoot: { xpr: [{ ref: ['HIERARCHY_LEVEL'] }, '-', { val: 1, param: false }], as: 'DistanceFromRoot' },
|
|
326
326
|
DrillState: false,
|
|
327
327
|
LimitedDescendantCount: { xpr: [{ ref: ['HIERARCHY_TREE_SIZE'] }, '-', { val: 1, param: false }], as: 'LimitedDescendantCount' },
|
|
328
|
-
LimitedRank: { xpr: [{ func: 'row_number', args: [] }, 'OVER', { xpr: [] }, '-', { val: 1, param: false }], as: 'LimitedRank' }
|
|
328
|
+
LimitedRank: { xpr: [{ func: 'row_number', args: [] }, 'OVER', { xpr: ['ORDER', 'BY', { ref: ['HIERARCHY_RANK'] }, 'ASC'] }, '-', { val: 1, param: false }], as: 'LimitedRank' }
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
const columnsFiltered = columns
|
|
@@ -350,7 +350,13 @@ class CQN2SQLRenderer {
|
|
|
350
350
|
if (element['@Core.Computed'] && name in availableComputedColumns) continue
|
|
351
351
|
if (name.toUpperCase() in reservedColumnNames) ref.as = `$$${name}$$`
|
|
352
352
|
columnsIn.push(ref)
|
|
353
|
-
|
|
353
|
+
const foreignkey4 = element._foreignKey4
|
|
354
|
+
if (
|
|
355
|
+
from.args ||
|
|
356
|
+
columnsFiltered.find(c => this.column_name(c) === name) ||
|
|
357
|
+
// foreignkey needs to be included when the association is expanded
|
|
358
|
+
(foreignkey4 && q.SELECT.columns.some(c => c.element?.isAssociation && c.element.name === foreignkey4))
|
|
359
|
+
) {
|
|
354
360
|
columnsOut.push(ref.as ? { ref: [ref.as], as: name } : ref)
|
|
355
361
|
}
|
|
356
362
|
}
|
|
@@ -1320,7 +1326,7 @@ class CQN2SQLRenderer {
|
|
|
1320
1326
|
} else {
|
|
1321
1327
|
cds.error`Invalid arguments provided for function '${func}' (${args})`
|
|
1322
1328
|
}
|
|
1323
|
-
const fn = this.class.Functions[func]?.apply(this, args) || `${func}(${args})`
|
|
1329
|
+
const fn = this.class.Functions[func]?.apply(this, Array.isArray(args) ? args: [args]) || `${func}(${args})`
|
|
1324
1330
|
if (xpr) return `${fn} ${this.xpr({ xpr })}`
|
|
1325
1331
|
return fn
|
|
1326
1332
|
}
|
package/lib/cqn4sql.js
CHANGED
|
@@ -74,6 +74,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
74
74
|
if (inferred.SELECT?.from.ref?.at(-1).id) {
|
|
75
75
|
assignQueryModifiers(inferred.SELECT, inferred.SELECT.from.ref.at(-1))
|
|
76
76
|
}
|
|
77
|
+
if (inferred.DELETE?.from.ref?.at(-1).id) {
|
|
78
|
+
assignQueryModifiers(inferred.DELETE, inferred.DELETE.from.ref.at(-1))
|
|
79
|
+
}
|
|
80
|
+
if (inferred.UPDATE?.entity.ref?.at(-1).id) {
|
|
81
|
+
assignQueryModifiers(inferred.UPDATE, inferred.UPDATE.entity.ref.at(-1))
|
|
82
|
+
}
|
|
77
83
|
inferred = infer(inferred, model)
|
|
78
84
|
const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
|
|
79
85
|
// if the query has custom joins we don't want to transform it
|
|
@@ -96,7 +102,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
96
102
|
|
|
97
103
|
// Transform the existing where, prepend table aliases, and so on...
|
|
98
104
|
if (where) {
|
|
99
|
-
transformedProp.where = getTransformedTokenStream(where)
|
|
105
|
+
transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
|
|
100
106
|
}
|
|
101
107
|
|
|
102
108
|
// Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
|
|
@@ -191,7 +197,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
191
197
|
|
|
192
198
|
// Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
|
|
193
199
|
if (having) {
|
|
194
|
-
transformedQuery.SELECT.having = getTransformedTokenStream(having)
|
|
200
|
+
transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
if (groupBy) {
|
|
@@ -314,7 +320,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
314
320
|
lhs.args.push(arg)
|
|
315
321
|
alreadySeen.set(nextAssoc.$refLink.alias, true)
|
|
316
322
|
if (nextAssoc.where) {
|
|
317
|
-
const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
|
|
323
|
+
const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
|
|
318
324
|
lhs.on = [
|
|
319
325
|
...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
|
|
320
326
|
'and',
|
|
@@ -521,14 +527,14 @@ function cqn4sql(originalQuery, model) {
|
|
|
521
527
|
}
|
|
522
528
|
}
|
|
523
529
|
|
|
524
|
-
function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
|
|
530
|
+
function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
|
|
525
531
|
let value
|
|
526
532
|
|
|
527
533
|
if (column.$refLinks) {
|
|
528
534
|
const { $refLinks } = column
|
|
529
535
|
value = $refLinks[$refLinks.length - 1].definition.value
|
|
530
536
|
if (column.$refLinks.length > 1) {
|
|
531
|
-
baseLink =
|
|
537
|
+
$baseLink =
|
|
532
538
|
[...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
|
|
533
539
|
// if there is no association in the path, the table alias is the base link
|
|
534
540
|
// TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
|
|
@@ -541,13 +547,13 @@ function cqn4sql(originalQuery, model) {
|
|
|
541
547
|
|
|
542
548
|
let res
|
|
543
549
|
if (ref) {
|
|
544
|
-
res = getTransformedTokenStream([value], baseLink)[0]
|
|
550
|
+
res = getTransformedTokenStream([value], { $baseLink })[0]
|
|
545
551
|
} else if (xpr) {
|
|
546
|
-
res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
|
|
552
|
+
res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
|
|
547
553
|
} else if (val !== undefined) {
|
|
548
554
|
res = { val }
|
|
549
555
|
} else if (func) {
|
|
550
|
-
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
|
|
556
|
+
res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
|
|
551
557
|
}
|
|
552
558
|
if (!omitAlias) res.as = column.as || column.name || column.flatName
|
|
553
559
|
return res
|
|
@@ -1018,7 +1024,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1018
1024
|
* the result.
|
|
1019
1025
|
*/
|
|
1020
1026
|
if (inOrderBy && flatColumns.length > 1)
|
|
1021
|
-
throw new Error(`
|
|
1027
|
+
throw new Error(`Structured element “${getFullName(leaf)}” expands to multiple fields and can't be used in order by`)
|
|
1022
1028
|
flatColumns.forEach(fc => {
|
|
1023
1029
|
if (col.nulls) fc.nulls = col.nulls
|
|
1024
1030
|
if (col.sort) fc.sort = col.sort
|
|
@@ -1182,7 +1188,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1182
1188
|
if (column.val || column.func || column.SELECT) return [column]
|
|
1183
1189
|
|
|
1184
1190
|
const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
|
|
1185
|
-
let { baseName, columnAlias = column.as, tableAlias } = names
|
|
1191
|
+
let { baseName, columnAlias = column.as, tableAlias } = names || {}
|
|
1186
1192
|
const { exclude, replace } = excludeAndReplace || {}
|
|
1187
1193
|
const { $refLinks, flatName, isJoinRelevant } = column
|
|
1188
1194
|
let firstNonJoinRelevantAssoc, stepAfterAssoc
|
|
@@ -1360,14 +1366,18 @@ function cqn4sql(originalQuery, model) {
|
|
|
1360
1366
|
* @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
|
|
1361
1367
|
* object representing a CQN construct such as a column, an operator,
|
|
1362
1368
|
* or a subquery.
|
|
1363
|
-
* @param {object} [
|
|
1364
|
-
*
|
|
1365
|
-
*
|
|
1366
|
-
*
|
|
1369
|
+
* @param {object} [context] - Optional context object.
|
|
1370
|
+
* @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
|
|
1371
|
+
* It serves as the reference point for resolving associations in
|
|
1372
|
+
* statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
|
|
1373
|
+
* Here, the $baseLink for `anotherAssoc` would be `assoc`.
|
|
1374
|
+
* @param {string} [context.prop] - The query property which holds the token stream which shall be
|
|
1375
|
+
* transformed by this function, e.g. "where".
|
|
1367
1376
|
* @returns {object[]} - The transformed token stream.
|
|
1368
1377
|
*/
|
|
1369
|
-
function getTransformedTokenStream(tokenStream,
|
|
1378
|
+
function getTransformedTokenStream(tokenStream, context = {}) {
|
|
1370
1379
|
const transformedTokenStream = []
|
|
1380
|
+
const { $baseLink, /* prop */ } = context
|
|
1371
1381
|
for (let i = 0; i < tokenStream.length; i++) {
|
|
1372
1382
|
const token = tokenStream[i]
|
|
1373
1383
|
if (token === 'exists') {
|
|
@@ -1453,27 +1463,15 @@ function cqn4sql(originalQuery, model) {
|
|
|
1453
1463
|
if (list.every(e => e.val))
|
|
1454
1464
|
// no need for transformation
|
|
1455
1465
|
transformedTokenStream.push({ list })
|
|
1456
|
-
else transformedTokenStream.push({ list: getTransformedTokenStream(list, $baseLink) })
|
|
1466
|
+
else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
|
|
1457
1467
|
}
|
|
1458
1468
|
} else if (tokenStream.length === 1 && token.val && $baseLink) {
|
|
1459
|
-
// infix filter - OData variant w/o mentioning key
|
|
1469
|
+
// infix filter - OData variant w/o mentioning key
|
|
1460
1470
|
const def = getDefinition($baseLink.definition.target) || $baseLink.definition
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
if (v !== backlinkFor($baseLink.definition)?.[0]) {
|
|
1466
|
-
// up__ID already part of inner where exists, no need to add it explicitly here
|
|
1467
|
-
flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
// TODO: improve error message, the current message is generally not true (only for OData shortcut notation)
|
|
1471
|
-
if (flatKeys.length > 1)
|
|
1472
|
-
throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
|
|
1473
|
-
flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
|
|
1474
|
-
keyValComparisons.forEach((kv, j) =>
|
|
1475
|
-
transformedTokenStream.push(...kv) && keyValComparisons[j + 1] ? transformedTokenStream.push('and') : null,
|
|
1476
|
-
)
|
|
1471
|
+
const flatKeys = getPrimaryKey(def, $baseLink.alias)
|
|
1472
|
+
if (flatKeys.length > 1) // TODO: what about keyless?
|
|
1473
|
+
throw new Error(`Shortcut notation “[${token.val}]” not available for composite primary key of “${def.name}”, write “<key> = ${token.val}” explicitly`)
|
|
1474
|
+
transformedTokenStream.push(...[flatKeys[0], '=', token]);
|
|
1477
1475
|
} else if (token.ref && token.param) {
|
|
1478
1476
|
transformedTokenStream.push({ ...token })
|
|
1479
1477
|
} else if (pseudos.elements[token.ref?.[0]]) {
|
|
@@ -1509,13 +1507,13 @@ function cqn4sql(originalQuery, model) {
|
|
|
1509
1507
|
i = indexRhs // jump to next relevant index
|
|
1510
1508
|
} else {
|
|
1511
1509
|
// reject associations in expression, except if we are in an infix filter -> $baseLink is set
|
|
1512
|
-
assertNoStructInXpr(token,
|
|
1510
|
+
assertNoStructInXpr(token, context)
|
|
1513
1511
|
// reject virtual elements in expressions as they will lead to a sql error down the line
|
|
1514
1512
|
if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
|
|
1515
1513
|
|
|
1516
1514
|
let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
|
|
1517
1515
|
if (token.ref) {
|
|
1518
|
-
const { definition } = token.$refLinks
|
|
1516
|
+
const { definition } = token.$refLinks.at(-1)
|
|
1519
1517
|
// Add definition to result
|
|
1520
1518
|
setElementOnColumns(result, definition)
|
|
1521
1519
|
if (isCalculatedOnRead(definition)) {
|
|
@@ -1536,7 +1534,15 @@ function cqn4sql(originalQuery, model) {
|
|
|
1536
1534
|
const lastAssoc =
|
|
1537
1535
|
token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
|
|
1538
1536
|
const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
|
|
1539
|
-
if
|
|
1537
|
+
if(isAssocOrStruct(definition)) {
|
|
1538
|
+
const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
|
|
1539
|
+
if(flat.length === 0)
|
|
1540
|
+
throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
|
|
1541
|
+
else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
|
|
1542
|
+
throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
|
|
1543
|
+
transformedTokenStream.push(...flat)
|
|
1544
|
+
continue
|
|
1545
|
+
} else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
|
|
1540
1546
|
let name = calculateElementName(token)
|
|
1541
1547
|
result.ref = [tableAlias, name]
|
|
1542
1548
|
} else if (tableAlias) {
|
|
@@ -1549,7 +1555,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1549
1555
|
result = transformSubquery(token)
|
|
1550
1556
|
} else {
|
|
1551
1557
|
if (token.xpr) {
|
|
1552
|
-
result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
|
|
1558
|
+
result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
|
|
1553
1559
|
}
|
|
1554
1560
|
if (token.func && token.args) {
|
|
1555
1561
|
result.args = getTransformedFunctionArgs(token.args, $baseLink)
|
|
@@ -1661,11 +1667,15 @@ function cqn4sql(originalQuery, model) {
|
|
|
1661
1667
|
}
|
|
1662
1668
|
}
|
|
1663
1669
|
|
|
1664
|
-
function assertNoStructInXpr(token,
|
|
1665
|
-
|
|
1666
|
-
|
|
1670
|
+
function assertNoStructInXpr(token, context) {
|
|
1671
|
+
const definition = token.$refLinks?.at(-1).definition
|
|
1672
|
+
if(!definition) return
|
|
1673
|
+
const rejectStructs = context && (context.prop in { where: 1, having: 1 })
|
|
1674
|
+
// unmanaged is always forbidden
|
|
1675
|
+
// expanding a ref in a `where`/`having` context
|
|
1676
|
+
if ((rejectStructs && definition?.target) || definition?.on)
|
|
1667
1677
|
rejectAssocInExpression()
|
|
1668
|
-
if (isStructured(
|
|
1678
|
+
if (rejectStructs && isStructured(definition))
|
|
1669
1679
|
// REVISIT: let this through if not requested otherwise
|
|
1670
1680
|
rejectStructInExpression()
|
|
1671
1681
|
|
|
@@ -1769,9 +1779,10 @@ function cqn4sql(originalQuery, model) {
|
|
|
1769
1779
|
}
|
|
1770
1780
|
}
|
|
1771
1781
|
|
|
1772
|
-
//
|
|
1773
|
-
if (refReverse[0].where)
|
|
1774
|
-
filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
|
|
1782
|
+
// OData variant w/o mentioning key
|
|
1783
|
+
if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
|
|
1784
|
+
filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
|
|
1785
|
+
}
|
|
1775
1786
|
|
|
1776
1787
|
if (existingWhere.length > 0) filterConditions.push(existingWhere)
|
|
1777
1788
|
if (whereExistsSubSelects.length > 0) {
|
|
@@ -2209,7 +2220,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
2209
2220
|
}
|
|
2210
2221
|
|
|
2211
2222
|
if (customWhere) {
|
|
2212
|
-
const filter = getTransformedTokenStream(customWhere, next)
|
|
2223
|
+
const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
|
|
2213
2224
|
const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
|
|
2214
2225
|
on.push('and', ...wrappedFilter)
|
|
2215
2226
|
}
|
|
@@ -2267,6 +2278,12 @@ function cqn4sql(originalQuery, model) {
|
|
|
2267
2278
|
if (!node || !node.$refLinks || !node.ref) {
|
|
2268
2279
|
throw new Error('Invalid node')
|
|
2269
2280
|
}
|
|
2281
|
+
if(node.$refLinks[0].$main) {
|
|
2282
|
+
if (node.isJoinRelevant) {
|
|
2283
|
+
return getJoinRelevantAlias(node)
|
|
2284
|
+
}
|
|
2285
|
+
return node.$refLinks[0].alias
|
|
2286
|
+
}
|
|
2270
2287
|
if ($baseLink) {
|
|
2271
2288
|
return getBaseLinkAlias($baseLink)
|
|
2272
2289
|
}
|
|
@@ -2315,10 +2332,10 @@ function cqn4sql(originalQuery, model) {
|
|
|
2315
2332
|
function getTransformedFunctionArgs(args, $baseLink = null) {
|
|
2316
2333
|
let result = null
|
|
2317
2334
|
if (Array.isArray(args)) {
|
|
2318
|
-
result = args.
|
|
2335
|
+
result = args.flatMap(t => {
|
|
2319
2336
|
if (!t.val)
|
|
2320
2337
|
// this must not be touched
|
|
2321
|
-
return getTransformedTokenStream([t], $baseLink)
|
|
2338
|
+
return getTransformedTokenStream([t], { $baseLink })
|
|
2322
2339
|
return t
|
|
2323
2340
|
})
|
|
2324
2341
|
} else if (typeof args === 'object') {
|
|
@@ -2327,7 +2344,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
2327
2344
|
const t = args[prop]
|
|
2328
2345
|
if (!t.val)
|
|
2329
2346
|
// this must not be touched
|
|
2330
|
-
result[prop] = getTransformedTokenStream([t], $baseLink)[0]
|
|
2347
|
+
result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
|
|
2331
2348
|
else result[prop] = t
|
|
2332
2349
|
}
|
|
2333
2350
|
}
|
|
@@ -2402,6 +2419,12 @@ function assignQueryModifiers(SELECT, modifiers) {
|
|
|
2402
2419
|
} else if (key === 'having') {
|
|
2403
2420
|
if (!SELECT.having) SELECT.having = val
|
|
2404
2421
|
else SELECT.having.push('and', ...val)
|
|
2422
|
+
} else if (key === 'where') {
|
|
2423
|
+
// ignore OData shortcut variant: `… bookshop.Orders:items[2]`
|
|
2424
|
+
if(!val || val.length === 1 && val[0].val) continue
|
|
2425
|
+
if (!SELECT.where) SELECT.where = val
|
|
2426
|
+
// infix filter comes first in resulting where
|
|
2427
|
+
else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]
|
|
2405
2428
|
}
|
|
2406
2429
|
}
|
|
2407
2430
|
}
|
package/lib/deep-queries.js
CHANGED
|
@@ -3,20 +3,7 @@ const { _target_name4 } = require('./SQLService')
|
|
|
3
3
|
|
|
4
4
|
const ROOT = Symbol('root')
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
let _compareJson
|
|
8
|
-
const compareJson = (...args) => {
|
|
9
|
-
if (!_compareJson) {
|
|
10
|
-
try {
|
|
11
|
-
// new path
|
|
12
|
-
_compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
|
|
13
|
-
} catch {
|
|
14
|
-
// old path
|
|
15
|
-
_compareJson = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson').compareJson
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return _compareJson(...args)
|
|
19
|
-
}
|
|
6
|
+
const compareJson = require('@sap/cds/libx/_runtime/common/utils/compareJson').compareJson
|
|
20
7
|
|
|
21
8
|
const handledDeep = Symbol('handledDeep')
|
|
22
9
|
|
package/lib/infer/index.js
CHANGED
|
@@ -47,7 +47,6 @@ function infer(originalQuery, model) {
|
|
|
47
47
|
let $combinedElements
|
|
48
48
|
|
|
49
49
|
const sources = inferTarget(_.into || _.from || _.entity, {}) // IMPORTANT: _.into has to go before _.from for INSERT.into().from(SELECT)
|
|
50
|
-
const joinTree = new JoinTree(sources)
|
|
51
50
|
const aliases = Object.keys(sources)
|
|
52
51
|
const target = aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery
|
|
53
52
|
Object.defineProperties(inferred, {
|
|
@@ -72,7 +71,6 @@ function infer(originalQuery, model) {
|
|
|
72
71
|
Object.defineProperties(inferred, {
|
|
73
72
|
$combinedElements: { value: $combinedElements, writable: true, configurable: true },
|
|
74
73
|
elements: { value: elements, writable: true, configurable: true },
|
|
75
|
-
joinTree: { value: joinTree, writable: true, configurable: true }, // REVISIT: eliminate
|
|
76
74
|
})
|
|
77
75
|
// also enrich original query -> writable because it may be inferred again
|
|
78
76
|
defineProperty(originalQuery, 'elements', elements)
|
|
@@ -96,6 +94,10 @@ function infer(originalQuery, model) {
|
|
|
96
94
|
*/
|
|
97
95
|
function inferTarget(from, querySources, useTechnicalAlias = true) {
|
|
98
96
|
const { ref } = from
|
|
97
|
+
// Given a from clause `Root:parent[$main.name = name].parent as Foo`
|
|
98
|
+
// we need to first resolve until to the last step of the from.ref
|
|
99
|
+
// before we can replace $main with `Foo`
|
|
100
|
+
const $mainLazyResolve = [] // TODO: remove and replace with real alias breakout
|
|
99
101
|
if (ref) {
|
|
100
102
|
const { id, args } = ref[0]
|
|
101
103
|
const first = id || ref[0]
|
|
@@ -111,7 +113,7 @@ function infer(originalQuery, model) {
|
|
|
111
113
|
if (target.kind !== 'entity' && !target.isAssociation)
|
|
112
114
|
throw new Error('Query source must be a an entity or an association')
|
|
113
115
|
|
|
114
|
-
inferArg(from, null, null, { inFrom: true })
|
|
116
|
+
inferArg(from, null, null, { inFrom: true, $mainLazyResolve })
|
|
115
117
|
const alias =
|
|
116
118
|
from.uniqueSubqueryAlias ||
|
|
117
119
|
from.as ||
|
|
@@ -137,6 +139,10 @@ function infer(originalQuery, model) {
|
|
|
137
139
|
} else if (from.SET) {
|
|
138
140
|
infer(from, model)
|
|
139
141
|
}
|
|
142
|
+
|
|
143
|
+
const joinTree = new JoinTree(querySources)
|
|
144
|
+
Object.defineProperty( inferred, 'joinTree', { value: joinTree, writable: true, configurable: true } )
|
|
145
|
+
for(const lazyRef of $mainLazyResolve) inferArg(lazyRef)
|
|
140
146
|
return querySources
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -201,7 +207,7 @@ function infer(originalQuery, model) {
|
|
|
201
207
|
} else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
|
|
202
208
|
const as = col.as || col.func || col.val
|
|
203
209
|
if (as === undefined) cds.error`Expecting expression to have an alias name`
|
|
204
|
-
if (queryElements[as])
|
|
210
|
+
if (queryElements[as]) rejectDuplicatedElement(as)
|
|
205
211
|
if (col.xpr || col.SELECT) {
|
|
206
212
|
queryElements[as] = getElementForXprOrSubquery(col, queryElements, dollarSelfRefs)
|
|
207
213
|
}
|
|
@@ -422,11 +428,13 @@ function infer(originalQuery, model) {
|
|
|
422
428
|
// we must ignore the element from the queries elements
|
|
423
429
|
let isPersisted = true
|
|
424
430
|
let firstStepIsTableAlias, firstStepIsSelf, expandOnTableAlias
|
|
425
|
-
|
|
431
|
+
const firstStepIsDollarMain = arg.ref.length > 1 && arg.ref[0] === '$main'
|
|
432
|
+
if (!inFrom && !firstStepIsDollarMain) {
|
|
426
433
|
firstStepIsTableAlias = arg.ref.length > 1 && arg.ref[0] in sources
|
|
427
434
|
firstStepIsSelf = !firstStepIsTableAlias && arg.ref.length > 1 && ['$self', '$projection'].includes(arg.ref[0])
|
|
428
435
|
expandOnTableAlias = arg.ref.length === 1 && arg.ref[0] in sources && (arg.expand || arg.inline)
|
|
429
436
|
}
|
|
437
|
+
|
|
430
438
|
if (dollarSelfRefs && firstStepIsSelf) {
|
|
431
439
|
defineProperty(arg, 'inXpr', true)
|
|
432
440
|
dollarSelfRefs.push(arg)
|
|
@@ -438,10 +446,20 @@ function infer(originalQuery, model) {
|
|
|
438
446
|
// on conditions of joins
|
|
439
447
|
const skipAliasedFkSegmentsOfNameStack = []
|
|
440
448
|
let pseudoPath = false
|
|
441
|
-
arg.ref.
|
|
449
|
+
for(let i = 0; i < arg.ref.length; i++) {
|
|
450
|
+
const step = arg.ref[i]
|
|
442
451
|
const id = step.id || step
|
|
443
452
|
if (i === 0) {
|
|
444
|
-
if
|
|
453
|
+
if(firstStepIsDollarMain) {
|
|
454
|
+
if(inFrom) { // we need to resolve the full from clause first
|
|
455
|
+
context.$mainLazyResolve.push(arg)
|
|
456
|
+
return; // this will be done once the from clause is fully resolved
|
|
457
|
+
} else {
|
|
458
|
+
// replace $main with the alias of the outermost query
|
|
459
|
+
const mainAlias = getMainAlias(inferred)
|
|
460
|
+
arg.$refLinks.push(Object.assign(mainAlias, {$main: true}))
|
|
461
|
+
}
|
|
462
|
+
} else if (id in pseudos.elements) {
|
|
445
463
|
// pseudo path
|
|
446
464
|
arg.$refLinks.push({ definition: pseudos.elements[id], target: pseudos })
|
|
447
465
|
pseudoPath = true // only first path step must be well defined
|
|
@@ -569,6 +587,7 @@ function infer(originalQuery, model) {
|
|
|
569
587
|
skipJoinsForFilter = true
|
|
570
588
|
} else if (token.ref || token.xpr || token.list) {
|
|
571
589
|
inferArg(token, false, arg.$refLinks[i], {
|
|
590
|
+
...context,
|
|
572
591
|
inExists: skipJoinsForFilter || inExists,
|
|
573
592
|
inXpr: !!token.xpr,
|
|
574
593
|
inInfixFilter: true,
|
|
@@ -586,7 +605,8 @@ function infer(originalQuery, model) {
|
|
|
586
605
|
})
|
|
587
606
|
}
|
|
588
607
|
|
|
589
|
-
arg.$refLinks[i]
|
|
608
|
+
if(!arg.$refLinks[i].$main)
|
|
609
|
+
arg.$refLinks[i].alias = !arg.ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
|
|
590
610
|
if (hasOwnSkip(getDefinition(arg.$refLinks[i].definition.target))) isPersisted = false
|
|
591
611
|
if (!arg.ref[i + 1]) {
|
|
592
612
|
const flatName = nameSegments.join('_')
|
|
@@ -602,9 +622,17 @@ function infer(originalQuery, model) {
|
|
|
602
622
|
if (arg.$refLinks.length === 1 && arg.$refLinks[0].definition.kind === 'entity')
|
|
603
623
|
elementName = arg.$refLinks[0].alias
|
|
604
624
|
else elementName = arg.as || flatName
|
|
605
|
-
|
|
625
|
+
|
|
626
|
+
if (queryElements) {
|
|
627
|
+
if (queryElements[elementName] !== undefined)
|
|
628
|
+
rejectDuplicatedElement(elementName)
|
|
629
|
+
queryElements[elementName] = elements
|
|
630
|
+
}
|
|
606
631
|
} else if (arg.inline && queryElements) {
|
|
607
632
|
const elements = resolveInline(arg)
|
|
633
|
+
for (const elName in elements) {
|
|
634
|
+
if (queryElements[elName] !== undefined) rejectDuplicatedElement(elName)
|
|
635
|
+
}
|
|
608
636
|
Object.assign(queryElements, elements)
|
|
609
637
|
} else {
|
|
610
638
|
// shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']`
|
|
@@ -626,14 +654,13 @@ function infer(originalQuery, model) {
|
|
|
626
654
|
else elementName = flatName
|
|
627
655
|
}
|
|
628
656
|
if (queryElements[elementName] !== undefined)
|
|
629
|
-
|
|
657
|
+
rejectDuplicatedElement(elementName)
|
|
630
658
|
const element = getCopyWithAnnos(arg, leafArt)
|
|
631
659
|
queryElements[elementName] = element
|
|
632
660
|
}
|
|
633
661
|
}
|
|
634
662
|
}
|
|
635
|
-
}
|
|
636
|
-
|
|
663
|
+
}
|
|
637
664
|
// we need inner joins for the path expressions inside filter expressions after exists predicate
|
|
638
665
|
if ($baseLink?.pathExpressionInsideFilter) defineProperty(arg, 'join', 'inner')
|
|
639
666
|
|
|
@@ -656,7 +683,9 @@ function infer(originalQuery, model) {
|
|
|
656
683
|
: arg
|
|
657
684
|
if (isColumnJoinRelevant(colWithBase)) {
|
|
658
685
|
defineProperty(arg, 'isJoinRelevant', true)
|
|
659
|
-
|
|
686
|
+
// join resolved in outer query
|
|
687
|
+
if(!(arg.$refLinks[0].$main && originalQuery.outerQueries))
|
|
688
|
+
inferred.joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
|
|
660
689
|
}
|
|
661
690
|
}
|
|
662
691
|
if (isCalculatedOnRead(leafArt)) {
|
|
@@ -807,6 +836,10 @@ function infer(originalQuery, model) {
|
|
|
807
836
|
throw new Error(err.join(','))
|
|
808
837
|
}
|
|
809
838
|
}
|
|
839
|
+
function rejectDuplicatedElement(elementName) {
|
|
840
|
+
throw new Error(`Duplicate definition of element “${elementName}”`)
|
|
841
|
+
}
|
|
842
|
+
|
|
810
843
|
function linkCalculatedElement(column, baseLink, baseColumn, context = {}) {
|
|
811
844
|
const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
|
|
812
845
|
if (alreadySeenCalcElements.has(calcElement)) return
|
|
@@ -901,7 +934,7 @@ function infer(originalQuery, model) {
|
|
|
901
934
|
if (calcElementIsJoinRelevant) {
|
|
902
935
|
if (!calcElement.value.isJoinRelevant)
|
|
903
936
|
defineProperty(step, 'isJoinRelevant',true)
|
|
904
|
-
joinTree.mergeColumn(p, originalQuery.outerQueries)
|
|
937
|
+
inferred.joinTree.mergeColumn(p, originalQuery.outerQueries)
|
|
905
938
|
} else {
|
|
906
939
|
// we need to explicitly set the value to false in this case,
|
|
907
940
|
// e.g. `SELECT from booksCalc.Books { ID, author.{name }, author {name } }`
|
|
@@ -1160,4 +1193,12 @@ function applyToFunctionArgs(funcArgs, cb, cbArgs) {
|
|
|
1160
1193
|
else if (typeof funcArgs === 'object') Object.keys(funcArgs).forEach(prop => cb(funcArgs[prop], ...cbArgs))
|
|
1161
1194
|
}
|
|
1162
1195
|
|
|
1196
|
+
function getMainAlias (query) {
|
|
1197
|
+
let mainAlias
|
|
1198
|
+
if (query.outerQueries) mainAlias = query.outerQueries[0].SELECT?.from.$refLinks.at(-1)
|
|
1199
|
+
else mainAlias = query.SELECT?.from.$refLinks.at(-1)
|
|
1200
|
+
if(!mainAlias) throw new Error('Cannot determine main query source for $main, please report this')
|
|
1201
|
+
return mainAlias
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1163
1204
|
module.exports = infer
|
package/lib/search.js
CHANGED
|
@@ -79,6 +79,9 @@ const _getSearchableColumns = entity => {
|
|
|
79
79
|
// always ignore virtual elements from search
|
|
80
80
|
if(column?.virtual) continue
|
|
81
81
|
if (column?.isAssociation || columnName.includes('.')) {
|
|
82
|
+
if(!annotationValue)
|
|
83
|
+
continue
|
|
84
|
+
|
|
82
85
|
const ref = columnName.split('.')
|
|
83
86
|
if(ref.length > 1) skipDefaultSearchableElements = true
|
|
84
87
|
deepSearchCandidates.push({ ref })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/db-service",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"generic-pool": "^3.9.0"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@sap/cds": ">=9"
|
|
30
|
+
"@sap/cds": ">=9.4.5"
|
|
31
31
|
},
|
|
32
32
|
"license": "Apache-2.0"
|
|
33
33
|
}
|