@cap-js/db-service 1.5.0 → 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,47 @@
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
+
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)
36
+
37
+
38
+ ### Fixed
39
+
40
+ * **cqn2sql:** supporting calculated elements ([#387](https://github.com/cap-js/cds-dbs/issues/387)) ([2153fb9](https://github.com/cap-js/cds-dbs/commit/2153fb9a3910cd4afa3a91918e6cf682646492b7))
41
+ * do not rely on db constraints for deep delete ([#390](https://github.com/cap-js/cds-dbs/issues/390)) ([9623af6](https://github.com/cap-js/cds-dbs/commit/9623af64db97cfe15ef07b659635850fc908f77c))
42
+
43
+
44
+ ### Performance Improvements
45
+
46
+ * HANA list placeholder ([#380](https://github.com/cap-js/cds-dbs/issues/380)) ([3eadfea](https://github.com/cap-js/cds-dbs/commit/3eadfea7b94f485030cc8bd0bd298ce088586422))
47
+
7
48
  ## [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
49
 
9
50
 
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}
@@ -153,7 +189,8 @@ class SQLService extends DatabaseService {
153
189
  }
154
190
 
155
191
  get onDELETE() {
156
- return super.onDELETE = cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : deep_delete
192
+ // REVISIT: It's not yet 100 % clear under which circumstances we can rely on db constraints
193
+ return (super.onDELETE = /* cds.env.features.assert_integrity === 'db' ? this.onSIMPLE : */ deep_delete)
157
194
  async function deep_delete(/** @type {Request} */ req) {
158
195
  let { compositions } = req.target
159
196
  if (compositions) {
@@ -162,24 +199,29 @@ class SQLService extends DatabaseService {
162
199
  if (typeof from === 'string') from = { ref: [from] }
163
200
  if (where) {
164
201
  let last = from.ref.at(-1)
165
- if (last.where) [ last, where ] = [ last.id, [ { xpr: last.where }, 'and', { xpr: where } ] ]
166
- 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 }] }
167
204
  }
168
205
  // Process child compositions depth-first
169
- let { depth=0, visited=[] } = req
170
- visited.push (req.target.name)
171
- await Promise.all (Object.values(compositions).map(c => {
172
- if (c._target['@cds.persistence.skip'] === true) return
173
- if (c._target === req.target) { // the Genre.children case
174
- if (++depth > (c['@depth'] || 3)) return
175
- } else if (visited.includes(c._target.name)) throw new Error(
176
- `Transitive circular composition detected: \n\n`+
177
- ` ${visited.join(' > ')} > ${c._target.name} \n\n`+
178
- `These are not supported by deep delete.`)
179
- // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
180
- const query = DELETE.from({ref:[ ...from.ref, c.name ]})
181
- return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
182
- }))
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
+ )
183
225
  }
184
226
  return this.onSIMPLE(req)
185
227
  }
@@ -232,8 +274,8 @@ class SQLService extends DatabaseService {
232
274
  // REVISIT: made uppercase count because of HANA reserved word quoting
233
275
  const cq = SELECT.one([{ func: 'count', as: 'COUNT' }]).from(
234
276
  cds.ql.clone(query, {
235
- localized: false,
236
- expand: false,
277
+ localized: false,
278
+ expand: false,
237
279
  limit: undefined,
238
280
  orderBy: undefined,
239
281
  }),
@@ -258,10 +300,12 @@ class SQLService extends DatabaseService {
258
300
  // preserves $count for .map calls on array
259
301
  static _arrayWithCount = function (a, count) {
260
302
  const _map = a.map
261
- const map = function (..._) { return SQLService._arrayWithCount(_map.call(a, ..._), count) }
303
+ const map = function (..._) {
304
+ return SQLService._arrayWithCount(_map.call(a, ..._), count)
305
+ }
262
306
  return Object.defineProperties(a, {
263
307
  $count: { value: count, enumerable: false, configurable: true, writable: true },
264
- map: { value: map, enumerable: false, configurable: true, writable: true }
308
+ map: { value: map, enumerable: false, configurable: true, writable: true },
265
309
  })
266
310
  }
267
311
 
@@ -279,10 +323,8 @@ class SQLService extends DatabaseService {
279
323
  */
280
324
  cqn2sql(query, values) {
281
325
  let q = this.cqn4sql(query)
282
- if (q.SELECT && 'elements' in q) q.SELECT.expand ??= 'root'
283
-
284
326
  let kind = q.kind || Object.keys(q)[0]
285
- 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 }) {
286
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?
287
329
  let target = q[kind]._transitions?.[0].target
288
330
  if (target) q.target = target // REVISIT: Why isn't that done in resolveView?
@@ -296,7 +338,14 @@ class SQLService extends DatabaseService {
296
338
  * @returns {import('./infer/cqn').Query}
297
339
  */
298
340
  cqn4sql(q) {
299
- 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
+ }
300
349
  return cqn4sql(q, this.model)
301
350
  }
302
351
 
@@ -329,7 +378,6 @@ class SQLService extends DatabaseService {
329
378
  * @interface
330
379
  */
331
380
  class PreparedStatement {
332
-
333
381
  /**
334
382
  * Executes a prepared DML query, i.e., INSERT, UPDATE, DELETE, CREATE, DROP
335
383
  * @abstract
@@ -379,9 +427,7 @@ const _target_name4 = q => {
379
427
  q.UPDATE?.entity ||
380
428
  q.DELETE?.from ||
381
429
  q.CREATE?.entity ||
382
- q.DROP?.entity ||
383
- q.STREAM?.from ||
384
- q.STREAM?.into
430
+ q.DROP?.entity
385
431
  if (target?.SET?.op === 'union') throw new cds.error('UNION-based queries are not supported')
386
432
  if (!target?.ref) return target
387
433
  const [first] = target.ref
@@ -400,8 +446,11 @@ const _unquirked = q => {
400
446
  return q
401
447
  }
402
448
 
403
-
404
- 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
+ })()
405
454
  cds.extend(cds.ql.Query).with(
406
455
  class {
407
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