@cap-js/db-service 1.10.2 → 1.11.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 +23 -0
- package/lib/SQLService.js +2 -0
- package/lib/cql-functions.js +2 -0
- package/lib/cqn2sql.js +12 -33
- package/lib/cqn4sql.js +176 -68
- package/lib/infer/index.js +7 -3
- package/lib/search.js +48 -24
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,29 @@
|
|
|
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.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* **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))
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
* `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))
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* 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))
|
|
21
|
+
|
|
22
|
+
## [1.10.3](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.2...db-service-v1.10.3) (2024-07-05)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
* rewrite assoc chains if intermediate assoc is not fk ([#715](https://github.com/cap-js/cds-dbs/issues/715)) ([3873f9a](https://github.com/cap-js/cds-dbs/commit/3873f9adce3ff26cafb2b18b9d2115758b0f0830))
|
|
28
|
+
* Support expand with group by clause ([#721](https://github.com/cap-js/cds-dbs/issues/721)) ([90c9e6a](https://github.com/cap-js/cds-dbs/commit/90c9e6a4da9d4a3451ec0ed60dd0815c04600134))
|
|
29
|
+
|
|
7
30
|
## [1.10.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.1...db-service-v1.10.2) (2024-06-25)
|
|
8
31
|
|
|
9
32
|
|
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
|
}
|
package/lib/cql-functions.js
CHANGED
|
@@ -22,6 +22,8 @@ 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
|
+
arg.val = arg.__proto__.val = arg.val.split(' ')[0].replace(/"/g, '')
|
|
25
27
|
const refs = ref.list || [ref],
|
|
26
28
|
{ toString } = ref
|
|
27
29
|
return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
|
package/lib/cqn2sql.js
CHANGED
|
@@ -222,6 +222,7 @@ class CQN2SQLRenderer {
|
|
|
222
222
|
if (distinct) sql += ` DISTINCT`
|
|
223
223
|
if (!_empty(columns)) sql += ` ${columns}`
|
|
224
224
|
if (!_empty(from)) sql += ` FROM ${this.from(from)}`
|
|
225
|
+
else sql += this.from_dummy()
|
|
225
226
|
if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
|
|
226
227
|
if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
|
|
227
228
|
if (!_empty(having)) sql += ` HAVING ${this.having(having)}`
|
|
@@ -310,12 +311,7 @@ class CQN2SQLRenderer {
|
|
|
310
311
|
*/
|
|
311
312
|
column_expr(x, q) {
|
|
312
313
|
if (x === '*') return '*'
|
|
313
|
-
|
|
314
|
-
// REVISIT: that should move out of here!
|
|
315
|
-
if (x?.element?.['@cds.extension']) {
|
|
316
|
-
return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
|
|
317
|
-
}
|
|
318
|
-
///////////////////////////////////////////////////////////////////////////////////////
|
|
314
|
+
|
|
319
315
|
let sql = this.expr({ param: false, __proto__: x })
|
|
320
316
|
let alias = this.column_alias4(x, q)
|
|
321
317
|
if (alias) sql += ' as ' + this.quote(alias)
|
|
@@ -351,6 +347,14 @@ class CQN2SQLRenderer {
|
|
|
351
347
|
return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
|
|
352
348
|
}
|
|
353
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Renders a FROM clause for when the query does not have a target
|
|
352
|
+
* @returns {string} SQL
|
|
353
|
+
*/
|
|
354
|
+
from_dummy() {
|
|
355
|
+
return ''
|
|
356
|
+
}
|
|
357
|
+
|
|
354
358
|
/**
|
|
355
359
|
* Renders a FROM clause into generic SQL
|
|
356
360
|
* @param {import('./infer/cqn').ref['ref'][0]['args']} args
|
|
@@ -480,7 +484,7 @@ class CQN2SQLRenderer {
|
|
|
480
484
|
: ObjectKeys(INSERT.entries[0])
|
|
481
485
|
|
|
482
486
|
/** @type {string[]} */
|
|
483
|
-
this.columns = columns
|
|
487
|
+
this.columns = columns
|
|
484
488
|
|
|
485
489
|
if (!elements) {
|
|
486
490
|
this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
|
|
@@ -493,24 +497,7 @@ class CQN2SQLRenderer {
|
|
|
493
497
|
elements,
|
|
494
498
|
!!q.UPSERT,
|
|
495
499
|
)
|
|
496
|
-
const extraction = extractions
|
|
497
|
-
.map(c => {
|
|
498
|
-
const element = elements?.[c.name]
|
|
499
|
-
if (element?.['@cds.extension']) {
|
|
500
|
-
return false
|
|
501
|
-
}
|
|
502
|
-
if (c.name === 'extensions__') {
|
|
503
|
-
const merges = extractions.filter(c => elements?.[c.name]?.['@cds.extension'])
|
|
504
|
-
if (merges.length) {
|
|
505
|
-
c.sql = `json_set(ifnull(${c.sql},'{}'),${merges.map(
|
|
506
|
-
c => this.string('$."' + c.name + '"') + ',' + c.sql,
|
|
507
|
-
)})`
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
return c
|
|
511
|
-
})
|
|
512
|
-
.filter(a => a)
|
|
513
|
-
.map(c => c.sql)
|
|
500
|
+
const extraction = extractions.map(c => c.sql)
|
|
514
501
|
|
|
515
502
|
// Include this.values for placeholders
|
|
516
503
|
/** @type {unknown[][]} */
|
|
@@ -783,14 +770,6 @@ class CQN2SQLRenderer {
|
|
|
783
770
|
}
|
|
784
771
|
}
|
|
785
772
|
|
|
786
|
-
columns = columns.map(c => {
|
|
787
|
-
if (q.elements?.[c.name]?.['@cds.extension']) return {
|
|
788
|
-
name: 'extensions__',
|
|
789
|
-
sql: `jsonb_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
|
|
790
|
-
}
|
|
791
|
-
return c
|
|
792
|
-
})
|
|
793
|
-
|
|
794
773
|
const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
|
|
795
774
|
|
|
796
775
|
sql += ` SET ${extraction}`
|
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
|
|
|
@@ -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) {
|
|
@@ -799,9 +793,20 @@ function cqn4sql(originalQuery, model) {
|
|
|
799
793
|
? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
|
|
800
794
|
: column.ref.map(idOnly).join('_'))
|
|
801
795
|
|
|
796
|
+
// if there is a group by on the main query, all
|
|
797
|
+
// columns of the expand must be in the groupBy
|
|
798
|
+
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
|
|
803
|
+
|
|
804
|
+
return _subqueryForGroupBy(column, baseRef, columnAlias)
|
|
805
|
+
}
|
|
806
|
+
|
|
802
807
|
// we need to respect the aliases of the outer query, so the columnAlias might not be suitable
|
|
803
808
|
// as table alias for the correlated subquery
|
|
804
|
-
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias,
|
|
809
|
+
const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)
|
|
805
810
|
|
|
806
811
|
// `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
|
|
807
812
|
const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
|
|
@@ -815,12 +820,15 @@ function cqn4sql(originalQuery, model) {
|
|
|
815
820
|
from,
|
|
816
821
|
columns: JSON.parse(JSON.stringify(column.expand)),
|
|
817
822
|
expand: true,
|
|
818
|
-
one: column.$refLinks
|
|
823
|
+
one: column.$refLinks.at(-1).definition.is2one,
|
|
819
824
|
},
|
|
820
825
|
}
|
|
821
826
|
const expanded = transformSubquery(subquery)
|
|
822
827
|
const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
|
|
823
|
-
Object.defineProperty(correlated, 'elements', {
|
|
828
|
+
Object.defineProperty(correlated, 'elements', {
|
|
829
|
+
value: expanded.elements,
|
|
830
|
+
writable: true,
|
|
831
|
+
})
|
|
824
832
|
return correlated
|
|
825
833
|
|
|
826
834
|
function _correlate(subq, outer) {
|
|
@@ -851,6 +859,72 @@ function cqn4sql(originalQuery, model) {
|
|
|
851
859
|
}
|
|
852
860
|
return subq
|
|
853
861
|
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Generates a special subquery for the `expand` of the `column`.
|
|
865
|
+
* All columns in the `expand` must be part of the GROUP BY clause of the main query.
|
|
866
|
+
* If this is the case, the subqueries columns match the corresponding references of the group by.
|
|
867
|
+
* Nested expands are also supported.
|
|
868
|
+
*
|
|
869
|
+
* @param {Object} column - To expand.
|
|
870
|
+
* @param {Array} baseRef - The base reference for the expanded column.
|
|
871
|
+
* @param {string} subqueryAlias - The alias of the `expand` subquery column.
|
|
872
|
+
* @returns {Object} - The subquery object.
|
|
873
|
+
* @throws {Error} - If one of the `ref`s in the `column.expand` is not part of the GROUP BY clause.
|
|
874
|
+
*/
|
|
875
|
+
function _subqueryForGroupBy(column, baseRef, subqueryAlias) {
|
|
876
|
+
const groupByLookup = new Map(
|
|
877
|
+
transformedQuery.SELECT.groupBy.map(c => [c.ref && c.ref.map(refWithConditions).join('.'), c]),
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
// to be attached to dummy query
|
|
881
|
+
const elements = {}
|
|
882
|
+
const expandedColumns = column.expand.flatMap(expand => {
|
|
883
|
+
const fullRef = [...baseRef, ...expand.ref]
|
|
884
|
+
|
|
885
|
+
if (expand.expand) {
|
|
886
|
+
const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
|
|
887
|
+
elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
|
|
888
|
+
return nested
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
|
|
892
|
+
if (!groupByRef) {
|
|
893
|
+
throw new Error(
|
|
894
|
+
`The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const columnCopy = Object.create(groupByRef)
|
|
899
|
+
if (expand.as) {
|
|
900
|
+
columnCopy.as = expand.as
|
|
901
|
+
}
|
|
902
|
+
const res = getFlatColumnsFor(columnCopy, { tableAlias: getQuerySourceName(columnCopy) })
|
|
903
|
+
res.forEach(c => {
|
|
904
|
+
elements[c.as || c.ref.at(-1)] = c.element
|
|
905
|
+
})
|
|
906
|
+
return res
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
const SELECT = {
|
|
910
|
+
from: null,
|
|
911
|
+
columns: expandedColumns,
|
|
912
|
+
}
|
|
913
|
+
return Object.defineProperties(
|
|
914
|
+
{},
|
|
915
|
+
{
|
|
916
|
+
SELECT: {
|
|
917
|
+
value: Object.defineProperties(SELECT, {
|
|
918
|
+
expand: { value: true, writable: true }, // non-enumerable
|
|
919
|
+
one: { value: column.$refLinks.at(-1).definition.is2one, writable: true }, // non-enumerable
|
|
920
|
+
}),
|
|
921
|
+
enumerable: true,
|
|
922
|
+
},
|
|
923
|
+
as: { value: subqueryAlias, enumerable: true, writable: true },
|
|
924
|
+
elements: { value: elements }, // non-enumerable
|
|
925
|
+
},
|
|
926
|
+
)
|
|
927
|
+
}
|
|
854
928
|
}
|
|
855
929
|
|
|
856
930
|
function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
|
|
@@ -860,15 +934,6 @@ function cqn4sql(originalQuery, model) {
|
|
|
860
934
|
if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
|
|
861
935
|
const calcElement = resolveCalculatedElement(col, true)
|
|
862
936
|
res.push(calcElement)
|
|
863
|
-
} else if (col.isJoinRelevant) {
|
|
864
|
-
const tableAlias = getQuerySourceName(col)
|
|
865
|
-
const name = calculateElementName(col)
|
|
866
|
-
const transformedColumn = {
|
|
867
|
-
ref: [tableAlias, name],
|
|
868
|
-
}
|
|
869
|
-
if (col.sort) transformedColumn.sort = col.sort
|
|
870
|
-
if (col.nulls) transformedColumn.nulls = col.nulls
|
|
871
|
-
res.push(transformedColumn)
|
|
872
937
|
} else if (pseudos.elements[col.ref?.[0]]) {
|
|
873
938
|
res.push({ ...col })
|
|
874
939
|
} else if (col.ref) {
|
|
@@ -926,8 +991,14 @@ function cqn4sql(originalQuery, model) {
|
|
|
926
991
|
*/
|
|
927
992
|
if (inOrderBy && flatColumns.length > 1)
|
|
928
993
|
throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
|
|
929
|
-
|
|
930
|
-
|
|
994
|
+
flatColumns.forEach(fc => {
|
|
995
|
+
if (col.nulls)
|
|
996
|
+
fc.nulls = col.nulls
|
|
997
|
+
if (col.sort)
|
|
998
|
+
fc.sort = col.sort
|
|
999
|
+
if (fc.as)
|
|
1000
|
+
delete fc.as
|
|
1001
|
+
})
|
|
931
1002
|
res.push(...flatColumns)
|
|
932
1003
|
} else {
|
|
933
1004
|
let transformedColumn
|
|
@@ -973,7 +1044,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
973
1044
|
const last = q.SELECT.from.ref.at(-1)
|
|
974
1045
|
const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
|
|
975
1046
|
getLastStringSegment(last.id || last),
|
|
976
|
-
|
|
1047
|
+
inferred.outerQueries,
|
|
977
1048
|
)
|
|
978
1049
|
Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
|
|
979
1050
|
}
|
|
@@ -1573,7 +1644,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1573
1644
|
transformedFrom.args.push(f)
|
|
1574
1645
|
}
|
|
1575
1646
|
})
|
|
1576
|
-
return { transformedFrom }
|
|
1647
|
+
return { transformedFrom, transformedWhere: existingWhere }
|
|
1577
1648
|
} else if (from.SELECT) {
|
|
1578
1649
|
transformedFrom = transformSubquery(from)
|
|
1579
1650
|
if (from.as) {
|
|
@@ -1581,9 +1652,9 @@ function cqn4sql(originalQuery, model) {
|
|
|
1581
1652
|
transformedFrom.as = from.as
|
|
1582
1653
|
} else {
|
|
1583
1654
|
// select from anonymous query, use artificial alias
|
|
1584
|
-
transformedFrom.as = Object.keys(
|
|
1655
|
+
transformedFrom.as = Object.keys(inferred.sources)[0]
|
|
1585
1656
|
}
|
|
1586
|
-
return { transformedFrom }
|
|
1657
|
+
return { transformedFrom, transformedWhere: existingWhere }
|
|
1587
1658
|
} else {
|
|
1588
1659
|
return _transformFrom()
|
|
1589
1660
|
}
|
|
@@ -1624,7 +1695,7 @@ function cqn4sql(originalQuery, model) {
|
|
|
1624
1695
|
* --> This is an artificial query, which will later be correlated
|
|
1625
1696
|
* with the main query alias. see @function expandColumn()
|
|
1626
1697
|
*/
|
|
1627
|
-
if (!(
|
|
1698
|
+
if (!(inferred.SELECT?.expand === true)) {
|
|
1628
1699
|
as = getNextAvailableTableAlias(as)
|
|
1629
1700
|
}
|
|
1630
1701
|
nextStepLink.alias = as
|
|
@@ -2084,6 +2155,35 @@ function cqn4sql(originalQuery, model) {
|
|
|
2084
2155
|
return getDefinition(assoc.target) || null
|
|
2085
2156
|
}
|
|
2086
2157
|
|
|
2158
|
+
/**
|
|
2159
|
+
* For a given search expression return a function "search" which holds the search expression
|
|
2160
|
+
* as well as the searchable columns as arguments.
|
|
2161
|
+
*
|
|
2162
|
+
* @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
|
|
2163
|
+
* @param {object} query - The FROM clause of the CQN statement.
|
|
2164
|
+
*
|
|
2165
|
+
* @returns {(Object|null)} returns either:
|
|
2166
|
+
* - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
|
|
2167
|
+
* - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
|
|
2168
|
+
*/
|
|
2169
|
+
function getSearchTerm(search, query) {
|
|
2170
|
+
const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
|
|
2171
|
+
const searchIn = computeColumnsToBeSearched(inferred, entity)
|
|
2172
|
+
if (searchIn.length > 0) {
|
|
2173
|
+
const xpr = search
|
|
2174
|
+
const searchFunc = {
|
|
2175
|
+
func: 'search',
|
|
2176
|
+
args: [
|
|
2177
|
+
searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
|
|
2178
|
+
xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
|
|
2179
|
+
],
|
|
2180
|
+
}
|
|
2181
|
+
return searchFunc
|
|
2182
|
+
} else {
|
|
2183
|
+
return null
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2087
2187
|
/**
|
|
2088
2188
|
* Calculates the name of the source which can be used to address the given node.
|
|
2089
2189
|
*
|
|
@@ -2129,11 +2229,11 @@ function cqn4sql(originalQuery, model) {
|
|
|
2129
2229
|
* - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
|
|
2130
2230
|
*/
|
|
2131
2231
|
if (
|
|
2132
|
-
|
|
2133
|
-
!
|
|
2232
|
+
inferred.SELECT?.from.uniqueSubqueryAlias &&
|
|
2233
|
+
!inferred.SELECT?.from.as &&
|
|
2134
2234
|
firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
|
|
2135
2235
|
) {
|
|
2136
|
-
return
|
|
2236
|
+
return inferred.SELECT?.from.uniqueSubqueryAlias
|
|
2137
2237
|
}
|
|
2138
2238
|
return node.ref[0]
|
|
2139
2239
|
}
|
|
@@ -2251,4 +2351,12 @@ function setElementOnColumns(col, element) {
|
|
|
2251
2351
|
|
|
2252
2352
|
const getName = col => col.as || col.ref?.at(-1)
|
|
2253
2353
|
const idOnly = ref => ref.id || ref
|
|
2354
|
+
const refWithConditions = step => {
|
|
2355
|
+
let appendix
|
|
2356
|
+
const { args, where } = step
|
|
2357
|
+
if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
|
|
2358
|
+
else if (where) appendix = JSON.stringify(where)
|
|
2359
|
+
else if (args) appendix = JSON.stringify(args)
|
|
2360
|
+
return appendix ? step.id + appendix : step
|
|
2361
|
+
}
|
|
2254
2362
|
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
|
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) )
|
|
@@ -1013,9 +1013,11 @@ function infer(originalQuery, model) {
|
|
|
1013
1013
|
}
|
|
1014
1014
|
return true
|
|
1015
1015
|
}
|
|
1016
|
-
if (assoc
|
|
1016
|
+
if (assoc) {
|
|
1017
1017
|
// foreign key access without filters never join relevant
|
|
1018
|
-
return false
|
|
1018
|
+
if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
|
|
1019
|
+
// <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
|
|
1020
|
+
return true
|
|
1019
1021
|
}
|
|
1020
1022
|
if (link.definition.target && link.definition.keys) {
|
|
1021
1023
|
if (column.ref[i + 1] || assoc) fkAccess = false
|
|
@@ -1198,6 +1200,8 @@ function infer(originalQuery, model) {
|
|
|
1198
1200
|
firstStepIsEntity = true
|
|
1199
1201
|
if (arg.$refLinks.length === 1) return `${cur.definition.name}`
|
|
1200
1202
|
return `${cur.definition.name}`
|
|
1203
|
+
} else if (cur.definition.SELECT) {
|
|
1204
|
+
return `${cur.definition.as}`
|
|
1201
1205
|
}
|
|
1202
1206
|
const dot = i === 1 && firstStepIsEntity ? ':' : '.' // divide with colon if first step is entity
|
|
1203
1207
|
return res !== '' ? res + dot + cur.definition.name : cur.definition.name
|
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