@cap-js/db-service 1.3.2 → 1.4.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 +18 -0
- package/lib/common/DatabaseService.js +22 -7
- package/lib/cql-functions.js +6 -6
- package/lib/cqn2sql.js +4 -2
- package/lib/cqn4sql.js +41 -2
- package/lib/infer/index.js +36 -22
- package/lib/search.js +164 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@
|
|
|
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.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.2...db-service-v1.4.0) (2023-11-20)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* **`UPDATE`/`DELETE`:** Enable path expressions for improved data manipulation ([#325](https://github.com/cap-js/cds-dbs/issues/325)) ([94f0776](https://github.com/cap-js/cds-dbs/commit/94f077661cffad8f137dc692a2cb9b0ae5e4d75b))
|
|
13
|
+
* **temporal data:** add time slice key to conflict clause ([#249](https://github.com/cap-js/cds-dbs/issues/249)) ([67b8edf](https://github.com/cap-js/cds-dbs/commit/67b8edf9b7f6b0fbab0010d7c93ed03a01e103ed))
|
|
14
|
+
* use place holders for update and delete ([#323](https://github.com/cap-js/cds-dbs/issues/323)) ([81472b9](https://github.com/cap-js/cds-dbs/commit/81472b971183f701e401247611310be56745a87a))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
* align time function behavior ([#322](https://github.com/cap-js/cds-dbs/issues/322)) ([c3ab40a](https://github.com/cap-js/cds-dbs/commit/c3ab40a007c105465349dd2f612178367b8e713a))
|
|
20
|
+
* **calculated elements:** path expressions in `func.args` within `xpr` ([#321](https://github.com/cap-js/cds-dbs/issues/321)) ([cee25e3](https://github.com/cap-js/cds-dbs/commit/cee25e33cf289592a87779cfa34dddc53e467676))
|
|
21
|
+
* Disconnect db service on shutdown ([#327](https://github.com/cap-js/cds-dbs/issues/327)) ([8471bda](https://github.com/cap-js/cds-dbs/commit/8471bda44fc030205abec45b1581b2cf6ed7c800))
|
|
22
|
+
* non-fk access in filter conditions are properly rejected ([#336](https://github.com/cap-js/cds-dbs/issues/336)) ([4c948fe](https://github.com/cap-js/cds-dbs/commit/4c948fecead1de562e1583886516413e131a39aa))
|
|
23
|
+
* **search:** check calculated columns at any depth ([#310](https://github.com/cap-js/cds-dbs/issues/310)) ([8fd6153](https://github.com/cap-js/cds-dbs/commit/8fd6153dfcd472a6d95c33faa58c4b3f96f485df))
|
|
24
|
+
|
|
7
25
|
## [1.3.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.1...db-service-v1.3.2) (2023-10-13)
|
|
8
26
|
|
|
9
27
|
|
|
@@ -6,10 +6,16 @@ const cds = require('@sap/cds/lib')
|
|
|
6
6
|
/** @typedef {unknown} DatabaseDriver */
|
|
7
7
|
|
|
8
8
|
class DatabaseService extends cds.Service {
|
|
9
|
+
|
|
10
|
+
init() {
|
|
11
|
+
cds.on('shutdown', () => this.disconnect())
|
|
12
|
+
return super.init()
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
/**
|
|
10
16
|
* Dictionary of connection pools per tenant
|
|
11
17
|
*/
|
|
12
|
-
pools = { _factory: this.factory }
|
|
18
|
+
pools = Object.setPrototypeOf({}, { _factory: this.factory })
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Return a pool factory + options property as expected by
|
|
@@ -44,7 +50,9 @@ class DatabaseService extends cds.Service {
|
|
|
44
50
|
async begin() {
|
|
45
51
|
// We expect tx.begin() being called for an txed db service
|
|
46
52
|
const ctx = this.context
|
|
47
|
-
|
|
53
|
+
|
|
54
|
+
// If .begin is called explicitly it starts a new transaction and executes begin
|
|
55
|
+
if (!ctx) return this.tx().begin()
|
|
48
56
|
|
|
49
57
|
// REVISIT: tenant should be undefined if !this.isMultitenant
|
|
50
58
|
let isMultitenant = 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
|
|
@@ -111,11 +119,18 @@ class DatabaseService extends cds.Service {
|
|
|
111
119
|
* @param {string} tenant
|
|
112
120
|
*/
|
|
113
121
|
async disconnect(tenant) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
const _disconnect = async tenant => {
|
|
123
|
+
const pool = this.pools[tenant]
|
|
124
|
+
if (!pool) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
await pool.drain()
|
|
128
|
+
await pool.clear()
|
|
129
|
+
delete this.pools[tenant]
|
|
130
|
+
}
|
|
131
|
+
if (tenant == null)
|
|
132
|
+
return Promise.all(Object.keys(this.pools).map(_disconnect))
|
|
133
|
+
return _disconnect(tenant)
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
/**
|
package/lib/cql-functions.js
CHANGED
|
@@ -149,37 +149,37 @@ const StandardFunctions = {
|
|
|
149
149
|
* Generates SQL statement that produces the year of a given timestamp
|
|
150
150
|
* @param {string} x
|
|
151
151
|
* @returns {string}
|
|
152
|
-
|
|
152
|
+
* /
|
|
153
153
|
year: x => `cast( strftime('%Y',${x}) as Integer )`,
|
|
154
154
|
/**
|
|
155
155
|
* Generates SQL statement that produces the month of a given timestamp
|
|
156
156
|
* @param {string} x
|
|
157
157
|
* @returns {string}
|
|
158
|
-
|
|
158
|
+
* /
|
|
159
159
|
month: x => `cast( strftime('%m',${x}) as Integer )`,
|
|
160
160
|
/**
|
|
161
161
|
* Generates SQL statement that produces the day of a given timestamp
|
|
162
162
|
* @param {string} x
|
|
163
163
|
* @returns {string}
|
|
164
|
-
|
|
164
|
+
* /
|
|
165
165
|
day: x => `cast( strftime('%d',${x}) as Integer )`,
|
|
166
166
|
/**
|
|
167
167
|
* Generates SQL statement that produces the hours of a given timestamp
|
|
168
168
|
* @param {string} x
|
|
169
169
|
* @returns {string}
|
|
170
|
-
|
|
170
|
+
* /
|
|
171
171
|
hour: x => `cast( strftime('%H',${x}) as Integer )`,
|
|
172
172
|
/**
|
|
173
173
|
* Generates SQL statement that produces the minutes of a given timestamp
|
|
174
174
|
* @param {string} x
|
|
175
175
|
* @returns {string}
|
|
176
|
-
|
|
176
|
+
* /
|
|
177
177
|
minute: x => `cast( strftime('%M',${x}) as Integer )`,
|
|
178
178
|
/**
|
|
179
179
|
* Generates SQL statement that produces the seconds of a given timestamp
|
|
180
180
|
* @param {string} x
|
|
181
181
|
* @returns {string}
|
|
182
|
-
|
|
182
|
+
* /
|
|
183
183
|
second: x => `cast( strftime('%S',${x}) as Integer )`,
|
|
184
184
|
|
|
185
185
|
/**
|
package/lib/cqn2sql.js
CHANGED
|
@@ -70,7 +70,7 @@ class CQN2SQLRenderer {
|
|
|
70
70
|
/** @type {unknown[]} */
|
|
71
71
|
this.values = [] // prepare values, filled in by subroutines
|
|
72
72
|
this[cmd]((this.cqn = q)) // actual sql rendering happens here
|
|
73
|
-
if (vars?.length && !this.values
|
|
73
|
+
if (vars?.length && !this.values?.length) this.values = vars
|
|
74
74
|
const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
|
|
75
75
|
DEBUG?.(
|
|
76
76
|
this.sql,
|
|
@@ -527,6 +527,9 @@ class CQN2SQLRenderer {
|
|
|
527
527
|
.filter(c => !keys.includes(c))
|
|
528
528
|
.map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
|
|
529
529
|
|
|
530
|
+
// temporal data
|
|
531
|
+
keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
|
|
532
|
+
|
|
530
533
|
keys = keys.map(k => this.quote(k))
|
|
531
534
|
const conflict = updateColumns.length
|
|
532
535
|
? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
|
|
@@ -872,7 +875,6 @@ class CQN2SQLRenderer {
|
|
|
872
875
|
|
|
873
876
|
let val = _managed[element[annotation]?.['=']]
|
|
874
877
|
if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
|
|
875
|
-
|
|
876
878
|
else if (!isUpdate && element.default) {
|
|
877
879
|
const d = element.default
|
|
878
880
|
if (d.val !== undefined || d.ref?.[0] === '$now') {
|
package/lib/cqn4sql.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const cds = require('@sap/cds/lib')
|
|
4
|
-
const { computeColumnsToBeSearched } = require('
|
|
4
|
+
const { computeColumnsToBeSearched } = require('./search')
|
|
5
5
|
|
|
6
6
|
const infer = require('./infer')
|
|
7
7
|
|
|
@@ -96,7 +96,46 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
if (queryNeedsJoins) {
|
|
99
|
-
|
|
99
|
+
if (inferred.UPDATE || inferred.DELETE) {
|
|
100
|
+
const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
|
|
101
|
+
const subquery = {
|
|
102
|
+
SELECT: {
|
|
103
|
+
from: { ...transformedFrom },
|
|
104
|
+
columns: [], // primary keys of the query target will be added later
|
|
105
|
+
where: [...transformedProp.where],
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
// The alias of the original query is now the alias for the subquery
|
|
109
|
+
// so that potential references in the where clause to the alias match.
|
|
110
|
+
// Hence, replace the alias of the original query with the next
|
|
111
|
+
// available alias, so that each alias is unique.
|
|
112
|
+
const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
|
|
113
|
+
transformedFrom.as = uniqueSubqueryAlias
|
|
114
|
+
|
|
115
|
+
// calculate the primary keys of the target entity, there is always exactly
|
|
116
|
+
// one query source for UPDATE / DELETE
|
|
117
|
+
const queryTarget = Object.values(originalQuery.sources)[0]
|
|
118
|
+
const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
|
|
119
|
+
const primaryKey = { list: [] }
|
|
120
|
+
keys.forEach(k => {
|
|
121
|
+
// cqn4sql will add the table alias to the column later, no need to add it here
|
|
122
|
+
subquery.SELECT.columns.push({ ref: [k.name] })
|
|
123
|
+
|
|
124
|
+
// add the alias of the main query to the list of primary key references
|
|
125
|
+
primaryKey.list.push({ ref: [transformedFrom.as, k.name] })
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const transformedSubquery = cqn4sql(subquery)
|
|
129
|
+
|
|
130
|
+
// replace where condition of original query with the transformed subquery
|
|
131
|
+
// correlate UPDATE / DELETE query with subquery by primary key matches
|
|
132
|
+
transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
|
|
133
|
+
|
|
134
|
+
if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
|
|
135
|
+
else transformedQuery.DELETE.from = transformedFrom
|
|
136
|
+
} else {
|
|
137
|
+
transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
|
|
138
|
+
}
|
|
100
139
|
}
|
|
101
140
|
}
|
|
102
141
|
}
|
package/lib/infer/index.js
CHANGED
|
@@ -499,20 +499,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
499
499
|
const elements = definition.elements || definition._target?.elements
|
|
500
500
|
if (elements && id in elements) {
|
|
501
501
|
const element = elements[id]
|
|
502
|
-
|
|
503
|
-
// only fk access in infix filter
|
|
504
|
-
const nextStep = column.ref[1]?.id || column.ref[1]
|
|
505
|
-
// no unmanaged assoc in infix filter path
|
|
506
|
-
if (!inExists && element.on)
|
|
507
|
-
throw new Error(
|
|
508
|
-
`"${element.name}" in path "${column.ref
|
|
509
|
-
.map(idOnly)
|
|
510
|
-
.join('.')}" must not be an unmanaged association`,
|
|
511
|
-
)
|
|
512
|
-
// no non-fk traversal in infix filter
|
|
513
|
-
if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
|
|
514
|
-
throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
|
|
515
|
-
}
|
|
502
|
+
rejectNonFkAccess(element)
|
|
516
503
|
const resolvableIn = definition.target ? definition._target : target
|
|
517
504
|
column.$refLinks.push({ definition: elements[id], target: resolvableIn })
|
|
518
505
|
} else {
|
|
@@ -557,6 +544,8 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
557
544
|
|
|
558
545
|
const target = definition._target || column.$refLinks[i - 1].target
|
|
559
546
|
if (element) {
|
|
547
|
+
if($baseLink)
|
|
548
|
+
rejectNonFkAccess(element)
|
|
560
549
|
const $refLink = { definition: elements[id], target }
|
|
561
550
|
column.$refLinks.push($refLink)
|
|
562
551
|
} else if (firstStepIsSelf) {
|
|
@@ -658,6 +647,29 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
658
647
|
}
|
|
659
648
|
}
|
|
660
649
|
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Check if the next step in the ref is foreign key of `element`
|
|
653
|
+
* if not, an error is thrown.
|
|
654
|
+
*
|
|
655
|
+
* @param {CSN.Element} element if this is an association, the next step must be a foreign key of the element.
|
|
656
|
+
*/
|
|
657
|
+
function rejectNonFkAccess(element) {
|
|
658
|
+
if (!inNestedProjection && !inCalcElement && element.target) {
|
|
659
|
+
// only fk access in infix filter
|
|
660
|
+
const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
|
|
661
|
+
// no unmanaged assoc in infix filter path
|
|
662
|
+
if (!inExists && element.on)
|
|
663
|
+
throw new Error(
|
|
664
|
+
`"${element.name}" in path "${column.ref
|
|
665
|
+
.map(idOnly)
|
|
666
|
+
.join('.')}" must not be an unmanaged association`
|
|
667
|
+
)
|
|
668
|
+
// no non-fk traversal in infix filter
|
|
669
|
+
if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
|
|
670
|
+
throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
661
673
|
})
|
|
662
674
|
|
|
663
675
|
// ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
|
|
@@ -680,10 +692,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
680
692
|
? { ref: [...baseColumn.ref, ...column.ref], $refLinks: [...baseColumn.$refLinks, ...column.$refLinks] }
|
|
681
693
|
: column
|
|
682
694
|
if (isColumnJoinRelevant(colWithBase)) {
|
|
683
|
-
if (originalQuery.UPDATE)
|
|
684
|
-
throw new Error(
|
|
685
|
-
'Path expressions for UPDATE statements are not supported. Use “where exists” with infix filters instead.',
|
|
686
|
-
)
|
|
687
695
|
Object.defineProperty(column, 'isJoinRelevant', { value: true })
|
|
688
696
|
joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
|
|
689
697
|
}
|
|
@@ -873,20 +881,26 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
873
881
|
if (leafOfCalculatedElementRef.value) mergePathsIntoJoinTree(leafOfCalculatedElementRef.value, basePath)
|
|
874
882
|
|
|
875
883
|
mergePathIfNecessary(basePath, arg)
|
|
876
|
-
} else if (arg.xpr) {
|
|
877
|
-
arg.xpr
|
|
884
|
+
} else if (arg.xpr || arg.args) {
|
|
885
|
+
const prop = arg.xpr ? 'xpr' : 'args'
|
|
886
|
+
arg[prop].forEach(step => {
|
|
887
|
+
const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
|
|
878
888
|
if (step.ref) {
|
|
879
|
-
const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
|
|
880
889
|
step.$refLinks.forEach((link, i) => {
|
|
881
890
|
const { definition } = link
|
|
882
891
|
if (definition.value) {
|
|
883
|
-
mergePathsIntoJoinTree(definition.value)
|
|
892
|
+
mergePathsIntoJoinTree(definition.value, subPath)
|
|
884
893
|
} else {
|
|
885
894
|
subPath.$refLinks.push(link)
|
|
886
895
|
subPath.ref.push(step.ref[i])
|
|
887
896
|
}
|
|
888
897
|
})
|
|
889
898
|
mergePathIfNecessary(subPath, step)
|
|
899
|
+
} else if (step.args || step.xpr) {
|
|
900
|
+
const nestedProp = step.xpr ? 'xpr' : 'args'
|
|
901
|
+
step[nestedProp].forEach(a => {
|
|
902
|
+
mergePathsIntoJoinTree(a, subPath)
|
|
903
|
+
})
|
|
890
904
|
}
|
|
891
905
|
})
|
|
892
906
|
}
|
package/lib/search.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const DRAFT_COLUMNS_UNION = {
|
|
4
|
+
IsActiveEntity: 1,
|
|
5
|
+
HasActiveEntity: 1,
|
|
6
|
+
HasDraftEntity: 1,
|
|
7
|
+
DraftAdministrativeData_DraftUUID: 1,
|
|
8
|
+
SiblingEntity: 1,
|
|
9
|
+
DraftAdministrativeData: 1,
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_SEARCHABLE_TYPE = 'cds.String'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* This method gets all columns for an entity.
|
|
15
|
+
* It includes the generated foreign keys from managed associations, structured elements and complex and custom types.
|
|
16
|
+
* As well, it provides the annotations starting with '@' for each column.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} entity - the csn entity
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param [options.onlyNames=false] - decides if the column name or the csn representation of the column should be returned
|
|
21
|
+
* @param [options.filterDraft=false] - indicates whether the draft columns should be filtered if the entity is draft enabled
|
|
22
|
+
* @param [options.removeIgnore=false]
|
|
23
|
+
* @param [options.filterVirtual=false]
|
|
24
|
+
* @param [options.keysOnly=false]
|
|
25
|
+
* @returns {Array<object>} - array of columns
|
|
26
|
+
*/
|
|
27
|
+
const getColumns = (
|
|
28
|
+
entity,
|
|
29
|
+
{ onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false },
|
|
30
|
+
) => {
|
|
31
|
+
const skipDraft = filterDraft && entity._isDraftEnabled
|
|
32
|
+
const columns = []
|
|
33
|
+
const elements = entity.elements
|
|
34
|
+
|
|
35
|
+
for (const each in elements) {
|
|
36
|
+
const element = elements[each]
|
|
37
|
+
if (element.isAssociation) continue
|
|
38
|
+
if (filterVirtual && element.virtual) continue
|
|
39
|
+
if (removeIgnore && element['@cds.api.ignore']) continue
|
|
40
|
+
if (skipDraft && each in DRAFT_COLUMNS_UNION) continue
|
|
41
|
+
if (keysOnly && !element.key) continue
|
|
42
|
+
columns.push(onlyNames ? each : element)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return columns
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const _isColumnCalculated = (query, columnName) => {
|
|
49
|
+
if (!query) return false
|
|
50
|
+
if (query.SELECT?.columns?.find(col => col.xpr && col.as === columnName)) return true
|
|
51
|
+
return _isColumnCalculated(query._target?.query, columnName)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const _getSearchableColumns = entity => {
|
|
55
|
+
const columnsOptions = { removeIgnore: true, filterVirtual: true }
|
|
56
|
+
const columns = getColumns(entity, columnsOptions)
|
|
57
|
+
const cdsSearchTerm = '@cds.search'
|
|
58
|
+
const cdsSearchKeys = []
|
|
59
|
+
const cdsSearchColumnMap = new Map()
|
|
60
|
+
|
|
61
|
+
for (const key in entity) {
|
|
62
|
+
if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let atLeastOneColumnIsSearchable = false
|
|
66
|
+
|
|
67
|
+
// build a map of columns annotated with the @cds.search annotation
|
|
68
|
+
for (const key of cdsSearchKeys) {
|
|
69
|
+
const columnName = key.split(cdsSearchTerm + '.').pop()
|
|
70
|
+
|
|
71
|
+
// REVISIT: for now, exclude search using path expression, as deep search is not currently
|
|
72
|
+
// supported
|
|
73
|
+
if (columnName.includes('.')) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const annotationKey = `${cdsSearchTerm}.${columnName}`
|
|
78
|
+
const annotationValue = entity[annotationKey]
|
|
79
|
+
if (annotationValue) atLeastOneColumnIsSearchable = true
|
|
80
|
+
cdsSearchColumnMap.set(columnName, annotationValue)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const searchableColumns = columns.filter(column => {
|
|
84
|
+
const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
|
|
85
|
+
|
|
86
|
+
// the element is searchable if it is annotated with the @cds.search, e.g.:
|
|
87
|
+
// `@cds.search { element1: true }` or `@cds.search { element1 }`
|
|
88
|
+
if (annotatedColumnValue) return true
|
|
89
|
+
|
|
90
|
+
// if at least one element is explicitly annotated as searchable, e.g.:
|
|
91
|
+
// `@cds.search { element1: true }` or `@cds.search { element1 }`
|
|
92
|
+
// and it is not the current column name, then it must be excluded from the search
|
|
93
|
+
if (atLeastOneColumnIsSearchable) return false
|
|
94
|
+
|
|
95
|
+
// the element is considered searchable if it is explicitly annotated as such or
|
|
96
|
+
// if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
|
|
97
|
+
return (
|
|
98
|
+
annotatedColumnValue === undefined &&
|
|
99
|
+
column._type === DEFAULT_SEARCHABLE_TYPE &&
|
|
100
|
+
!_isColumnCalculated(entity?.query, column.name)
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// if the @cds.search annotation is provided -->
|
|
105
|
+
// Early return to ignore the interpretation of the @Search.defaultSearchElement
|
|
106
|
+
// annotation when an entity is annotated with the @cds.search annotation.
|
|
107
|
+
// The @cds.search annotation overrules the @Search.defaultSearchElement annotation.
|
|
108
|
+
if (cdsSearchKeys.length > 0) {
|
|
109
|
+
return searchableColumns.map(column => column.name)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return searchableColumns.map(column => column.name)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @returns {Array<object>} - array of columns
|
|
117
|
+
*/
|
|
118
|
+
const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, alias) => {
|
|
119
|
+
let toBeSearched = []
|
|
120
|
+
|
|
121
|
+
// aggregations case
|
|
122
|
+
// in the new parser groupBy is moved to sub select.
|
|
123
|
+
if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
|
|
124
|
+
cqn.SELECT.columns &&
|
|
125
|
+
cqn.SELECT.columns.forEach(column => {
|
|
126
|
+
if (column.func) {
|
|
127
|
+
// exclude $count by SELECT of number of Items in a Collection
|
|
128
|
+
if (
|
|
129
|
+
cqn.SELECT.columns.length === 1 &&
|
|
130
|
+
column.func === 'count' &&
|
|
131
|
+
(column.as === '_counted_' || column.as === '$count')
|
|
132
|
+
) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toBeSearched.push(column)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const columnRef = column.ref
|
|
141
|
+
if (columnRef) {
|
|
142
|
+
if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return
|
|
143
|
+
column = { ref: [...column.ref] }
|
|
144
|
+
if (alias) column.ref.unshift(alias)
|
|
145
|
+
toBeSearched.push(column)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
} else {
|
|
149
|
+
toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
|
|
150
|
+
if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
|
|
151
|
+
toBeSearched = toBeSearched.map(c => {
|
|
152
|
+
const col = { ref: [c] }
|
|
153
|
+
if (alias) col.ref.unshift(alias)
|
|
154
|
+
return col
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return toBeSearched
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
getColumns,
|
|
163
|
+
computeColumnsToBeSearched,
|
|
164
|
+
}
|
package/package.json
CHANGED