@cap-js/db-service 1.1.0 → 1.2.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/InsertResults.js +2 -1
- package/lib/SQLService.js +64 -52
- package/lib/common/DatabaseService.js +84 -130
- package/lib/common/generic-pool.js +34 -0
- package/lib/common/session-context.js +32 -0
- package/lib/cql-functions.js +14 -1
- package/lib/cqn2sql.js +215 -171
- package/lib/cqn4sql.js +118 -56
- package/lib/deep-queries.js +4 -3
- package/lib/fill-in-keys.js +5 -4
- package/lib/infer/index.js +41 -19
- package/lib/infer/join-tree.js +2 -2
- package/package.json +2 -5
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
|
+
## Version 1.2.0 - 2023-09-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- support for calculated elements on read. #113 #123
|
|
12
|
+
- support for managed associations with default values. #193
|
|
13
|
+
- introduced new operator `==` which translates to `IS NOT DISTINCT FROM`. #164
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- resolved a type error which occured in some cases for deeply nested `expand`s. #173
|
|
18
|
+
- path expression traversing non-foreign-key fields within infix filters are now properly rejected for `exists` predicates. #181
|
|
19
|
+
- CQL functions: In the `args` of the `concat` function an `xpr` is now wrapped in parentheses. #196
|
|
20
|
+
- Make `UPDATE` and `ofarray` typed column compatible. #184
|
|
21
|
+
- Ensure that `INSERT` with `rows` always inserts into the correct column. #193
|
|
22
|
+
- Allow `DateTime` columns to compare against their returned value. #206
|
|
23
|
+
- Deep Insert using backlink associations as key #199.
|
|
24
|
+
|
|
7
25
|
## Version 1.1.0 - 2023-08-01
|
|
8
26
|
|
|
9
27
|
### Fixed
|
package/lib/InsertResults.js
CHANGED
|
@@ -17,7 +17,8 @@ module.exports = class InsertResult {
|
|
|
17
17
|
* @param {unknown[]} results
|
|
18
18
|
*/
|
|
19
19
|
constructor(query, results) {
|
|
20
|
-
|
|
20
|
+
// Storing query as non-enumerable property to avoid polluting trace output
|
|
21
|
+
Object.defineProperty(this, 'query', { value: query })
|
|
21
22
|
this.results = results
|
|
22
23
|
}
|
|
23
24
|
|
package/lib/SQLService.js
CHANGED
|
@@ -18,6 +18,7 @@ const cqn4sql = require('./cqn4sql')
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
class SQLService extends DatabaseService {
|
|
21
|
+
|
|
21
22
|
init() {
|
|
22
23
|
this.on(['SELECT'], this.transformStreamFromCQN)
|
|
23
24
|
this.on(['UPDATE'], this.transformStreamIntoCQN)
|
|
@@ -82,7 +83,7 @@ class SQLService extends DatabaseService {
|
|
|
82
83
|
if (rows.length)
|
|
83
84
|
if (cqn.SELECT.expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
|
|
84
85
|
if (cqn.SELECT.count) rows.$count = await this.count(query, rows)
|
|
85
|
-
return cqn.SELECT.one || query.SELECT.from
|
|
86
|
+
return cqn.SELECT.one || query.SELECT.from?.ref?.[0].cardinality?.max === 1 ? rows[0] : rows
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
/**
|
|
@@ -91,7 +92,7 @@ class SQLService extends DatabaseService {
|
|
|
91
92
|
*/
|
|
92
93
|
async onINSERT({ query, data }) {
|
|
93
94
|
const { sql, entries, cqn } = this.cqn2sql(query, data)
|
|
94
|
-
if (!sql) return // Do nothing when there is nothing to be done
|
|
95
|
+
if (!sql) return // Do nothing when there is nothing to be done // REVISIT: fix within mtxs
|
|
95
96
|
const ps = await this.prepare(sql)
|
|
96
97
|
const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run()
|
|
97
98
|
return new this.class.InsertResults(cqn, results)
|
|
@@ -103,10 +104,11 @@ class SQLService extends DatabaseService {
|
|
|
103
104
|
*/
|
|
104
105
|
async onUPSERT({ query, data }) {
|
|
105
106
|
const { sql, entries } = this.cqn2sql(query, data)
|
|
106
|
-
if (!sql) return // Do nothing when there is nothing to be done
|
|
107
|
+
if (!sql) return // Do nothing when there is nothing to be done // REVISIT: When does this happen?
|
|
107
108
|
const ps = await this.prepare(sql)
|
|
108
109
|
const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run()
|
|
109
|
-
|
|
110
|
+
// REVISIT: results isn't an array, when no entries -> how could that work? when do we have no entries?
|
|
111
|
+
return results.reduce((total, affectedRows) => (total += affectedRows.changes), 0)
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -129,21 +131,15 @@ class SQLService extends DatabaseService {
|
|
|
129
131
|
* @type {Handler}
|
|
130
132
|
*/
|
|
131
133
|
async onSTREAM(req) {
|
|
132
|
-
const { sql, values
|
|
134
|
+
const { one, sql, values } = this.cqn2sql(req.query)
|
|
133
135
|
// writing stream
|
|
134
136
|
if (req.query.STREAM.into) {
|
|
135
|
-
const stream = entries[0]
|
|
136
|
-
stream.on('error', () => stream.removeAllListeners('error'))
|
|
137
|
-
values.unshift(stream)
|
|
138
137
|
const ps = await this.prepare(sql)
|
|
139
138
|
return (await ps.run(values)).changes
|
|
140
139
|
}
|
|
141
140
|
// reading stream
|
|
142
141
|
const ps = await this.prepare(sql)
|
|
143
|
-
|
|
144
|
-
if (result.length === 0) return
|
|
145
|
-
|
|
146
|
-
return Object.values(result[0])[0]
|
|
142
|
+
return ps.stream(values, one)
|
|
147
143
|
}
|
|
148
144
|
|
|
149
145
|
/**
|
|
@@ -171,7 +167,12 @@ class SQLService extends DatabaseService {
|
|
|
171
167
|
*/
|
|
172
168
|
async onPlainSQL({ query, data }, next) {
|
|
173
169
|
if (typeof query === 'string') {
|
|
174
|
-
|
|
170
|
+
// REVISIT: this is a hack the target of $now might not be a timestamp or date time
|
|
171
|
+
// Add input converter to CURRENT_TIMESTAMP inside views using $now
|
|
172
|
+
if(/^CREATE VIEW.* CURRENT_TIMESTAMP[( ]/is.test(query)) {
|
|
173
|
+
query = query.replace(/CURRENT_TIMESTAMP/gi, 'ISO(CURRENT_TIMESTAMP)')
|
|
174
|
+
}
|
|
175
|
+
DEBUG?.(query, data)
|
|
175
176
|
const ps = await this.prepare(query)
|
|
176
177
|
const exec = this.hasResults(query) ? d => ps.all(d) : d => ps.run(d)
|
|
177
178
|
if (Array.isArray(data) && typeof data[0] === 'object') return await Promise.all(data.map(exec))
|
|
@@ -200,8 +201,9 @@ class SQLService extends DatabaseService {
|
|
|
200
201
|
const [max, offset = 0] = one ? [1] : _ ? [_.rows?.val, _.offset?.val] : []
|
|
201
202
|
if (max === undefined || (n < max && (n || !offset))) return n + offset
|
|
202
203
|
}
|
|
204
|
+
// REVISIT: made uppercase count because of HANA reserved word quoting
|
|
203
205
|
const cq = cds.ql.clone(query, {
|
|
204
|
-
columns: [{ func: 'count' }],
|
|
206
|
+
columns: [{ func: 'count', as: 'COUNT' }],
|
|
205
207
|
localized: false,
|
|
206
208
|
expand: false,
|
|
207
209
|
limit: 0,
|
|
@@ -209,10 +211,14 @@ class SQLService extends DatabaseService {
|
|
|
209
211
|
})
|
|
210
212
|
const { sql, values } = this.cqn2sql(cq)
|
|
211
213
|
const ps = await this.prepare(sql)
|
|
212
|
-
const { count } = await ps.get(values)
|
|
213
|
-
return count
|
|
214
|
+
const { count, COUNT } = await ps.get(values)
|
|
215
|
+
return count ?? COUNT
|
|
214
216
|
}
|
|
215
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Helper class for results of INSERTs.
|
|
220
|
+
* Subclasses may override this.
|
|
221
|
+
*/
|
|
216
222
|
static InsertResults = require('./InsertResults')
|
|
217
223
|
|
|
218
224
|
/**
|
|
@@ -229,28 +235,22 @@ class SQLService extends DatabaseService {
|
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
/**
|
|
232
|
-
* @param {import('@sap/cds/apis/cqn').Query}
|
|
238
|
+
* @param {import('@sap/cds/apis/cqn').Query} query
|
|
233
239
|
* @param {unknown} values
|
|
234
240
|
* @returns {typeof SQLService.CQN2SQL}
|
|
235
241
|
*/
|
|
236
|
-
cqn2sql(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (cmd in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 } || cqn.STREAM?.into) {
|
|
246
|
-
let resolvedCqn = resolveView(cqn, this.model, this)
|
|
247
|
-
if (resolvedCqn && resolvedCqn[cmd]._transitions?.[0].target) {
|
|
248
|
-
resolvedCqn = resolvedCqn || cqn
|
|
249
|
-
resolvedCqn.target = resolvedCqn?.[cmd]._transitions[0].target || cqn.target
|
|
250
|
-
}
|
|
251
|
-
return new this.class.CQN2SQL(this.context).render(resolvedCqn, values)
|
|
242
|
+
cqn2sql(query, values) {
|
|
243
|
+
let q = this.cqn4sql(query)
|
|
244
|
+
if (q.SELECT && q.elements) q.SELECT.expand = q.SELECT.expand ?? 'root'
|
|
245
|
+
|
|
246
|
+
let cmd = q.cmd || Object.keys(q)[0]
|
|
247
|
+
if (cmd in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 } || q.STREAM?.into) {
|
|
248
|
+
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?
|
|
249
|
+
let target = q[cmd]._transitions?.[0].target
|
|
250
|
+
if (target) q.target = target // REVISIT: Why isn't that done in resolveView?
|
|
252
251
|
}
|
|
253
|
-
|
|
252
|
+
let cqn2sql = new this.class.CQN2SQL(this)
|
|
253
|
+
return cqn2sql.render(q, values)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
@@ -258,18 +258,19 @@ class SQLService extends DatabaseService {
|
|
|
258
258
|
* @returns {import('./infer/cqn').Query}
|
|
259
259
|
*/
|
|
260
260
|
cqn4sql(q) {
|
|
261
|
-
|
|
262
|
-
if (!q.SELECT?.from?.join && !this.model?.definitions[_target_name4(q)]) return _unquirked(q)
|
|
261
|
+
if (!q.SELECT?.from?.join && !q.SELECT?.from?.SELECT && !this.model?.definitions[_target_name4(q)]) return _unquirked(q)
|
|
263
262
|
return cqn4sql(q, this.model)
|
|
264
263
|
}
|
|
265
264
|
|
|
266
265
|
/**
|
|
267
266
|
* Returns a Promise which resolves to a prepared statement object with
|
|
268
267
|
* `{run,get,all}` signature as specified in {@link PreparedStatement}.
|
|
268
|
+
* @abstract
|
|
269
|
+
* @param {string} sql The SQL String to be prepared
|
|
269
270
|
* @returns {PreparedStatement}
|
|
270
271
|
*/
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
async prepare(sql) {
|
|
273
|
+
sql
|
|
273
274
|
throw '2b overridden by subclass'
|
|
274
275
|
}
|
|
275
276
|
|
|
@@ -290,8 +291,10 @@ class SQLService extends DatabaseService {
|
|
|
290
291
|
* @interface
|
|
291
292
|
*/
|
|
292
293
|
class PreparedStatement {
|
|
294
|
+
|
|
293
295
|
/**
|
|
294
296
|
* Executes a prepared DML query, i.e., INSERT, UPDATE, DELETE, CREATE, DROP
|
|
297
|
+
* @abstract
|
|
295
298
|
* @param {unknown|unknown[]} binding_params
|
|
296
299
|
*/
|
|
297
300
|
async run(binding_params) {
|
|
@@ -300,6 +303,7 @@ class PreparedStatement {
|
|
|
300
303
|
}
|
|
301
304
|
/**
|
|
302
305
|
* Executes a prepared SELECT query and returns a single/first row only
|
|
306
|
+
* @abstract
|
|
303
307
|
* @param {unknown|unknown[]} binding_params
|
|
304
308
|
* @returns {Promise<unknown>}
|
|
305
309
|
*/
|
|
@@ -309,6 +313,7 @@ class PreparedStatement {
|
|
|
309
313
|
}
|
|
310
314
|
/**
|
|
311
315
|
* Executes a prepared SELECT query and returns an array of all rows
|
|
316
|
+
* @abstract
|
|
312
317
|
* @param {unknown|unknown[]} binding_params
|
|
313
318
|
* @returns {Promise<unknown[]>}
|
|
314
319
|
*/
|
|
@@ -316,6 +321,15 @@ class PreparedStatement {
|
|
|
316
321
|
binding_params
|
|
317
322
|
return [{}]
|
|
318
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Executes a prepared SELECT query and returns a stream of the result
|
|
326
|
+
* @abstract
|
|
327
|
+
* @param {unknown|unknown[]} binding_params
|
|
328
|
+
* @returns {ReadableStream<string|Buffer>} A stream of the result
|
|
329
|
+
*/
|
|
330
|
+
async stream(binding_params) {
|
|
331
|
+
binding_params
|
|
332
|
+
}
|
|
319
333
|
}
|
|
320
334
|
SQLService.prototype.PreparedStatement = PreparedStatement
|
|
321
335
|
|
|
@@ -329,29 +343,26 @@ const _target_name4 = q => {
|
|
|
329
343
|
q.CREATE?.entity ||
|
|
330
344
|
q.DROP?.entity ||
|
|
331
345
|
q.STREAM?.from ||
|
|
332
|
-
q.STREAM?.into
|
|
333
|
-
|
|
334
|
-
if (target?.SET?.op === 'union') throw new cds.error('”UNION” based queries are not supported')
|
|
346
|
+
q.STREAM?.into
|
|
347
|
+
if (target?.SET?.op === 'union') throw new cds.error('UNION-based queries are not supported')
|
|
335
348
|
if (!target?.ref) return target
|
|
336
349
|
const [first] = target.ref
|
|
337
350
|
return first.id || first
|
|
338
351
|
}
|
|
339
352
|
|
|
340
353
|
const _unquirked = q => {
|
|
341
|
-
if (
|
|
342
|
-
if (typeof q.
|
|
343
|
-
if (typeof q.
|
|
344
|
-
if (typeof q.
|
|
345
|
-
if (typeof q.
|
|
346
|
-
if (typeof q.
|
|
354
|
+
if (!q) return q
|
|
355
|
+
else if (typeof q.SELECT?.from === 'string') q.SELECT.from = { ref: [q.SELECT.from] }
|
|
356
|
+
else if (typeof q.INSERT?.into === 'string') q.INSERT.into = { ref: [q.INSERT.into] }
|
|
357
|
+
else if (typeof q.UPSERT?.into === 'string') q.UPSERT.into = { ref: [q.UPSERT.into] }
|
|
358
|
+
else if (typeof q.UPDATE?.entity === 'string') q.UPDATE.entity = { ref: [q.UPDATE.entity] }
|
|
359
|
+
else if (typeof q.DELETE?.from === 'string') q.DELETE.from = { ref: [q.DELETE.from] }
|
|
360
|
+
else if (typeof q.CREATE?.entity === 'string') q.CREATE.entity = { ref: [q.CREATE.entity] }
|
|
361
|
+
else if (typeof q.DROP?.entity === 'string') q.DROP.entity = { ref: [q.DROP.entity] }
|
|
347
362
|
return q
|
|
348
363
|
}
|
|
349
364
|
|
|
350
|
-
const sqls = new
|
|
351
|
-
get factory() {
|
|
352
|
-
return null
|
|
353
|
-
}
|
|
354
|
-
})()
|
|
365
|
+
const sqls = new class extends SQLService { get factory() { return null } }
|
|
355
366
|
cds.extend(cds.ql.Query).with(
|
|
356
367
|
class {
|
|
357
368
|
forSQL() {
|
|
@@ -359,6 +370,7 @@ cds.extend(cds.ql.Query).with(
|
|
|
359
370
|
return this.flat(cqn)
|
|
360
371
|
}
|
|
361
372
|
toSQL() {
|
|
373
|
+
if (this.SELECT) this.SELECT.expand = 'root' // Enforces using json functions always for top-level SELECTS
|
|
362
374
|
let { sql, values } = (cds.db || sqls).cqn2sql(this)
|
|
363
375
|
return { sql, values } // skipping .cqn property
|
|
364
376
|
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
const SessionContext = require('./session-context')
|
|
2
|
+
const ConnectionPool = require('./generic-pool')
|
|
1
3
|
const infer = require('../infer')
|
|
2
|
-
const cds = require('@sap/cds')
|
|
3
|
-
|
|
4
|
-
function Pool(factory, tenant) {
|
|
5
|
-
const pool = createPool({ __proto__: factory, create: factory.create.bind(undefined, tenant) }, factory.options)
|
|
6
|
-
pool._trackedConnections = []
|
|
7
|
-
return pool
|
|
8
|
-
}
|
|
9
|
-
const { createPool } = require('@sap/cds-foss').pool
|
|
4
|
+
const cds = require('@sap/cds/lib')
|
|
10
5
|
|
|
11
6
|
/** @typedef {unknown} DatabaseDriver */
|
|
12
7
|
|
|
13
8
|
class DatabaseService extends cds.Service {
|
|
9
|
+
/**
|
|
10
|
+
* Dictionary of connection pools per tenant
|
|
11
|
+
*/
|
|
12
|
+
pools = { _factory: this.factory }
|
|
13
|
+
|
|
14
14
|
/**
|
|
15
15
|
* Return a pool factory + options property as expected by
|
|
16
16
|
* https://github.com/coopernurse/node-pool#createpool.
|
|
@@ -20,120 +20,89 @@ class DatabaseService extends cds.Service {
|
|
|
20
20
|
get factory() {
|
|
21
21
|
throw '2b overriden in subclass'
|
|
22
22
|
}
|
|
23
|
-
pools = { _factory: this.factory }
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @returns {boolean} whether this service is multi tenant enabled
|
|
27
|
-
*/
|
|
28
|
-
get isMultitenant() {
|
|
29
|
-
return 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @typedef {Object} DefaultSessionVariables
|
|
34
|
-
* @property {string} '$user.id'
|
|
35
|
-
* @property {string} '$user.locale'
|
|
36
|
-
* @property {string} '$valid.from'
|
|
37
|
-
* @property {string} '$valid.to'
|
|
38
|
-
*/
|
|
39
23
|
|
|
40
24
|
/**
|
|
41
|
-
* Set one or more session context variables
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* })
|
|
49
|
-
* ```
|
|
50
|
-
* @param {unknown|DefaultSessionVariables} variables
|
|
25
|
+
* Set one or more session context variables like so:
|
|
26
|
+
*
|
|
27
|
+
* const tx = cds.db.tx()
|
|
28
|
+
* tx.set({ foo: 'bar' })
|
|
29
|
+
*
|
|
30
|
+
* This is used in this.begin() for standard properties
|
|
31
|
+
* like `$user.id` or `$user.locale`.
|
|
51
32
|
*/
|
|
33
|
+
// eslint-disable-next-line no-unused-vars
|
|
52
34
|
set(variables) {
|
|
53
|
-
variables
|
|
54
35
|
throw '2b overridden by subclass'
|
|
55
36
|
}
|
|
56
37
|
|
|
57
38
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
infer(q, m = this.model) {
|
|
63
|
-
return infer(q, m)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* @returns {Promise<DatabaseService>}
|
|
39
|
+
* Acquires a pooled connection and starts a session, including setting
|
|
40
|
+
* session context like `$user.id` or `$user.locale`, and starting a
|
|
41
|
+
* transaction with `BEGIN`
|
|
42
|
+
* @returns this
|
|
68
43
|
*/
|
|
69
44
|
async begin() {
|
|
45
|
+
// We expect tx.begin() being called for an txed db service
|
|
70
46
|
const ctx = this.context
|
|
71
|
-
if (!ctx) return this.tx().begin()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
let
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this._beginStack = new Error('begin called from:')
|
|
85
|
-
connections.push(this)
|
|
86
|
-
/**
|
|
87
|
-
* @param {DatabaseDriver} dbc
|
|
88
|
-
*/
|
|
89
|
-
this._release = async dbc => {
|
|
90
|
-
await pool.release(dbc)
|
|
91
|
-
connections.splice(connections.indexOf(this), 1)
|
|
92
|
-
}
|
|
47
|
+
if (!ctx) return this.tx().begin() // REVISIT: Is this correct? When does this happen?
|
|
48
|
+
|
|
49
|
+
// REVISIT: tenant should be undefined if !this.isMultitenant
|
|
50
|
+
let isMultitenant = 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
|
|
51
|
+
let tenant = isMultitenant && ctx.tenant
|
|
52
|
+
|
|
53
|
+
// Setting this.pool as used in this.acquire() and this.release()
|
|
54
|
+
this.pool = this.pools[tenant] ??= new ConnectionPool(this.pools._factory, tenant)
|
|
55
|
+
|
|
56
|
+
// Acquire a pooled connection
|
|
57
|
+
this.dbc = await this.acquire()
|
|
58
|
+
|
|
59
|
+
// Begin a session...
|
|
93
60
|
try {
|
|
94
|
-
|
|
95
|
-
await this.set({
|
|
96
|
-
get '$user.id'() {
|
|
97
|
-
return _set(this, '$user.id', ctx.user?.id || 'anonymous')
|
|
98
|
-
},
|
|
99
|
-
get '$user.locale'() {
|
|
100
|
-
return _set(this, '$user.locale', ctx.locale || cds.env.i18n.default_language)
|
|
101
|
-
},
|
|
102
|
-
get '$valid.from'() {
|
|
103
|
-
return _set(this, '$valid.from', ctx._?.['VALID-FROM'] ?? ctx._?.['VALID-AT'] ?? '1970-01-01T00:00:00.000Z')
|
|
104
|
-
},
|
|
105
|
-
get '$valid.to'() {
|
|
106
|
-
return _set(
|
|
107
|
-
this,
|
|
108
|
-
'$valid.to',
|
|
109
|
-
ctx._?.['VALID-TO'] ?? _validTo4(ctx._?.['VALID-AT']) ?? '9999-11-11T22:22:22.000Z',
|
|
110
|
-
)
|
|
111
|
-
},
|
|
112
|
-
})
|
|
113
|
-
// Run BEGIN
|
|
61
|
+
await this.set(new SessionContext(ctx))
|
|
114
62
|
await this.send('BEGIN')
|
|
115
63
|
} catch (e) {
|
|
116
|
-
this.
|
|
64
|
+
this.release()
|
|
117
65
|
throw e
|
|
118
66
|
}
|
|
119
67
|
return this
|
|
120
68
|
}
|
|
121
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Commits a transaction and releases the connection to the pool.
|
|
72
|
+
*/
|
|
122
73
|
async commit() {
|
|
123
|
-
|
|
124
|
-
if (!dbc) return
|
|
74
|
+
if (!this.dbc) return
|
|
125
75
|
await this.send('COMMIT')
|
|
126
|
-
this.
|
|
76
|
+
this.release() // only release on successful commit as otherwise released on rollback
|
|
127
77
|
}
|
|
128
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Rolls back a transaction and releases the connection to the pool.
|
|
81
|
+
*/
|
|
129
82
|
async rollback() {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
83
|
+
if (!this.dbc) return
|
|
84
|
+
else
|
|
85
|
+
try {
|
|
86
|
+
await this.send('ROLLBACK')
|
|
87
|
+
} finally {
|
|
88
|
+
this.release()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Acquires a connection from this.pool, stored into this.dbc
|
|
94
|
+
* This is for subclasses to intercept, if required.
|
|
95
|
+
*/
|
|
96
|
+
async acquire() {
|
|
97
|
+
return await this.pool.acquire()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Releases own connection, i.e. tix.dbc, from this.pool
|
|
102
|
+
* This is for subclasses to intercept, if required.
|
|
103
|
+
*/
|
|
104
|
+
async release() {
|
|
105
|
+
return this.pool.release(this.dbc)
|
|
137
106
|
}
|
|
138
107
|
|
|
139
108
|
// REVISIT: should happen automatically after a configurable time
|
|
@@ -142,18 +111,24 @@ class DatabaseService extends cds.Service {
|
|
|
142
111
|
*/
|
|
143
112
|
async disconnect(tenant) {
|
|
144
113
|
const pool = this.pools[tenant]
|
|
145
|
-
if (pool)
|
|
146
|
-
else return
|
|
114
|
+
if (!pool) return
|
|
147
115
|
await pool.drain()
|
|
148
116
|
await pool.clear()
|
|
117
|
+
delete this.pools[tenant]
|
|
149
118
|
}
|
|
150
119
|
|
|
151
120
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* @param {
|
|
155
|
-
* @
|
|
156
|
-
|
|
121
|
+
* Infers the given query with this DatabaseService instance's model.
|
|
122
|
+
* In general `this.model` is the same then `cds.model`
|
|
123
|
+
* @param {CQN} query - the query to infer
|
|
124
|
+
* @returns {CQN} the inferred query
|
|
125
|
+
*/
|
|
126
|
+
infer(query) {
|
|
127
|
+
return infer(query, this.model)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* DatabaseServices also support passing native query strings to underlying databases.
|
|
157
132
|
*/
|
|
158
133
|
run(query, data, ...etc) {
|
|
159
134
|
// Allow db.run('...',1,2,3,4)
|
|
@@ -162,33 +137,12 @@ class DatabaseService extends cds.Service {
|
|
|
162
137
|
}
|
|
163
138
|
|
|
164
139
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* @returns {string}
|
|
140
|
+
* @returns {string} A url-like string used to print log output,
|
|
141
|
+
* e.g., in cds.deploy()
|
|
168
142
|
*/
|
|
169
|
-
url4(tenant) {
|
|
170
|
-
|
|
171
|
-
let { url } = this.options?.credentials || this.options || {}
|
|
172
|
-
return url
|
|
143
|
+
url4(/*tenant*/) {
|
|
144
|
+
return this.options.credentials?.url || this.options.url
|
|
173
145
|
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Old name of url4
|
|
177
|
-
* @deprecated
|
|
178
|
-
* @param {string} tenant
|
|
179
|
-
* @returns {string}
|
|
180
|
-
*/
|
|
181
|
-
getDbUrl(tenant) {
|
|
182
|
-
return this.url4(tenant)
|
|
183
|
-
} // REVISIT: Remove after cds v6.7
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const _set = (context, variable, value) => {
|
|
187
|
-
Object.defineProperty(context, variable, { value, configurable: true })
|
|
188
|
-
return value
|
|
189
|
-
}
|
|
190
|
-
const _validTo4 = validAt => {
|
|
191
|
-
return validAt?.replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || '')
|
|
192
146
|
}
|
|
193
147
|
|
|
194
148
|
DatabaseService.prototype.isDatabaseService = true
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const { createPool } = require('@sap/cds-foss').pool
|
|
2
|
+
|
|
3
|
+
class ConnectionPool {
|
|
4
|
+
constructor(factory, tenant) {
|
|
5
|
+
let bound_factory = { __proto__: factory, create: factory.create.bind(null, tenant) }
|
|
6
|
+
return _track_connections4(createPool(bound_factory, factory.options))
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// REVISIT: Is that really neccessary ?!
|
|
11
|
+
function _track_connections4(pool) {
|
|
12
|
+
const { acquire, release } = pool
|
|
13
|
+
return Object.assign(pool, {
|
|
14
|
+
async acquire() {
|
|
15
|
+
const connections = (this._trackedConnections ??= new Set())
|
|
16
|
+
try {
|
|
17
|
+
let dbc = await acquire.call(this)
|
|
18
|
+
connections.add((dbc._beginStack = new Error('begin called from:')))
|
|
19
|
+
return dbc
|
|
20
|
+
} catch (err) {
|
|
21
|
+
// TODO: add acquire timeout error check
|
|
22
|
+
err.stack += `\nActive connections:${connections.size}\n${[...connections].map(e => e.stack).join('\n')}`
|
|
23
|
+
throw err
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
release(dbc) {
|
|
28
|
+
this._trackedConnections?.delete(dbc._beginStack)
|
|
29
|
+
return release.call(this, dbc)
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = ConnectionPool
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const cds = require('@sap/cds/lib')
|
|
2
|
+
|
|
3
|
+
class SessionContext {
|
|
4
|
+
constructor(ctx) {
|
|
5
|
+
Object.defineProperty(this, 'ctx', { value: ctx })
|
|
6
|
+
}
|
|
7
|
+
get '$user.id'() {
|
|
8
|
+
return (super['$user.id'] = this.ctx.user?.id || 'anonymous')
|
|
9
|
+
}
|
|
10
|
+
get '$user.locale'() {
|
|
11
|
+
return (super['$user.locale'] = this.ctx.locale || cds.env.i18n.default_language)
|
|
12
|
+
}
|
|
13
|
+
// REVISIT: should be decided in spec meeting for definitive name
|
|
14
|
+
get $now() {
|
|
15
|
+
return (super.$now = (this.ctx.timestamp || new Date()).toISOString())
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class TemporalSessionContext extends SessionContext {
|
|
20
|
+
get '$valid.from'() {
|
|
21
|
+
return (super['$valid.from'] = this.ctx._?.['VALID-FROM'] ?? this.ctx._?.['VALID-AT'] ?? new Date().toISOString())
|
|
22
|
+
}
|
|
23
|
+
get '$valid.to'() {
|
|
24
|
+
return (super['$valid.to'] =
|
|
25
|
+
this.ctx._?.['VALID-TO'] ??
|
|
26
|
+
this.ctx._?.['VALID-AT']?.replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || '') ??
|
|
27
|
+
new Date().toISOString().replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || ''))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// REVISIT: only set temporal context if required!
|
|
32
|
+
module.exports = TemporalSessionContext
|
package/lib/cql-functions.js
CHANGED
|
@@ -31,7 +31,8 @@ const StandardFunctions = {
|
|
|
31
31
|
* @param {...string} args
|
|
32
32
|
* @returns {string}
|
|
33
33
|
*/
|
|
34
|
-
concat: (...args) => args.join('||'),
|
|
34
|
+
concat: (...args) => args.map(a => a.xpr ? `(${a})` : a).join(' || '),
|
|
35
|
+
|
|
35
36
|
/**
|
|
36
37
|
* Generates SQL statement that produces a boolean value indicating whether the first string contains the second string
|
|
37
38
|
* @param {...string} args
|
|
@@ -139,6 +140,11 @@ const StandardFunctions = {
|
|
|
139
140
|
round: (x, p) => `round(${x}${p ? `,${p}` : ''})`,
|
|
140
141
|
|
|
141
142
|
// Date and Time Functions
|
|
143
|
+
|
|
144
|
+
current_date: p => p ? `current_date(${p})`: 'current_date',
|
|
145
|
+
current_time: p => p ? `current_time(${p})`: 'current_time',
|
|
146
|
+
current_timestamp: p => p ? `current_timestamp(${p})`: 'current_timestamp',
|
|
147
|
+
|
|
142
148
|
/**
|
|
143
149
|
* Generates SQL statement that produces the year of a given timestamp
|
|
144
150
|
* @param {string} x
|
|
@@ -241,6 +247,13 @@ const StandardFunctions = {
|
|
|
241
247
|
)
|
|
242
248
|
) * 86400
|
|
243
249
|
)`,
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Generates SQL statement that calls the session_context function with the given parameter
|
|
253
|
+
* @param {string} x session variable name or SQL expression
|
|
254
|
+
* @returns {string}
|
|
255
|
+
*/
|
|
256
|
+
session_context: x => `session_context('${x.val}')`,
|
|
244
257
|
}
|
|
245
258
|
|
|
246
259
|
const HANAFunctions = {
|