@cap-js/db-service 1.3.2 → 1.5.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 +31 -0
- package/lib/SQLService.js +11 -12
- package/lib/common/DatabaseService.js +36 -8
- package/lib/cql-functions.js +6 -6
- package/lib/cqn2sql.js +14 -11
- package/lib/cqn4sql.js +43 -5
- 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,37 @@
|
|
|
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.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.4.0...db-service-v1.5.0) (2023-12-06)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
* Improved connection pool for HANAService ([#349](https://github.com/cap-js/cds-dbs/issues/349)) ([1c284e6](https://github.com/cap-js/cds-dbs/commit/1c284e69cccd76daad52249c0462bc62aa4d11a8))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
* **localized:** `ref`s in subqueries in `from` are translated ([#366](https://github.com/cap-js/cds-dbs/issues/366)) ([cfe4897](https://github.com/cap-js/cds-dbs/commit/cfe489715db0854d30b90b7f13c024e6e90be497))
|
|
18
|
+
* wrong odata count in filter with groupby ([#352](https://github.com/cap-js/cds-dbs/issues/352)) ([70690a1](https://github.com/cap-js/cds-dbs/commit/70690a1a13e72bfbb66f03bf315d3f2d48672bf6))
|
|
19
|
+
|
|
20
|
+
## [1.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.2...db-service-v1.4.0) (2023-11-20)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
* **`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))
|
|
26
|
+
* **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))
|
|
27
|
+
* 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))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
* align time function behavior ([#322](https://github.com/cap-js/cds-dbs/issues/322)) ([c3ab40a](https://github.com/cap-js/cds-dbs/commit/c3ab40a007c105465349dd2f612178367b8e713a))
|
|
33
|
+
* **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))
|
|
34
|
+
* 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))
|
|
35
|
+
* 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))
|
|
36
|
+
* **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))
|
|
37
|
+
|
|
7
38
|
## [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
39
|
|
|
9
40
|
|
package/lib/SQLService.js
CHANGED
|
@@ -230,16 +230,15 @@ class SQLService extends DatabaseService {
|
|
|
230
230
|
if (max === undefined || (n < max && (n || !offset))) return n + offset
|
|
231
231
|
}
|
|
232
232
|
// REVISIT: made uppercase count because of HANA reserved word quoting
|
|
233
|
-
const cq =
|
|
234
|
-
|
|
233
|
+
const cq = SELECT.one([{ func: 'count', as: 'COUNT' }]).from(
|
|
234
|
+
cds.ql.clone(query, {
|
|
235
235
|
localized: false,
|
|
236
236
|
expand: false,
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
const { count, COUNT } = await ps.get(values)
|
|
237
|
+
limit: undefined,
|
|
238
|
+
orderBy: undefined,
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
const { count, COUNT } = await this.onSELECT({ query: cq })
|
|
243
242
|
return count ?? COUNT
|
|
244
243
|
}
|
|
245
244
|
|
|
@@ -280,12 +279,12 @@ class SQLService extends DatabaseService {
|
|
|
280
279
|
*/
|
|
281
280
|
cqn2sql(query, values) {
|
|
282
281
|
let q = this.cqn4sql(query)
|
|
283
|
-
if (q.SELECT &&
|
|
282
|
+
if (q.SELECT && 'elements' in q) q.SELECT.expand ??= 'root'
|
|
284
283
|
|
|
285
|
-
let
|
|
286
|
-
if (
|
|
284
|
+
let kind = q.kind || Object.keys(q)[0]
|
|
285
|
+
if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 } || q.STREAM?.into) {
|
|
287
286
|
q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
|
|
288
|
-
let target = q[
|
|
287
|
+
let target = q[kind]._transitions?.[0].target
|
|
289
288
|
if (target) q.target = target // REVISIT: Why isn't that done in resolveView?
|
|
290
289
|
}
|
|
291
290
|
let cqn2sql = new this.class.CQN2SQL(this)
|
|
@@ -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
|
|
@@ -55,6 +63,7 @@ class DatabaseService extends cds.Service {
|
|
|
55
63
|
|
|
56
64
|
// Acquire a pooled connection
|
|
57
65
|
this.dbc = await this.acquire()
|
|
66
|
+
this.dbc.destroy = this.destroy.bind(this)
|
|
58
67
|
|
|
59
68
|
// Begin a session...
|
|
60
69
|
try {
|
|
@@ -102,8 +111,20 @@ class DatabaseService extends cds.Service {
|
|
|
102
111
|
*/
|
|
103
112
|
async release() {
|
|
104
113
|
if (!this.dbc) return
|
|
105
|
-
|
|
114
|
+
const dbc = this.dbc
|
|
115
|
+
this.dbc = undefined
|
|
116
|
+
await this.pool.release(dbc)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Destroys own connection, i.e. tix.dbc, from this.pool
|
|
121
|
+
* This is for subclasses to intercept, if required.
|
|
122
|
+
*/
|
|
123
|
+
async destroy() {
|
|
124
|
+
if (!this.dbc) return
|
|
125
|
+
const dbc = this.dbc
|
|
106
126
|
this.dbc = undefined
|
|
127
|
+
await this.pool.destroy(dbc)
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
// REVISIT: should happen automatically after a configurable time
|
|
@@ -111,11 +132,18 @@ class DatabaseService extends cds.Service {
|
|
|
111
132
|
* @param {string} tenant
|
|
112
133
|
*/
|
|
113
134
|
async disconnect(tenant) {
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
135
|
+
const _disconnect = async tenant => {
|
|
136
|
+
const pool = this.pools[tenant]
|
|
137
|
+
if (!pool) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
await pool.drain()
|
|
141
|
+
await pool.clear()
|
|
142
|
+
delete this.pools[tenant]
|
|
143
|
+
}
|
|
144
|
+
if (tenant == null)
|
|
145
|
+
return Promise.all(Object.keys(this.pools).map(_disconnect))
|
|
146
|
+
return _disconnect(tenant)
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
/**
|
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
|
@@ -62,15 +62,15 @@ class CQN2SQLRenderer {
|
|
|
62
62
|
* @returns {CQN2SQLRenderer|unknown}
|
|
63
63
|
*/
|
|
64
64
|
render(q, vars) {
|
|
65
|
-
const
|
|
65
|
+
const kind = q.kind || Object.keys(q)[0] // SELECT, INSERT, ...
|
|
66
66
|
/**
|
|
67
67
|
* @type {string} the rendered SQL string
|
|
68
68
|
*/
|
|
69
69
|
this.sql = '' // to have it as first property for debugging
|
|
70
70
|
/** @type {unknown[]} */
|
|
71
71
|
this.values = [] // prepare values, filled in by subroutines
|
|
72
|
-
this[
|
|
73
|
-
if (vars?.length && !this.values
|
|
72
|
+
this[kind]((this.cqn = q)) // actual sql rendering happens here
|
|
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,
|
|
@@ -208,9 +208,8 @@ class CQN2SQLRenderer {
|
|
|
208
208
|
if (limit) sql += ` LIMIT ${this.limit(limit)}`
|
|
209
209
|
// Expand cannot work without an inferred query
|
|
210
210
|
if (expand) {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
sql = this.SELECT_expand(q, sql)
|
|
211
|
+
if ('elements' in q) sql = this.SELECT_expand (q,sql)
|
|
212
|
+
else cds.error`Query was not inferred and includes expand. For which the metadata is missing.`
|
|
214
213
|
}
|
|
215
214
|
return (this.sql = sql)
|
|
216
215
|
}
|
|
@@ -230,10 +229,12 @@ class CQN2SQLRenderer {
|
|
|
230
229
|
* @param {string} sql
|
|
231
230
|
* @returns {string} SQL
|
|
232
231
|
*/
|
|
233
|
-
SELECT_expand(
|
|
232
|
+
SELECT_expand(q, sql) {
|
|
233
|
+
if (!('elements' in q)) return sql
|
|
234
|
+
|
|
235
|
+
const SELECT = q.SELECT
|
|
234
236
|
if (!SELECT.columns) return sql
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
|
|
237
238
|
let cols = SELECT.columns.map(x => {
|
|
238
239
|
const name = this.column_name(x)
|
|
239
240
|
let col = `'${name}',${this.output_converter4(x.element, this.quote(name))}`
|
|
@@ -256,7 +257,7 @@ class CQN2SQLRenderer {
|
|
|
256
257
|
}
|
|
257
258
|
// REVISIT: json_merge is a user defined function, bad performance!
|
|
258
259
|
obj = `json_merge(${chunks})`
|
|
259
|
-
}
|
|
260
|
+
}
|
|
260
261
|
|
|
261
262
|
|
|
262
263
|
return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj.includes('json_merge') ? `json_insert(${obj})` : obj})`} as _json_ FROM (${sql})`
|
|
@@ -527,6 +528,9 @@ class CQN2SQLRenderer {
|
|
|
527
528
|
.filter(c => !keys.includes(c))
|
|
528
529
|
.map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
|
|
529
530
|
|
|
531
|
+
// temporal data
|
|
532
|
+
keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
|
|
533
|
+
|
|
530
534
|
keys = keys.map(k => this.quote(k))
|
|
531
535
|
const conflict = updateColumns.length
|
|
532
536
|
? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
|
|
@@ -872,7 +876,6 @@ class CQN2SQLRenderer {
|
|
|
872
876
|
|
|
873
877
|
let val = _managed[element[annotation]?.['=']]
|
|
874
878
|
if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
|
|
875
|
-
|
|
876
879
|
else if (!isUpdate && element.default) {
|
|
877
880
|
const d = element.default
|
|
878
881
|
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
|
|
|
@@ -48,7 +48,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
48
48
|
if (originalQuery.SELECT?.from.args && !originalQuery.joinTree) return inferred
|
|
49
49
|
|
|
50
50
|
let transformedQuery = cds.ql.clone(inferred)
|
|
51
|
-
const kind = inferred.
|
|
51
|
+
const kind = inferred.kind || Object.keys(inferred)[0]
|
|
52
52
|
|
|
53
53
|
if (inferred.INSERT || inferred.UPSERT) {
|
|
54
54
|
transformedQuery = transformQueryForInsertUpsert(kind)
|
|
@@ -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
|
}
|
|
@@ -376,7 +415,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
376
415
|
return transformedColumns
|
|
377
416
|
|
|
378
417
|
function handleSubquery(col) {
|
|
379
|
-
if (isLocalized(inferred.target)) col.SELECT.localized = true
|
|
380
418
|
if (!col.SELECT.from.as) {
|
|
381
419
|
const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
|
|
382
420
|
getLastStringSegment(col.SELECT.from.ref[col.SELECT.from.ref.length - 1]),
|
|
@@ -791,7 +829,6 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
791
829
|
one: column.$refLinks[column.$refLinks.length - 1].definition.is2one,
|
|
792
830
|
},
|
|
793
831
|
}
|
|
794
|
-
if (isLocalized(inferred.target)) subquery.SELECT.localized = true
|
|
795
832
|
const expanded = transformSubquery(subquery)
|
|
796
833
|
const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
|
|
797
834
|
Object.defineProperty(correlated, 'elements', { value: subquery.elements, writable: true })
|
|
@@ -907,6 +944,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
|
|
|
907
944
|
outerQueries.push(inferred)
|
|
908
945
|
Object.defineProperty(q, 'outerQueries', { value: outerQueries })
|
|
909
946
|
}
|
|
947
|
+
if (isLocalized(inferred.target)) q.SELECT.localized = true
|
|
910
948
|
return cqn4sql(q, model)
|
|
911
949
|
}
|
|
912
950
|
|
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