@cap-js/db-service 1.10.3 → 1.12.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 +30 -0
- package/README.md +3 -3
- package/lib/SQLService.js +4 -16
- package/lib/common/generic-pool.js +1 -1
- package/lib/cql-functions.js +3 -0
- package/lib/cqn2sql.js +7 -3
- package/lib/cqn4sql.js +106 -86
- package/lib/infer/index.js +1 -1
- package/lib/search.js +48 -24
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@
|
|
|
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.12.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.11.0...db-service-v1.12.0) (2024-07-25)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
*** 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))
|
|
13
|
+
*** 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)
|
|
14
|
+
*** quotations in vals ([#754](https://github.com/cap-js/cds-dbs/issues/754)) ([94d8e97](https://github.com/cap-js/cds-dbs/commit/94d8e977ed00776ff494287ce505d6b7e8017d2e))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
*** 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
|
+
|
|
21
|
+
|
|
22
|
+
## [1.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
* **search:** enable deep search with path expressions ([#590](https://github.com/cap-js/cds-dbs/issues/590)) ([e9e9461](https://github.com/cap-js/cds-dbs/commit/e9e9461362b3a521c06075e32fb1405c8ee4dee6))
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
* `search` interprets only first search term instead of raising an error ([#707](https://github.com/cap-js/cds-dbs/issues/707)) ([0b9108c](https://github.com/cap-js/cds-dbs/commit/0b9108c11a61b18704e36f93fbd654e0942bf40a))
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
* optimize foreign key access for expand with aggregations ([#734](https://github.com/cap-js/cds-dbs/issues/734)) ([77b7978](https://github.com/cap-js/cds-dbs/commit/77b79788931c9c45f156d54a4b2ec76ba9ba629a))
|
|
36
|
+
|
|
7
37
|
## [1.10.3](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.2...db-service-v1.10.3) (2024-07-05)
|
|
8
38
|
|
|
9
39
|
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# CDS base database service
|
|
2
2
|
|
|
3
|
-
Welcome to the
|
|
3
|
+
Welcome to the base database service for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js. This service forms the core of all supported databases and is the base of our streamlined database architecture.
|
|
4
4
|
|
|
5
|
-
Find documentation at https://cap.cloud.sap/docs/guides/databases
|
|
5
|
+
Find documentation at <https://cap.cloud.sap/docs/guides/databases>
|
|
6
6
|
|
|
7
7
|
## Support
|
|
8
8
|
|
|
@@ -23,4 +23,4 @@ We as members, contributors, and leaders pledge to make participation in our com
|
|
|
23
23
|
|
|
24
24
|
## Licensing
|
|
25
25
|
|
|
26
|
-
Copyright
|
|
26
|
+
Copyright 2024 SAP SE or an SAP affiliate company and cds-dbs contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/cds-dbs).
|
package/lib/SQLService.js
CHANGED
|
@@ -116,6 +116,8 @@ class SQLService extends DatabaseService {
|
|
|
116
116
|
* @type {Handler}
|
|
117
117
|
*/
|
|
118
118
|
async onSELECT({ query, data }) {
|
|
119
|
+
// REVISIT: for custom joins, infer is called twice, which is bad
|
|
120
|
+
// --> make cds.infer properly work with custom joins and remove this
|
|
119
121
|
if (!query.target) {
|
|
120
122
|
try { this.infer(query) } catch { /**/ }
|
|
121
123
|
}
|
|
@@ -369,10 +371,8 @@ class SQLService extends DatabaseService {
|
|
|
369
371
|
!q.SELECT?.from?.join &&
|
|
370
372
|
!q.SELECT?.from?.SELECT &&
|
|
371
373
|
!this.model?.definitions[_target_name4(q)]
|
|
372
|
-
)
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
return cqn4sql(q, this.model)
|
|
374
|
+
) return q
|
|
375
|
+
else return cqn4sql(q, this.model)
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
/**
|
|
@@ -462,18 +462,6 @@ const _target_name4 = q => {
|
|
|
462
462
|
return first.id || first
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
-
const _unquirked = !cds.env.ql.quirks_mode ? q => q : q => {
|
|
466
|
-
if (!q) return q
|
|
467
|
-
else if (typeof q.SELECT?.from === 'string') q.SELECT.from = { ref: [q.SELECT.from] }
|
|
468
|
-
else if (typeof q.INSERT?.into === 'string') q.INSERT.into = { ref: [q.INSERT.into] }
|
|
469
|
-
else if (typeof q.UPSERT?.into === 'string') q.UPSERT.into = { ref: [q.UPSERT.into] }
|
|
470
|
-
else if (typeof q.UPDATE?.entity === 'string') q.UPDATE.entity = { ref: [q.UPDATE.entity] }
|
|
471
|
-
else if (typeof q.DELETE?.from === 'string') q.DELETE.from = { ref: [q.DELETE.from] }
|
|
472
|
-
else if (typeof q.CREATE?.entity === 'string') q.CREATE.entity = { ref: [q.CREATE.entity] }
|
|
473
|
-
else if (typeof q.DROP?.entity === 'string') q.DROP.entity = { ref: [q.DROP.entity] }
|
|
474
|
-
return q
|
|
475
|
-
}
|
|
476
|
-
|
|
477
465
|
const sqls = new (class extends SQLService {
|
|
478
466
|
get factory() {
|
|
479
467
|
return null
|
package/lib/cql-functions.js
CHANGED
|
@@ -22,6 +22,9 @@ const StandardFunctions = {
|
|
|
22
22
|
*/
|
|
23
23
|
search: function (ref, arg) {
|
|
24
24
|
if (!('val' in arg)) throw new Error(`Only single value arguments are allowed for $search`)
|
|
25
|
+
// only apply first search term, rest is ignored
|
|
26
|
+
const sub= /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
|
|
27
|
+
arg.val = arg.__proto__.val = (sub[2] ? JSON.parse(sub[2]) : sub[3]) || ''
|
|
25
28
|
const refs = ref.list || [ref],
|
|
26
29
|
{ toString } = ref
|
|
27
30
|
return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
|
package/lib/cqn2sql.js
CHANGED
|
@@ -85,7 +85,7 @@ class CQN2SQLRenderer {
|
|
|
85
85
|
const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
|
|
86
86
|
DEBUG?.(
|
|
87
87
|
this.sql,
|
|
88
|
-
sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values,
|
|
88
|
+
...(sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []),
|
|
89
89
|
)
|
|
90
90
|
return this
|
|
91
91
|
}
|
|
@@ -311,8 +311,8 @@ class CQN2SQLRenderer {
|
|
|
311
311
|
*/
|
|
312
312
|
column_expr(x, q) {
|
|
313
313
|
if (x === '*') return '*'
|
|
314
|
-
|
|
315
|
-
let sql = this.expr({ param: false, __proto__: x })
|
|
314
|
+
|
|
315
|
+
let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
|
|
316
316
|
let alias = this.column_alias4(x, q)
|
|
317
317
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
318
318
|
return sql
|
|
@@ -508,6 +508,7 @@ class CQN2SQLRenderer {
|
|
|
508
508
|
} else {
|
|
509
509
|
const stream = Readable.from(this.INSERT_entries_stream(INSERT.entries), { objectMode: false })
|
|
510
510
|
stream.type = 'json'
|
|
511
|
+
stream._raw = INSERT.entries
|
|
511
512
|
this.entries = [[...this.values, stream]]
|
|
512
513
|
}
|
|
513
514
|
|
|
@@ -652,6 +653,7 @@ class CQN2SQLRenderer {
|
|
|
652
653
|
} else {
|
|
653
654
|
const stream = Readable.from(this.INSERT_rows_stream(INSERT.rows), { objectMode: false })
|
|
654
655
|
stream.type = 'json'
|
|
656
|
+
stream._raw = INSERT.rows
|
|
655
657
|
this.entries = [[...this.values, stream]]
|
|
656
658
|
}
|
|
657
659
|
|
|
@@ -1080,6 +1082,8 @@ Buffer.prototype.toJSON = function () {
|
|
|
1080
1082
|
return this.toString('base64')
|
|
1081
1083
|
}
|
|
1082
1084
|
|
|
1085
|
+
Readable.prototype[require('node:util').inspect.custom] = Readable.prototype.toJSON = function () { return this._raw || `[object ${this.constructor.name}]` }
|
|
1086
|
+
|
|
1083
1087
|
const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || []
|
|
1084
1088
|
const _managed = {
|
|
1085
1089
|
'$user.id': '$user.id',
|
package/lib/cqn4sql.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const cds = require('@sap/cds')
|
|
4
|
-
const { computeColumnsToBeSearched } = require('./search')
|
|
5
4
|
|
|
6
5
|
const infer = require('./infer')
|
|
6
|
+
const { computeColumnsToBeSearched } = require('./search')
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
|
|
@@ -44,8 +44,26 @@ const { pseudos } = require('./infer/pseudos')
|
|
|
44
44
|
* @returns {object} transformedQuery the transformed query
|
|
45
45
|
*/
|
|
46
46
|
function cqn4sql(originalQuery, model) {
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
|
|
48
|
+
const hasCustomJoins =
|
|
49
|
+
originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)
|
|
50
|
+
|
|
51
|
+
if (!hasCustomJoins && inferred.SELECT?.search) {
|
|
52
|
+
// we need an instance of query because the elements of the query are needed for the calculation of the search columns
|
|
53
|
+
if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, Object.getPrototypeOf(SELECT()))
|
|
54
|
+
const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
|
|
55
|
+
if (searchTerm) {
|
|
56
|
+
// Search target can be a navigation, in that case use _target to get the correct entity
|
|
57
|
+
const { where, having } = transformSearch(searchTerm)
|
|
58
|
+
if (where) inferred.SELECT.where = where
|
|
59
|
+
else if (having) inferred.SELECT.having = having
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
inferred = infer(inferred, model)
|
|
63
|
+
// if the query has custom joins we don't want to transform it
|
|
64
|
+
// TODO: move all the way to the top of this function once cds.infer supports joins as well
|
|
65
|
+
// we need to infer the query even if no transformation will happen because cds.infer can't calculate the target
|
|
66
|
+
if (hasCustomJoins) return originalQuery
|
|
49
67
|
|
|
50
68
|
let transformedQuery = cds.ql.clone(inferred)
|
|
51
69
|
const kind = inferred.kind || Object.keys(inferred)[0]
|
|
@@ -111,7 +129,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
111
129
|
|
|
112
130
|
// calculate the primary keys of the target entity, there is always exactly
|
|
113
131
|
// one query source for UPDATE / DELETE
|
|
114
|
-
const queryTarget = Object.values(
|
|
132
|
+
const queryTarget = Object.values(inferred.sources)[0].definition
|
|
115
133
|
const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
|
|
116
134
|
const primaryKey = { list: [] }
|
|
117
135
|
keys.forEach(k => {
|
|
@@ -155,7 +173,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
155
173
|
if (columns) {
|
|
156
174
|
transformedQuery.SELECT.columns = getTransformedColumns(columns)
|
|
157
175
|
} else {
|
|
158
|
-
transformedQuery.SELECT.columns = getColumnsForWildcard(
|
|
176
|
+
transformedQuery.SELECT.columns = getColumnsForWildcard(inferred.SELECT?.excluding)
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
// Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
|
|
@@ -178,13 +196,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
178
196
|
transformedQuery.SELECT.orderBy = transformedOrderBy
|
|
179
197
|
}
|
|
180
198
|
}
|
|
181
|
-
|
|
182
|
-
if (inferred.SELECT.search) {
|
|
183
|
-
// Search target can be a navigation, in that case use _target to get the correct entity
|
|
184
|
-
const { where, having } = transformSearch(inferred.SELECT.search, transformedFrom) || {}
|
|
185
|
-
if (where) transformedQuery.SELECT.where = where
|
|
186
|
-
else if (having) transformedQuery.SELECT.having = having
|
|
187
|
-
}
|
|
188
199
|
return transformedQuery
|
|
189
200
|
}
|
|
190
201
|
|
|
@@ -206,46 +217,29 @@ function cqn4sql(originalQuery, model) {
|
|
|
206
217
|
* Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
|
|
207
218
|
* The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
|
|
208
219
|
*
|
|
209
|
-
* @param {object}
|
|
220
|
+
* @param {object} searchTerm - The search expression to be applied to the searchable columns within the query source.
|
|
210
221
|
* @param {object} from - The FROM clause of the CQN statement.
|
|
211
222
|
*
|
|
212
|
-
* @returns {
|
|
213
|
-
* - If the target of the query contains searchable elements, an array representing the WHERE or HAVING clause is returned.
|
|
214
|
-
* This includes appending to an existing clause with an AND condition or creating a new clause solely with the 'contains' clause.
|
|
215
|
-
* - If the SELECT query does not initially contain a WHERE or HAVING clause, the returned object solely consists of the 'contains' clause.
|
|
216
|
-
* - If the target entity of the query does not contain searchable elements, the function returns null.
|
|
223
|
+
* @returns {Object} - The function returns an object representing the WHERE or HAVING clause of the query.
|
|
217
224
|
*
|
|
218
225
|
* Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
|
|
219
226
|
* The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
|
|
220
227
|
*/
|
|
221
|
-
function transformSearch(
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// if the query is grouped and the queries columns contain an aggregate function,
|
|
237
|
-
// we must put the search term into the `having` clause, as the search expression
|
|
238
|
-
// is defined on the aggregated result, not on the individual rows
|
|
239
|
-
let prop = 'where'
|
|
240
|
-
|
|
241
|
-
if (inferred.SELECT.groupBy && searchIn.some(c => c.func || c.xpr)) prop = 'having'
|
|
242
|
-
if (transformedQuery.SELECT[prop]) {
|
|
243
|
-
return { [prop]: [asXpr(transformedQuery.SELECT.where), 'and', contains] }
|
|
244
|
-
} else {
|
|
245
|
-
return { [prop]: [contains] }
|
|
246
|
-
}
|
|
228
|
+
function transformSearch(searchTerm) {
|
|
229
|
+
let prop = 'where'
|
|
230
|
+
|
|
231
|
+
// if the query is grouped and the queries columns contain an aggregate function,
|
|
232
|
+
// we must put the search term into the `having` clause, as the search expression
|
|
233
|
+
// is defined on the aggregated result, not on the individual rows
|
|
234
|
+
const usesAggregation =
|
|
235
|
+
inferred.SELECT.groupBy &&
|
|
236
|
+
(searchTerm.args[0].func || searchTerm.args[0].xpr || searchTerm.args[0].list?.some(c => c.func || c.xpr))
|
|
237
|
+
|
|
238
|
+
if (usesAggregation) prop = 'having'
|
|
239
|
+
if (inferred.SELECT[prop]) {
|
|
240
|
+
return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
|
|
247
241
|
} else {
|
|
248
|
-
return
|
|
242
|
+
return { [prop]: [searchTerm] }
|
|
249
243
|
}
|
|
250
244
|
}
|
|
251
245
|
|
|
@@ -438,7 +432,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
438
432
|
return
|
|
439
433
|
}
|
|
440
434
|
|
|
441
|
-
const tableAlias =
|
|
435
|
+
const tableAlias = getTableAlias(col)
|
|
442
436
|
// re-adjust usage of implicit alias in subquery
|
|
443
437
|
if (col.$refLinks[0].definition.kind === 'entity' && col.ref[0] !== tableAlias) {
|
|
444
438
|
col.ref[0] = tableAlias
|
|
@@ -484,7 +478,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
484
478
|
if (replaceWith === -1) transformedColumns.push(transformedColumn)
|
|
485
479
|
else transformedColumns.splice(replaceWith, 1, transformedColumn)
|
|
486
480
|
|
|
487
|
-
setElementOnColumns(transformedColumn,
|
|
481
|
+
setElementOnColumns(transformedColumn, inferred.elements[col.as])
|
|
488
482
|
}
|
|
489
483
|
|
|
490
484
|
function getTransformedColumn(col) {
|
|
@@ -731,7 +725,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
731
725
|
res.push(...getColumnsForWildcard(exclude, replace, col.as))
|
|
732
726
|
} else
|
|
733
727
|
res.push(
|
|
734
|
-
...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias:
|
|
728
|
+
...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
|
|
735
729
|
exclude,
|
|
736
730
|
replace,
|
|
737
731
|
}),
|
|
@@ -812,7 +806,8 @@ function cqn4sql(originalQuery, model) {
|
|
|
812
806
|
|
|
813
807
|
// we need to respect the aliases of the outer query, so the columnAlias might not be suitable
|
|
814
808
|
// as table alias for the correlated subquery
|
|
815
|
-
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias,
|
|
809
|
+
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)
|
|
810
|
+
|
|
816
811
|
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
|
|
817
812
|
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
|
|
818
813
|
const subqueryBase = Object.fromEntries(
|
|
@@ -830,7 +825,10 @@ function cqn4sql(originalQuery, model) {
|
|
|
830
825
|
}
|
|
831
826
|
const expanded = transformSubquery(subquery)
|
|
832
827
|
const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
|
|
833
|
-
Object.defineProperty(correlated, 'elements', {
|
|
828
|
+
Object.defineProperty(correlated, 'elements', {
|
|
829
|
+
value: expanded.elements,
|
|
830
|
+
writable: true,
|
|
831
|
+
})
|
|
834
832
|
return correlated
|
|
835
833
|
|
|
836
834
|
function _correlate(subq, outer) {
|
|
@@ -881,7 +879,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
881
879
|
|
|
882
880
|
// to be attached to dummy query
|
|
883
881
|
const elements = {}
|
|
884
|
-
const expandedColumns = column.expand.
|
|
882
|
+
const expandedColumns = column.expand.flatMap(expand => {
|
|
885
883
|
const fullRef = [...baseRef, ...expand.ref]
|
|
886
884
|
|
|
887
885
|
if (expand.expand) {
|
|
@@ -897,17 +895,16 @@ function cqn4sql(originalQuery, model) {
|
|
|
897
895
|
)
|
|
898
896
|
}
|
|
899
897
|
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
return columnCopy
|
|
898
|
+
const copy = Object.create(groupByRef)
|
|
899
|
+
// always alias for this special case, so that they nested element names match the expected result structure
|
|
900
|
+
// otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
|
|
901
|
+
copy.as = expand.as || expand.ref.at(-1)
|
|
902
|
+
const tableAlias = getTableAlias(copy)
|
|
903
|
+
const res = getFlatColumnsFor(copy, { tableAlias })
|
|
904
|
+
res.forEach(c => {
|
|
905
|
+
elements[c.as || c.ref.at(-1)] = c.element
|
|
906
|
+
})
|
|
907
|
+
return res
|
|
911
908
|
})
|
|
912
909
|
|
|
913
910
|
const SELECT = {
|
|
@@ -938,15 +935,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
938
935
|
if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
|
|
939
936
|
const calcElement = resolveCalculatedElement(col, true)
|
|
940
937
|
res.push(calcElement)
|
|
941
|
-
} else if (col.isJoinRelevant) {
|
|
942
|
-
const tableAlias = getQuerySourceName(col)
|
|
943
|
-
const name = calculateElementName(col)
|
|
944
|
-
const transformedColumn = {
|
|
945
|
-
ref: [tableAlias, name],
|
|
946
|
-
}
|
|
947
|
-
if (col.sort) transformedColumn.sort = col.sort
|
|
948
|
-
if (col.nulls) transformedColumn.nulls = col.nulls
|
|
949
|
-
res.push(transformedColumn)
|
|
950
938
|
} else if (pseudos.elements[col.ref?.[0]]) {
|
|
951
939
|
res.push({ ...col })
|
|
952
940
|
} else if (col.ref) {
|
|
@@ -974,7 +962,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
974
962
|
referredCol.nulls = col.nulls
|
|
975
963
|
col = referredCol
|
|
976
964
|
if (definition.kind === 'element') {
|
|
977
|
-
tableAlias =
|
|
965
|
+
tableAlias = getTableAlias(col)
|
|
978
966
|
} else {
|
|
979
967
|
// we must replace the reference with the underlying expression
|
|
980
968
|
const { val, func, args, xpr } = col
|
|
@@ -986,7 +974,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
986
974
|
}
|
|
987
975
|
}
|
|
988
976
|
} else {
|
|
989
|
-
tableAlias =
|
|
977
|
+
tableAlias = getTableAlias(col) // do not prepend TA if orderBy column addresses element of query
|
|
990
978
|
}
|
|
991
979
|
const leaf = col.$refLinks[col.$refLinks.length - 1].definition
|
|
992
980
|
if (leaf.virtual === true) continue // already in getFlatColumnForElement
|
|
@@ -1004,8 +992,11 @@ function cqn4sql(originalQuery, model) {
|
|
|
1004
992
|
*/
|
|
1005
993
|
if (inOrderBy && flatColumns.length > 1)
|
|
1006
994
|
throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
|
|
1007
|
-
|
|
1008
|
-
|
|
995
|
+
flatColumns.forEach(fc => {
|
|
996
|
+
if (col.nulls) fc.nulls = col.nulls
|
|
997
|
+
if (col.sort) fc.sort = col.sort
|
|
998
|
+
if (fc.as) delete fc.as
|
|
999
|
+
})
|
|
1009
1000
|
res.push(...flatColumns)
|
|
1010
1001
|
} else {
|
|
1011
1002
|
let transformedColumn
|
|
@@ -1051,7 +1042,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1051
1042
|
const last = q.SELECT.from.ref.at(-1)
|
|
1052
1043
|
const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
|
|
1053
1044
|
getLastStringSegment(last.id || last),
|
|
1054
|
-
|
|
1045
|
+
inferred.outerQueries,
|
|
1055
1046
|
)
|
|
1056
1047
|
Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
|
|
1057
1048
|
}
|
|
@@ -1163,7 +1154,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1163
1154
|
if (column.val || column.func || column.SELECT) return [column]
|
|
1164
1155
|
|
|
1165
1156
|
const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
|
|
1166
|
-
let { baseName, columnAlias, tableAlias } = names
|
|
1157
|
+
let { baseName, columnAlias = column.as, tableAlias } = names
|
|
1167
1158
|
const { exclude, replace } = excludeAndReplace || {}
|
|
1168
1159
|
const { $refLinks, flatName, isJoinRelevant } = column
|
|
1169
1160
|
let leafAssoc
|
|
@@ -1206,7 +1197,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1206
1197
|
baseName = getFullName(replacedBy.$refLinks?.[replacedBy.$refLinks.length - 2].definition)
|
|
1207
1198
|
if (replacedBy.isJoinRelevant)
|
|
1208
1199
|
// we need to provide the correct table alias
|
|
1209
|
-
tableAlias =
|
|
1200
|
+
tableAlias = getTableAlias(replacedBy)
|
|
1210
1201
|
|
|
1211
1202
|
if (replacedBy.expand) return [{ as: baseName }]
|
|
1212
1203
|
|
|
@@ -1495,7 +1486,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1495
1486
|
// hence we need to ignore the alias of the `$baseLink`
|
|
1496
1487
|
const lastAssoc =
|
|
1497
1488
|
token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
|
|
1498
|
-
const tableAlias =
|
|
1489
|
+
const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
|
|
1499
1490
|
if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
|
|
1500
1491
|
let name = calculateElementName(token, getFullName)
|
|
1501
1492
|
result.ref = [tableAlias, name]
|
|
@@ -1592,7 +1583,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1592
1583
|
if (!def.$refLinks) return def
|
|
1593
1584
|
const leaf = def.$refLinks[def.$refLinks.length - 1]
|
|
1594
1585
|
const first = def.$refLinks[0]
|
|
1595
|
-
const tableAlias =
|
|
1586
|
+
const tableAlias = getTableAlias(
|
|
1596
1587
|
def,
|
|
1597
1588
|
def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
|
|
1598
1589
|
)
|
|
@@ -1659,7 +1650,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1659
1650
|
transformedFrom.as = from.as
|
|
1660
1651
|
} else {
|
|
1661
1652
|
// select from anonymous query, use artificial alias
|
|
1662
|
-
transformedFrom.as = Object.keys(
|
|
1653
|
+
transformedFrom.as = Object.keys(inferred.sources)[0]
|
|
1663
1654
|
}
|
|
1664
1655
|
return { transformedFrom, transformedWhere: existingWhere }
|
|
1665
1656
|
} else {
|
|
@@ -1702,7 +1693,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1702
1693
|
* --> This is an artificial query, which will later be correlated
|
|
1703
1694
|
* with the main query alias. see @function expandColumn()
|
|
1704
1695
|
*/
|
|
1705
|
-
if (!(
|
|
1696
|
+
if (!(inferred.SELECT?.expand === true)) {
|
|
1706
1697
|
as = getNextAvailableTableAlias(as)
|
|
1707
1698
|
}
|
|
1708
1699
|
nextStepLink.alias = as
|
|
@@ -2162,6 +2153,35 @@ function cqn4sql(originalQuery, model) {
|
|
|
2162
2153
|
return getDefinition(assoc.target) || null
|
|
2163
2154
|
}
|
|
2164
2155
|
|
|
2156
|
+
/**
|
|
2157
|
+
* For a given search expression return a function "search" which holds the search expression
|
|
2158
|
+
* as well as the searchable columns as arguments.
|
|
2159
|
+
*
|
|
2160
|
+
* @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
|
|
2161
|
+
* @param {object} query - The FROM clause of the CQN statement.
|
|
2162
|
+
*
|
|
2163
|
+
* @returns {(Object|null)} returns either:
|
|
2164
|
+
* - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
|
|
2165
|
+
* - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
|
|
2166
|
+
*/
|
|
2167
|
+
function getSearchTerm(search, query) {
|
|
2168
|
+
const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
|
|
2169
|
+
const searchIn = computeColumnsToBeSearched(inferred, entity)
|
|
2170
|
+
if (searchIn.length > 0) {
|
|
2171
|
+
const xpr = search
|
|
2172
|
+
const searchFunc = {
|
|
2173
|
+
func: 'search',
|
|
2174
|
+
args: [
|
|
2175
|
+
searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
|
|
2176
|
+
xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
|
|
2177
|
+
],
|
|
2178
|
+
}
|
|
2179
|
+
return searchFunc
|
|
2180
|
+
} else {
|
|
2181
|
+
return null
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2165
2185
|
/**
|
|
2166
2186
|
* Calculates the name of the source which can be used to address the given node.
|
|
2167
2187
|
*
|
|
@@ -2172,7 +2192,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
2172
2192
|
* the combined elements of the query
|
|
2173
2193
|
* @returns the source name which can be used to address the node
|
|
2174
2194
|
*/
|
|
2175
|
-
function
|
|
2195
|
+
function getTableAlias(node, $baseLink = null) {
|
|
2176
2196
|
if (!node || !node.$refLinks || !node.ref) {
|
|
2177
2197
|
throw new Error('Invalid node')
|
|
2178
2198
|
}
|
|
@@ -2207,11 +2227,11 @@ function cqn4sql(originalQuery, model) {
|
|
|
2207
2227
|
* - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
|
|
2208
2228
|
*/
|
|
2209
2229
|
if (
|
|
2210
|
-
|
|
2211
|
-
!
|
|
2230
|
+
inferred.SELECT?.from.uniqueSubqueryAlias &&
|
|
2231
|
+
!inferred.SELECT?.from.as &&
|
|
2212
2232
|
firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
|
|
2213
2233
|
) {
|
|
2214
|
-
return
|
|
2234
|
+
return inferred.SELECT?.from.uniqueSubqueryAlias
|
|
2215
2235
|
}
|
|
2216
2236
|
return node.ref[0]
|
|
2217
2237
|
}
|
package/lib/infer/index.js
CHANGED
|
@@ -24,7 +24,7 @@ for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
|
|
|
24
24
|
*/
|
|
25
25
|
function infer(originalQuery, model) {
|
|
26
26
|
if (!model) throw new Error('Please specify a model')
|
|
27
|
-
const inferred =
|
|
27
|
+
const inferred = originalQuery
|
|
28
28
|
|
|
29
29
|
// REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
|
|
30
30
|
// e.g. there's a lot of overhead for infer( SELECT.from(Books) )
|
package/lib/search.js
CHANGED
|
@@ -39,7 +39,6 @@ const getColumns = (
|
|
|
39
39
|
|
|
40
40
|
for (const each in elements) {
|
|
41
41
|
const element = elements[each]
|
|
42
|
-
if (element.isAssociation) continue
|
|
43
42
|
if (filterVirtual && element.virtual) continue
|
|
44
43
|
if (removeIgnore && element['@cds.api.ignore']) continue
|
|
45
44
|
if (filterDraft && each in DRAFT_COLUMNS_UNION) continue
|
|
@@ -58,7 +57,7 @@ const _isColumnCalculated = (query, columnName) => {
|
|
|
58
57
|
|
|
59
58
|
const _getSearchableColumns = entity => {
|
|
60
59
|
const columnsOptions = { removeIgnore: true, filterVirtual: true }
|
|
61
|
-
const columns = getColumns(entity, columnsOptions)
|
|
60
|
+
const columns = entity.SELECT?.columns || getColumns(entity, columnsOptions)
|
|
62
61
|
const cdsSearchTerm = '@cds.search'
|
|
63
62
|
const cdsSearchKeys = []
|
|
64
63
|
const cdsSearchColumnMap = new Map()
|
|
@@ -68,30 +67,35 @@ const _getSearchableColumns = entity => {
|
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
let atLeastOneColumnIsSearchable = false
|
|
70
|
+
const deepSearchCandidates = []
|
|
71
71
|
|
|
72
72
|
// build a map of columns annotated with the @cds.search annotation
|
|
73
73
|
for (const key of cdsSearchKeys) {
|
|
74
74
|
const columnName = key.split(cdsSearchTerm + '.').pop()
|
|
75
|
-
|
|
76
|
-
// REVISIT: for now, exclude search using path expression, as deep search is not currently
|
|
77
|
-
// supported
|
|
78
|
-
if (columnName.includes('.')) {
|
|
79
|
-
continue
|
|
80
|
-
}
|
|
81
|
-
|
|
82
75
|
const annotationKey = `${cdsSearchTerm}.${columnName}`
|
|
83
76
|
const annotationValue = entity[annotationKey]
|
|
84
77
|
if (annotationValue) atLeastOneColumnIsSearchable = true
|
|
78
|
+
|
|
79
|
+
const column = entity.elements[columnName]
|
|
80
|
+
if (column?.isAssociation || columnName.includes('.')) {
|
|
81
|
+
deepSearchCandidates.push({ ref: columnName.split('.') })
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
85
84
|
cdsSearchColumnMap.set(columnName, annotationValue)
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
const searchableColumns = columns.filter(column => {
|
|
89
88
|
const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
|
|
89
|
+
const elementName = column.as || column.ref?.at(-1) || column.name
|
|
90
|
+
const element = entity.elements[elementName]
|
|
90
91
|
|
|
91
92
|
// the element is searchable if it is annotated with the @cds.search, e.g.:
|
|
92
93
|
// `@cds.search { element1: true }` or `@cds.search { element1 }`
|
|
93
94
|
if (annotatedColumnValue) return true
|
|
94
95
|
|
|
96
|
+
// calculated elements are only searchable if requested through `@cds.search`
|
|
97
|
+
if(column.value) return false
|
|
98
|
+
|
|
95
99
|
// if at least one element is explicitly annotated as searchable, e.g.:
|
|
96
100
|
// `@cds.search { element1: true }` or `@cds.search { element1 }`
|
|
97
101
|
// and it is not the current column name, then it must be excluded from the search
|
|
@@ -101,32 +105,48 @@ const _getSearchableColumns = entity => {
|
|
|
101
105
|
// if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
|
|
102
106
|
return (
|
|
103
107
|
annotatedColumnValue === undefined &&
|
|
104
|
-
|
|
108
|
+
element?.type === DEFAULT_SEARCHABLE_TYPE &&
|
|
105
109
|
!_isColumnCalculated(entity?.query, column.name)
|
|
106
110
|
)
|
|
107
111
|
})
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
if (deepSearchCandidates.length) {
|
|
114
|
+
deepSearchCandidates.forEach(c => {
|
|
115
|
+
const element = c.ref.reduce((resolveIn, curr, i) => {
|
|
116
|
+
const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
|
|
117
|
+
if (next.isAssociation && !c.ref[i + 1]) {
|
|
118
|
+
const searchInTarget = _getSearchableColumns(next._target)
|
|
119
|
+
searchInTarget.forEach(elementRefInTarget => {
|
|
120
|
+
searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
return next
|
|
124
|
+
}, entity)
|
|
125
|
+
if (element?.type === DEFAULT_SEARCHABLE_TYPE) {
|
|
126
|
+
searchableColumns.push({ ref: c.ref })
|
|
127
|
+
}
|
|
128
|
+
})
|
|
115
129
|
}
|
|
116
130
|
|
|
117
|
-
return searchableColumns.map(column =>
|
|
131
|
+
return searchableColumns.map(column => {
|
|
132
|
+
if(column.ref)
|
|
133
|
+
return column
|
|
134
|
+
return { ref: [ column.name ] }
|
|
135
|
+
})
|
|
118
136
|
}
|
|
119
137
|
|
|
120
138
|
/**
|
|
121
139
|
* @returns {Array<object>} - array of columns
|
|
122
140
|
*/
|
|
123
|
-
const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }
|
|
141
|
+
const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }) => {
|
|
124
142
|
let toBeSearched = []
|
|
125
143
|
|
|
126
144
|
// aggregations case
|
|
127
145
|
// in the new parser groupBy is moved to sub select.
|
|
128
146
|
if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
|
|
129
147
|
cqn.SELECT.columns?.forEach(column => {
|
|
148
|
+
const elementName = column.as || column.ref?.at(-1) || column.name
|
|
149
|
+
const element = cqn.elements[elementName]
|
|
130
150
|
if (column.func || column.xpr) {
|
|
131
151
|
// exclude $count by SELECT of number of Items in a Collection
|
|
132
152
|
if (
|
|
@@ -138,7 +158,7 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
|
|
|
138
158
|
}
|
|
139
159
|
|
|
140
160
|
// only strings can be searched
|
|
141
|
-
if (
|
|
161
|
+
if (element?.type !== DEFAULT_SEARCHABLE_TYPE) {
|
|
142
162
|
if (column.xpr) return
|
|
143
163
|
if (column.func && !(column.func in aggregateFunctions)) return
|
|
144
164
|
}
|
|
@@ -156,18 +176,22 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
|
|
|
156
176
|
|
|
157
177
|
// no need to set ref[0] to alias, because columns were already properly transformed
|
|
158
178
|
if (column.ref) {
|
|
159
|
-
if (
|
|
179
|
+
if (element?.type !== DEFAULT_SEARCHABLE_TYPE) return
|
|
160
180
|
column = { ref: [...column.ref] }
|
|
161
181
|
toBeSearched.push(column)
|
|
162
182
|
return
|
|
163
183
|
}
|
|
164
184
|
})
|
|
165
185
|
} else {
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
if(entity.kind === 'entity') {
|
|
187
|
+
// first check cache
|
|
188
|
+
toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
|
|
189
|
+
} else {
|
|
190
|
+
// if we search on a subquery, we don't have a cache
|
|
191
|
+
toBeSearched = _getSearchableColumns(entity)
|
|
192
|
+
}
|
|
168
193
|
toBeSearched = toBeSearched.map(c => {
|
|
169
|
-
const column = {
|
|
170
|
-
if (alias) column.ref.unshift(alias)
|
|
194
|
+
const column = {ref: [...c.ref]}
|
|
171
195
|
return column
|
|
172
196
|
})
|
|
173
197
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/db-service",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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": {
|
|
@@ -24,6 +24,9 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "jest --silent"
|
|
26
26
|
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"generic-pool": "^3.9.0"
|
|
29
|
+
},
|
|
27
30
|
"peerDependencies": {
|
|
28
31
|
"@sap/cds": ">=7.9"
|
|
29
32
|
},
|