@cap-js/db-service 1.5.1 → 1.6.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 CHANGED
@@ -4,6 +4,34 @@
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.6.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.5.1...db-service-v1.6.0) (2024-02-02)
8
+
9
+
10
+ ### Added
11
+
12
+ * Add fallback for @cap-js/hana for unknown entities ([#403](https://github.com/cap-js/cds-dbs/issues/403)) ([e7dd6de](https://github.com/cap-js/cds-dbs/commit/e7dd6de4ef65881ef66f7ba9c164ff2b4e9b1111))
13
+ * SELECT returns binaries as Buffers ([#416](https://github.com/cap-js/cds-dbs/issues/416)) ([d4240d5](https://github.com/cap-js/cds-dbs/commit/d4240d5efb7789851593c83a430e601d6ff87118))
14
+ * SELECT returns LargeBinaries as streams unless feature flag "stream_compat" is set ([#251](https://github.com/cap-js/cds-dbs/issues/251)) ([8165a4a](https://github.com/cap-js/cds-dbs/commit/8165a4a3f6bb21c970668c8873f9d9c662b43780))
15
+ * strict mode to validate input for `INSERT`, `UPDATE` and `UPSERT` ([#384](https://github.com/cap-js/cds-dbs/issues/384)) ([4644483](https://github.com/cap-js/cds-dbs/commit/464448384145d934933c473ae2f20d49cc75554d))
16
+ * Support Readable Streams inside INSERT.entries ([#343](https://github.com/cap-js/cds-dbs/issues/343)) ([f6faf89](https://github.com/cap-js/cds-dbs/commit/f6faf8955b7888479c66f1727ade65b382611c2f))
17
+
18
+
19
+ ### Fixed
20
+
21
+ * **`cqn4sql`:** only transform list if necessary ([#438](https://github.com/cap-js/cds-dbs/issues/438)) ([8a7ec65](https://github.com/cap-js/cds-dbs/commit/8a7ec65fe46c2dae668bb536671943a76d5e8206))
22
+ * always generate unique subquery aliases ([#435](https://github.com/cap-js/cds-dbs/issues/435)) ([c875b7d](https://github.com/cap-js/cds-dbs/commit/c875b7d07a83693febb2543d202fd53b43172f7b))
23
+ * consider `list` in `from.where` ([#429](https://github.com/cap-js/cds-dbs/issues/429)) ([3288e94](https://github.com/cap-js/cds-dbs/commit/3288e943f53a2ba08d97018e016c06932b5c8f88))
24
+ * **cqn2sql:** $user.locale refs ([#431](https://github.com/cap-js/cds-dbs/issues/431)) ([ec55276](https://github.com/cap-js/cds-dbs/commit/ec55276409ccd56d8b831bbff3d3915e078d3f72))
25
+ * **cqn4sql:** expand structured keys in on-conditions ([#421](https://github.com/cap-js/cds-dbs/issues/421)) ([b1e0677](https://github.com/cap-js/cds-dbs/commit/b1e06777ccfce80f50443e61a10ae5d86c6bc232))
26
+ * Do not generate UUIDs for association keys ([#398](https://github.com/cap-js/cds-dbs/issues/398)) ([9970e14](https://github.com/cap-js/cds-dbs/commit/9970e14352679711a9c60807608becff05151fc4))
27
+ * enumeration issue with session context in @cap-js/hana ([#399](https://github.com/cap-js/cds-dbs/issues/399)) ([8106a20](https://github.com/cap-js/cds-dbs/commit/8106a207543be700d37b1f1b510d00d5dd1370e4))
28
+ * make @cap-js/sqlite work with better-sqlite3@9.3.0 ([#422](https://github.com/cap-js/cds-dbs/issues/422)) ([44c0a59](https://github.com/cap-js/cds-dbs/commit/44c0a59277b14be0b81b7f80555e18377ddbfe3c))
29
+ * pass context of navigation for list within infix filter ([#433](https://github.com/cap-js/cds-dbs/issues/433)) ([0ca077f](https://github.com/cap-js/cds-dbs/commit/0ca077f071a1569fa3b46f6ccfb003feaebd1ea0))
30
+ * Restore former deep upsert behavior / error ([#406](https://github.com/cap-js/cds-dbs/issues/406)) ([284b1e3](https://github.com/cap-js/cds-dbs/commit/284b1e3a605957d91dd867794ab1a7dcdf345c40))
31
+ * Skip virtual fields on UPSERTs ([#405](https://github.com/cap-js/cds-dbs/issues/405)) ([1a05dcb](https://github.com/cap-js/cds-dbs/commit/1a05dcb1d032a85e826c76f5a8a710161fa2b679))
32
+ * sqlite date string compatibility parsing only for valid dates ([#410](https://github.com/cap-js/cds-dbs/issues/410)) ([2a8bb2d](https://github.com/cap-js/cds-dbs/commit/2a8bb2d60940760c6280d8cc06100cb9087194b5)), closes [#409](https://github.com/cap-js/cds-dbs/issues/409)
33
+ * UPSERT for @cap-js/hana for entities with multiple keys ([#418](https://github.com/cap-js/cds-dbs/issues/418)) ([9bbac6e](https://github.com/cap-js/cds-dbs/commit/9bbac6ebbbddfa2f620833ce195eedeb0a79f43e))
34
+
7
35
  ## [1.5.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.5.0...db-service-v1.5.1) (2023-12-20)
8
36
 
9
37
 
package/lib/SQLService.js CHANGED
@@ -1,9 +1,15 @@
1
1
  const cds = require('@sap/cds/lib'),
2
2
  DEBUG = cds.debug('sql|db')
3
+ const { Readable } = require('stream')
3
4
  const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
4
5
  const DatabaseService = require('./common/DatabaseService')
5
6
  const cqn4sql = require('./cqn4sql')
6
7
 
8
+ const BINARY_TYPES = {
9
+ 'cds.Binary': 1,
10
+ 'cds.hana.BINARY': 1
11
+ }
12
+
7
13
  /** @typedef {import('@sap/cds/apis/services').Request} Request */
8
14
 
9
15
  /**
@@ -14,12 +20,29 @@ const cqn4sql = require('./cqn4sql')
14
20
  */
15
21
 
16
22
  class SQLService extends DatabaseService {
17
-
18
23
  init() {
19
- this.on(['SELECT'], this.transformStreamFromCQN)
20
- this.on(['UPDATE'], this.transformStreamIntoCQN)
21
- this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT: should be replaced by correct input processing eventually
24
+ this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT should be replaced by correct input processing eventually
22
25
  this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
26
+ if (cds.env.features.db_strict) {
27
+ this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => {
28
+ const elements = query.target?.elements; if (!elements) return
29
+ const kind = query.kind || Object.keys(query)[0]
30
+ const operation = query[kind]
31
+ if (!operation.columns && !operation.entries && !operation.data) return
32
+ const columns =
33
+ operation.columns ||
34
+ Object.keys(
35
+ operation.data || operation.entries?.reduce((acc, obj) => {
36
+ return Object.assign(acc, obj)
37
+ }, {}),
38
+ )
39
+ const invalidColumns = columns.filter(c => !(c in elements))
40
+
41
+ if (invalidColumns.length > 0) {
42
+ cds.error(`STRICT MODE: Trying to ${kind} non existent columns (${invalidColumns})`)
43
+ }
44
+ })
45
+ }
23
46
  this.on(['SELECT'], this.onSELECT)
24
47
  this.on(['INSERT'], this.onINSERT)
25
48
  this.on(['UPSERT'], this.onUPSERT)
@@ -27,46 +50,63 @@ class SQLService extends DatabaseService {
27
50
  this.on(['DELETE'], this.onDELETE)
28
51
  this.on(['CREATE ENTITY', 'DROP ENTITY'], this.onSIMPLE)
29
52
  this.on(['BEGIN', 'COMMIT', 'ROLLBACK'], this.onEVENT)
30
- this.on(['STREAM'], this.onSTREAM)
31
53
  this.on(['*'], this.onPlainSQL)
32
54
  return super.init()
33
55
  }
34
56
 
35
- /** @type {Handler} */
36
- async transformStreamFromCQN({ query }, next) {
37
- if (!query._streaming) return next()
38
- const cqn = STREAM.from(query.SELECT.from).column(query.SELECT.columns[0].ref[0])
39
- if (query.SELECT.where) cqn.STREAM.where = query.SELECT.where
40
- const stream = await this.run(cqn)
41
- return stream && { value: stream }
42
- }
57
+ _changeToStreams(columns, rows, one, compat) {
58
+ if (!rows || !columns) return
59
+ if (!Array.isArray(rows)) rows = [rows]
60
+ if (!rows.length || !Object.keys(rows[0]).length) return
43
61
 
44
- /** @type {Handler} */
45
- async transformStreamIntoCQN({ query, data, target }, next) {
46
- let col, type, etag
47
- const elements = query._target?.elements || target?.elements
48
- if (!elements) next()
49
- for (const key in elements) {
50
- const element = elements[key]
51
- if (element['@Core.MediaType'] && data[key]?.pipe) col = key
52
- if (element['@Core.IsMediaType'] && data[key]) type = key
53
- if (element['@odata.etag'] && data[key]) etag = key
62
+ // REVISIT: remove after removing stream_compat feature flag
63
+ if (compat) {
64
+ rows[0][Object.keys(rows[0])[0]] = this._stream(Object.values(rows[0])[0])
65
+ return
54
66
  }
55
67
 
56
- if (!col) return next()
57
-
58
- const cqn = STREAM.into(query.UPDATE.entity).column(col).data(data[col])
59
- if (query.UPDATE.where) cqn.STREAM.where = query.UPDATE.where
60
- const result = await this.run(cqn)
61
- if (type || etag) {
62
- const d = { ...data }
63
- delete d[col]
64
- const cqn = UPDATE.entity(query.UPDATE.entity).with(d)
65
- if (query.UPDATE.where) cqn.UPDATE.where = query.UPDATE.where
66
- await this.run(cqn)
68
+ for (let col of columns) {
69
+ const name = col.as || col.ref?.[col.ref.length - 1] || (typeof col === 'string' && col)
70
+ if (col.element?.isAssociation) {
71
+ if (one) this._changeToStreams(col.SELECT.columns, rows[0][name], false, compat)
72
+ else
73
+ rows.forEach(row => {
74
+ this._changeToStreams(col.SELECT.columns, row[name], false, compat)
75
+ })
76
+ } else if (col.element?.type === 'cds.LargeBinary') {
77
+ if (one) rows[0][name] = this._stream(rows[0][name])
78
+ else
79
+ rows.forEach(row => {
80
+ row[name] = this._stream(row[name])
81
+ })
82
+ } else if (col.element?.type in BINARY_TYPES) {
83
+ if (one) rows[0][name] = this._buffer(rows[0][name])
84
+ else
85
+ rows.forEach(row => {
86
+ row[name] = this._buffer(row[name])
87
+ })
88
+ }
67
89
  }
90
+ }
68
91
 
69
- return result
92
+ _stream(val) {
93
+ if (val === null) return null
94
+ if (val instanceof Readable) return val
95
+ // Buffer.from only applies encoding when the input is a string
96
+ let raw = typeof val === 'string' ? Buffer.from(val.toString(), 'base64') : val
97
+ return new Readable({
98
+ read(size) {
99
+ if (raw.length === 0) return this.push(null)
100
+ const chunk = raw.slice(0, size)
101
+ raw = raw.slice(size)
102
+ this.push(chunk)
103
+ },
104
+ })
105
+ }
106
+
107
+ _buffer(val) {
108
+ if (val === null) return null
109
+ return Buffer.from(val, 'base64')
70
110
  }
71
111
 
72
112
  /**
@@ -74,15 +114,27 @@ class SQLService extends DatabaseService {
74
114
  * @type {Handler}
75
115
  */
76
116
  async onSELECT({ query, data }) {
117
+ query.SELECT.expand = 'root'
77
118
  const { sql, values, cqn } = this.cqn2sql(query, data)
78
119
  let ps = await this.prepare(sql)
79
120
  let rows = await ps.all(values)
80
121
  if (rows.length)
81
122
  if (cqn.SELECT.expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
123
+
124
+ if (cds.env.features.stream_compat) {
125
+ if (query._streaming) {
126
+ this._changeToStreams(cqn.SELECT.columns, rows, true, true)
127
+ return rows.length ? { value: Object.values(rows[0])[0] } : undefined
128
+ }
129
+ } else {
130
+ this._changeToStreams(cqn.SELECT.columns, rows, query.SELECT.one, false)
131
+ }
132
+
82
133
  if (cqn.SELECT.count) {
83
134
  // REVISIT: the runtime always expects that the count is preserved with .map, required for renaming in mocks
84
135
  return SQLService._arrayWithCount(rows, await this.count(query, rows))
85
136
  }
137
+
86
138
  return cqn.SELECT.one || query.SELECT.from?.ref?.[0].cardinality?.max === 1 ? rows[0] : rows
87
139
  }
88
140
 
@@ -126,22 +178,6 @@ class SQLService extends DatabaseService {
126
178
  return this.onSIMPLE(req)
127
179
  }
128
180
 
129
- /**
130
- * Handler for Stream
131
- * @type {Handler}
132
- */
133
- async onSTREAM(req) {
134
- const { one, sql, values } = this.cqn2sql(req.query)
135
- // writing stream
136
- if (req.query.STREAM.into) {
137
- const ps = await this.prepare(sql)
138
- return (await ps.run(values)).changes
139
- }
140
- // reading stream
141
- const ps = await this.prepare(sql)
142
- return ps.stream(values, one)
143
- }
144
-
145
181
  /**
146
182
  * Handler for CREATE, DROP, UPDATE, DELETE, with simple CQN
147
183
  * @type {Handler}
@@ -154,7 +190,7 @@ class SQLService extends DatabaseService {
154
190
 
155
191
  get onDELETE() {
156
192
  // REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
157
- return super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete
193
+ return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
158
194
  async function deep_delete(/** @type {Request} */ req) {
159
195
  let { compositions } = req.target
160
196
  if (compositions) {
@@ -163,24 +199,29 @@ class SQLService extends DatabaseService {
163
199
  if (typeof from === 'string') from = { ref: [from] }
164
200
  if (where) {
165
201
  let last = from.ref.at(-1)
166
- if (last.where) [ last, where ] = [ last.id, [ { xpr: last.where }, 'and', { xpr: where } ] ]
167
- from = {ref:[ ...from.ref.slice(0,-1), { id: last, where }]}
202
+ if (last.where) [last, where] = [last.id, [{ xpr: last.where }, 'and', { xpr: where }]]
203
+ from = { ref: [...from.ref.slice(0, -1), { id: last, where }] }
168
204
  }
169
205
  // Process child compositions depth-first
170
- let { depth=0, visited=[] } = req
171
- visited.push (req.target.name)
172
- await Promise.all (Object.values(compositions).map(c => {
173
- if (c._target['@cds.persistence.skip'] === true) return
174
- if (c._target === req.target) { // the Genre.children case
175
- if (++depth > (c['@depth'] || 3)) return
176
- } else if (visited.includes(c._target.name)) throw new Error(
177
- `Transitive circular composition detected: \n\n`+
178
- ` ${visited.join(' > ')} > ${c._target.name} \n\n`+
179
- `These are not supported by deep delete.`)
180
- // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
181
- const query = DELETE.from({ref:[ ...from.ref, c.name ]})
182
- return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
183
- }))
206
+ let { depth = 0, visited = [] } = req
207
+ visited.push(req.target.name)
208
+ await Promise.all(
209
+ Object.values(compositions).map(c => {
210
+ if (c._target['@cds.persistence.skip'] === true) return
211
+ if (c._target === req.target) {
212
+ // the Genre.children case
213
+ if (++depth > (c['@depth'] || 3)) return
214
+ } else if (visited.includes(c._target.name))
215
+ throw new Error(
216
+ `Transitive circular composition detected: \n\n` +
217
+ ` ${visited.join(' > ')} > ${c._target.name} \n\n` +
218
+ `These are not supported by deep delete.`,
219
+ )
220
+ // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
221
+ const query = DELETE.from({ ref: [...from.ref, c.name] })
222
+ return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
223
+ }),
224
+ )
184
225
  }
185
226
  return this.onSIMPLE(req)
186
227
  }
@@ -233,8 +274,8 @@ class SQLService extends DatabaseService {
233
274
  // REVISIT: made uppercase count because of HANA reserved word quoting
234
275
  const cq = SELECT.one([{ func: 'count', as: 'COUNT' }]).from(
235
276
  cds.ql.clone(query, {
236
- localized: false,
237
- expand: false,
277
+ localized: false,
278
+ expand: false,
238
279
  limit: undefined,
239
280
  orderBy: undefined,
240
281
  }),
@@ -259,10 +300,12 @@ class SQLService extends DatabaseService {
259
300
  // preserves $count for .map calls on array
260
301
  static _arrayWithCount = function (a, count) {
261
302
  const _map = a.map
262
- const map = function (..._) { return SQLService._arrayWithCount(_map.call(a, ..._), count) }
303
+ const map = function (..._) {
304
+ return SQLService._arrayWithCount(_map.call(a, ..._), count)
305
+ }
263
306
  return Object.defineProperties(a, {
264
307
  $count: { value: count, enumerable: false, configurable: true, writable: true },
265
- map: { value: map, enumerable: false, configurable: true, writable: true }
308
+ map: { value: map, enumerable: false, configurable: true, writable: true },
266
309
  })
267
310
  }
268
311
 
@@ -280,10 +323,8 @@ class SQLService extends DatabaseService {
280
323
  */
281
324
  cqn2sql(query, values) {
282
325
  let q = this.cqn4sql(query)
283
- if (q.SELECT && 'elements' in q) q.SELECT.expand ??= 'root'
284
-
285
326
  let kind = q.kind || Object.keys(q)[0]
286
- if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 } || q.STREAM?.into) {
327
+ if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
287
328
  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
329
  let target = q[kind]._transitions?.[0].target
289
330
  if (target) q.target = target // REVISIT: Why isn't that done in resolveView?
@@ -297,7 +338,14 @@ class SQLService extends DatabaseService {
297
338
  * @returns {import('./infer/cqn').Query}
298
339
  */
299
340
  cqn4sql(q) {
300
- if (!q.SELECT?.from?.join && !q.SELECT?.from?.SELECT && !this.model?.definitions[_target_name4(q)]) return _unquirked(q)
341
+ if (
342
+ !cds.env.features.db_strict &&
343
+ !q.SELECT?.from?.join &&
344
+ !q.SELECT?.from?.SELECT &&
345
+ !this.model?.definitions[_target_name4(q)]
346
+ ) {
347
+ return _unquirked(q)
348
+ }
301
349
  return cqn4sql(q, this.model)
302
350
  }
303
351
 
@@ -330,7 +378,6 @@ class SQLService extends DatabaseService {
330
378
  * @interface
331
379
  */
332
380
  class PreparedStatement {
333
-
334
381
  /**
335
382
  * Executes a prepared DML query, i.e., INSERT, UPDATE, DELETE, CREATE, DROP
336
383
  * @abstract
@@ -380,9 +427,7 @@ const _target_name4 = q => {
380
427
  q.UPDATE?.entity ||
381
428
  q.DELETE?.from ||
382
429
  q.CREATE?.entity ||
383
- q.DROP?.entity ||
384
- q.STREAM?.from ||
385
- q.STREAM?.into
430
+ q.DROP?.entity
386
431
  if (target?.SET?.op === 'union') throw new cds.error('UNION-based queries are not supported')
387
432
  if (!target?.ref) return target
388
433
  const [first] = target.ref
@@ -401,8 +446,11 @@ const _unquirked = q => {
401
446
  return q
402
447
  }
403
448
 
404
-
405
- const sqls = new class extends SQLService { get factory() { return null } }
449
+ const sqls = new (class extends SQLService {
450
+ get factory() {
451
+ return null
452
+ }
453
+ })()
406
454
  cds.extend(cds.ql.Query).with(
407
455
  class {
408
456
  forSQL() {
@@ -128,22 +128,14 @@ class DatabaseService extends cds.Service {
128
128
  }
129
129
 
130
130
  // REVISIT: should happen automatically after a configurable time
131
- /**
132
- * @param {string} tenant
133
- */
134
- async disconnect(tenant) {
135
- const _disconnect = async tenant => {
136
- const pool = this.pools[tenant]
137
- if (!pool) {
138
- return
139
- }
131
+ async disconnect (tenant) {
132
+ const tenants = tenant ? [tenant] : Object.keys(this.pools)
133
+ await Promise.all (tenants.map (async t => {
134
+ const pool = this.pools[t]; if (!pool) return
140
135
  await pool.drain()
141
136
  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)
137
+ delete this.pools[t]
138
+ }))
147
139
  }
148
140
 
149
141
  /**
@@ -28,5 +28,19 @@ class TemporalSessionContext extends SessionContext {
28
28
  }
29
29
  }
30
30
 
31
+ // Set all getters as enumerable
32
+ const iterate = { enumerable: true }
33
+ const getters = (obj) => {
34
+ const prot = obj.prototype
35
+ const patch = {}
36
+ for (const [key, value] of Object.entries(Object.getOwnPropertyDescriptors(prot))) {
37
+ if (!value.get) continue
38
+ patch[key] = iterate
39
+ }
40
+ Object.defineProperties(prot, patch)
41
+ }
42
+ getters(SessionContext)
43
+ getters(TemporalSessionContext)
44
+
31
45
  // REVISIT: only set temporal context if required!
32
46
  module.exports = TemporalSessionContext