@cap-js/db-service 1.0.1 → 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 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
+ ## 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
+
25
+ ## Version 1.1.0 - 2023-08-01
26
+
27
+ ### Fixed
28
+
29
+ - `UPDATE` with path expressions do not end up in a dump anymore. Instead, a proper error message is emitted.
30
+ - `UPDATE` is only noop if it does not include an element annotated with `@cds.on.update`.
31
+ - `SELECT` with `'*'` that is not expanded creates now a clearer error when the column name is required.
32
+ - `SELECT` with plain SQL statements will return correct result regardless of casing.
33
+ - View resolving for streams.
34
+
7
35
  ## Version 1.0.1 - 2023-07-03
8
36
 
9
37
  ### Fixed
@@ -12,7 +40,6 @@
12
40
  are now correctly substituted.
13
41
  - Mapping for OData `average` function to ANSI SQL compliant `avg` function.
14
42
 
15
-
16
43
  ## Version 1.0.0 - 2023-06-23
17
44
 
18
- - Initial Release
45
+ - Initial Release
package/index.js CHANGED
@@ -1,4 +1,18 @@
1
+ const DatabaseService = require('./lib/common/DatabaseService')
2
+ const SQLService = require('./lib/SQLService')
3
+ const CQN2SQL = require('./lib/cqn2sql').classDefinition
4
+
5
+ /**
6
+ * @template T
7
+ * @typedef {import('./lib/common/factory').Factory<T>} Factory
8
+ */
9
+
10
+ /**
11
+ * @typedef {import('./lib/SQLService').prototype.PreparedStatement} PreparedStatement
12
+ */
13
+
1
14
  module.exports = {
2
- DatabaseService: require('./lib/common/DatabaseService'),
3
- SQLService: require('./lib/SQLService'),
15
+ DatabaseService,
16
+ SQLService,
17
+ CQN2SQL,
4
18
  }
@@ -12,12 +12,17 @@ const USAGE_SAMPLE = async () => {
12
12
  }
13
13
 
14
14
  module.exports = class InsertResult {
15
+ /**
16
+ * @param {import('@sap/cds/apis/cqn').INSERT} query
17
+ * @param {unknown[]} results
18
+ */
15
19
  constructor(query, results) {
16
- this.query = query
20
+ // Storing query as non-enumerable property to avoid polluting trace output
21
+ Object.defineProperty(this, 'query', { value: query })
17
22
  this.results = results
18
23
  }
19
24
 
20
- /*
25
+ /**
21
26
  * Lazy access to auto-generated keys.
22
27
  */
23
28
  get [iterator]() {
@@ -70,8 +75,9 @@ module.exports = class InsertResult {
70
75
  })
71
76
  }
72
77
 
73
- /*
78
+ /**
74
79
  * the number of inserted (root) entries or the number of affectedRows in case of INSERT into SELECT
80
+ * @return {number}
75
81
  */
76
82
  get affectedRows() {
77
83
  const { INSERT: _ } = this.query
@@ -79,16 +85,28 @@ module.exports = class InsertResult {
79
85
  else return (super.affectedRows = _.entries?.length || _.rows?.length || this.results.length || 1)
80
86
  }
81
87
 
82
- /*
88
+ /**
83
89
  * for checks such as res > 2
90
+ * @return {number}
84
91
  */
85
92
  valueOf() {
86
93
  return this.affectedRows
87
94
  }
88
95
 
96
+ /**
97
+ * The last id of the auto incremented key column
98
+ * @param {unknown[]} result
99
+ * @returns {number}
100
+ */
89
101
  insertedRowId4(result) {
90
102
  return result.lastID
91
103
  }
104
+
105
+ /**
106
+ * Number of affected rows
107
+ * @param {unknown[]} result
108
+ * @returns {number}
109
+ */
92
110
  affectedRows4(result) {
93
111
  return result.changes
94
112
  }
package/lib/SQLService.js CHANGED
@@ -4,7 +4,21 @@ const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView
4
4
  const DatabaseService = require('./common/DatabaseService')
5
5
  const cqn4sql = require('./cqn4sql')
6
6
 
7
+ /**
8
+ * @callback next
9
+ * @param {Error} param0
10
+ * @returns {Promise<unknown>}
11
+ */
12
+
13
+ /**
14
+ * @callback Handler
15
+ * @param {import('@sap/cds/apis/services').Request} param0
16
+ * @param {next} param1
17
+ * @returns {Promise<unknown>}
18
+ */
19
+
7
20
  class SQLService extends DatabaseService {
21
+
8
22
  init() {
9
23
  this.on(['SELECT'], this.transformStreamFromCQN)
10
24
  this.on(['UPDATE'], this.transformStreamIntoCQN)
@@ -21,6 +35,7 @@ class SQLService extends DatabaseService {
21
35
  return super.init()
22
36
  }
23
37
 
38
+ /** @type {Handler} */
24
39
  async transformStreamFromCQN({ query }, next) {
25
40
  if (!query._streaming) return next()
26
41
  const cqn = STREAM.from(query.SELECT.from).column(query.SELECT.columns[0].ref[0])
@@ -29,6 +44,7 @@ class SQLService extends DatabaseService {
29
44
  return stream && { value: stream }
30
45
  }
31
46
 
47
+ /** @type {Handler} */
32
48
  async transformStreamIntoCQN({ query, data, target }, next) {
33
49
  let col, type, etag
34
50
  const elements = query._target?.elements || target?.elements
@@ -56,78 +72,107 @@ class SQLService extends DatabaseService {
56
72
  return result
57
73
  }
58
74
 
59
- /** Handler for SELECT */
75
+ /**
76
+ * Handler for SELECT
77
+ * @type {Handler}
78
+ */
60
79
  async onSELECT({ query, data }) {
61
- // REVISIT: disable this for queries like (SELECT 1)
62
- // Will return multiple rows with objects inside
63
- query.SELECT.expand = 'root'
64
80
  const { sql, values, cqn } = this.cqn2sql(query, data)
65
81
  let ps = await this.prepare(sql)
66
82
  let rows = await ps.all(values)
67
83
  if (rows.length)
68
84
  if (cqn.SELECT.expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
69
85
  if (cqn.SELECT.count) rows.$count = await this.count(query, rows)
70
- return cqn.SELECT.one || query.SELECT.from.ref?.[0].cardinality?.max === 1 ? rows[0] : rows
86
+ return cqn.SELECT.one || query.SELECT.from?.ref?.[0].cardinality?.max === 1 ? rows[0] : rows
71
87
  }
72
88
 
89
+ /**
90
+ * Handler for INSERT
91
+ * @type {Handler}
92
+ */
73
93
  async onINSERT({ query, data }) {
74
94
  const { sql, entries, cqn } = this.cqn2sql(query, data)
75
- 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
76
96
  const ps = await this.prepare(sql)
77
97
  const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run()
78
98
  return new this.class.InsertResults(cqn, results)
79
99
  }
80
100
 
101
+ /**
102
+ * Handler for UPSERT
103
+ * @type {Handler}
104
+ */
81
105
  async onUPSERT({ query, data }) {
82
106
  const { sql, entries } = this.cqn2sql(query, data)
83
- 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?
84
108
  const ps = await this.prepare(sql)
85
109
  const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run()
86
- return results.reduce((lastValue, currentValue) => (lastValue += currentValue.changes), 0)
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)
87
112
  }
88
113
 
89
- /** Handler for UPDATE */
114
+ /**
115
+ * Handler for UPDATE
116
+ * @type {Handler}
117
+ */
90
118
  async onUPDATE(req) {
91
- if (!req.query.UPDATE.data && !req.query.UPDATE.with) return 0
119
+ // noop if not a touch for @cds.on.update
120
+ if (
121
+ !req.query.UPDATE.data &&
122
+ !req.query.UPDATE.with &&
123
+ !Object.values(req.target?.elements || {}).some(e => e['@cds.on.update'])
124
+ )
125
+ return 0
92
126
  return this.onSIMPLE(req)
93
127
  }
94
128
 
95
- /** Handler for Stream */
129
+ /**
130
+ * Handler for Stream
131
+ * @type {Handler}
132
+ */
96
133
  async onSTREAM(req) {
97
- const { sql, values, entries } = this.cqn2sql(req.query)
134
+ const { one, sql, values } = this.cqn2sql(req.query)
98
135
  // writing stream
99
136
  if (req.query.STREAM.into) {
100
- const stream = entries[0]
101
- stream.on('error', () => stream.removeAllListeners('error'))
102
- values.unshift(stream)
103
137
  const ps = await this.prepare(sql)
104
138
  return (await ps.run(values)).changes
105
139
  }
106
140
  // reading stream
107
141
  const ps = await this.prepare(sql)
108
- let result = await ps.all(values)
109
- if (result.length === 0) return
110
-
111
- return Object.values(result[0])[0]
142
+ return ps.stream(values, one)
112
143
  }
113
144
 
114
- /** Handler for CREATE, DROP, UPDATE, DELETE, with simple CQN */
145
+ /**
146
+ * Handler for CREATE, DROP, UPDATE, DELETE, with simple CQN
147
+ * @type {Handler}
148
+ */
115
149
  async onSIMPLE({ query, data }) {
116
150
  const { sql, values } = this.cqn2sql(query, data)
117
151
  let ps = await this.prepare(sql)
118
152
  return (await ps.run(values)).changes
119
153
  }
120
154
 
121
- /** Handler for BEGIN, COMMIT, ROLLBACK, which don't have any CQN */
155
+ /**
156
+ * Handler for BEGIN, COMMIT, ROLLBACK, which don't have any CQN
157
+ * @type {Handler}
158
+ */
122
159
  async onEVENT({ event }) {
123
160
  DEBUG?.(event) // in the other cases above DEBUG happens in cqn2sql
124
161
  return await this.exec(event)
125
162
  }
126
163
 
127
- /** Handler for SQL statements which don't have any CQN */
164
+ /**
165
+ * Handler for SQL statements which don't have any CQN
166
+ * @type {Handler}
167
+ */
128
168
  async onPlainSQL({ query, data }, next) {
129
169
  if (typeof query === 'string') {
130
- DEBUG?.(query)
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)
131
176
  const ps = await this.prepare(query)
132
177
  const exec = this.hasResults(query) ? d => ps.all(d) : d => ps.run(d)
133
178
  if (Array.isArray(data) && typeof data[0] === 'object') return await Promise.all(data.map(exec))
@@ -135,12 +180,20 @@ class SQLService extends DatabaseService {
135
180
  } else return next()
136
181
  }
137
182
 
138
- /** Override in subclasses to detect more statements to be called with ps.all() */
183
+ /**
184
+ * Override in subclasses to detect more statements to be called with ps.all()
185
+ * @param {string} sql
186
+ */
139
187
  hasResults(sql) {
140
- return /^(SELECT|WITH|CALL|PRAGMA table_info)/.test(sql)
188
+ return /^(SELECT|WITH|CALL|PRAGMA table_info)/i.test(sql)
141
189
  }
142
190
 
143
- /** Derives and executes a query to fill in `$count` for given query */
191
+ /**
192
+ * Derives and executes a query to fill in `$count` for given query
193
+ * @param {import('@sap/cds/apis/cqn').SELECT} query - SELECT CQN
194
+ * @param {unknown[]} ret - Results of the original query
195
+ * @returns {Promise<number>}
196
+ */
144
197
  async count(query, ret) {
145
198
  if (ret) {
146
199
  const { one, limit: _ } = query.SELECT,
@@ -148,8 +201,9 @@ class SQLService extends DatabaseService {
148
201
  const [max, offset = 0] = one ? [1] : _ ? [_.rows?.val, _.offset?.val] : []
149
202
  if (max === undefined || (n < max && (n || !offset))) return n + offset
150
203
  }
204
+ // REVISIT: made uppercase count because of HANA reserved word quoting
151
205
  const cq = cds.ql.clone(query, {
152
- columns: [{ func: 'count' }],
206
+ columns: [{ func: 'count', as: 'COUNT' }],
153
207
  localized: false,
154
208
  expand: false,
155
209
  limit: 0,
@@ -157,10 +211,14 @@ class SQLService extends DatabaseService {
157
211
  })
158
212
  const { sql, values } = this.cqn2sql(cq)
159
213
  const ps = await this.prepare(sql)
160
- const { count } = await ps.get(values)
161
- return count
214
+ const { count, COUNT } = await ps.get(values)
215
+ return count ?? COUNT
162
216
  }
163
217
 
218
+ /**
219
+ * Helper class for results of INSERTs.
220
+ * Subclasses may override this.
221
+ */
164
222
  static InsertResults = require('./InsertResults')
165
223
 
166
224
  /**
@@ -168,71 +226,110 @@ class SQLService extends DatabaseService {
168
226
  * Subclasses commonly override this.
169
227
  */
170
228
  static CQN2SQL = require('./cqn2sql').class
171
- constructor() {
172
- super(...arguments)
229
+
230
+ /** @param {unknown[]} args */
231
+ constructor(...args) {
232
+ super(...args)
233
+ /** @type {unknown} */
173
234
  this.class = new.target // for IntelliSense
174
235
  }
175
- cqn2sql(q, values) {
176
- const cqn = this.cqn4sql(q)
177
-
178
- const cmd = cqn.cmd || Object.keys(cqn)[0]
179
- if (cmd in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
180
- let resolvedCqn = resolveView(cqn, this.model, this)
181
- if (resolvedCqn && resolvedCqn[cmd]._transitions?.[0].target) {
182
- resolvedCqn = resolvedCqn || cqn
183
- resolvedCqn.target = resolvedCqn?.[cmd]._transitions[0].target || cqn.target
184
- }
185
- return new this.class.CQN2SQL(this.context).render(resolvedCqn, values)
236
+
237
+ /**
238
+ * @param {import('@sap/cds/apis/cqn').Query} query
239
+ * @param {unknown} values
240
+ * @returns {typeof SQLService.CQN2SQL}
241
+ */
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?
186
251
  }
187
- return new this.class.CQN2SQL(this.context).render(cqn, values)
252
+ let cqn2sql = new this.class.CQN2SQL(this)
253
+ return cqn2sql.render(q, values)
188
254
  }
255
+
256
+ /**
257
+ * @param {import('@sap/cds/apis/cqn').Query} q
258
+ * @returns {import('./infer/cqn').Query}
259
+ */
189
260
  cqn4sql(q) {
190
- // REVISIT: move this check to cqn4sql?
191
- 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)
192
262
  return cqn4sql(q, this.model)
193
263
  }
194
264
 
195
265
  /**
196
266
  * Returns a Promise which resolves to a prepared statement object with
197
267
  * `{run,get,all}` signature as specified in {@link PreparedStatement}.
268
+ * @abstract
269
+ * @param {string} sql The SQL String to be prepared
198
270
  * @returns {PreparedStatement}
199
271
  */
200
- // eslint-disable-next-line no-unused-vars
201
- async prepare(/*sql*/) {
272
+ async prepare(sql) {
273
+ sql
202
274
  throw '2b overridden by subclass'
203
275
  }
204
276
 
205
277
  /**
206
278
  * Used to execute simple SQL statement like BEGIN, COMMIT, ROLLBACK
279
+ * @param {string} sql
280
+ * @returns {Promise<unknown>} The result of the query
207
281
  */
208
- // eslint-disable-next-line no-unused-vars
209
282
  async exec(sql) {
283
+ sql
210
284
  throw '2b overridden by subclass'
211
285
  }
212
286
  }
213
287
 
214
- /** Interface of prepared statement objects as returned by {@link SQLService#prepare} */
288
+ /**
289
+ * Interface of prepared statement objects as returned by {@link SQLService#prepare}
290
+ * @class
291
+ * @interface
292
+ */
215
293
  class PreparedStatement {
216
- // eslint-disable-line no-unused-vars
294
+
217
295
  /**
218
296
  * Executes a prepared DML query, i.e., INSERT, UPDATE, DELETE, CREATE, DROP
219
- * @param {[]|{}} binding_params
297
+ * @abstract
298
+ * @param {unknown|unknown[]} binding_params
220
299
  */
221
- async run(/*binding_params*/) {} // eslint-disable-line no-unused-vars
300
+ async run(binding_params) {
301
+ binding_params
302
+ return 0
303
+ }
222
304
  /**
223
305
  * Executes a prepared SELECT query and returns a single/first row only
224
- * @param {[]|{}} binding_params
306
+ * @abstract
307
+ * @param {unknown|unknown[]} binding_params
308
+ * @returns {Promise<unknown>}
225
309
  */
226
- async get(/*binding_params*/) {
310
+ async get(binding_params) {
311
+ binding_params
227
312
  return {}
228
- } // eslint-disable-line no-unused-vars
313
+ }
229
314
  /**
230
315
  * Executes a prepared SELECT query and returns an array of all rows
231
- * @param {[]|{}} binding_params
316
+ * @abstract
317
+ * @param {unknown|unknown[]} binding_params
318
+ * @returns {Promise<unknown[]>}
232
319
  */
233
- async all(/*binding_params*/) {
320
+ async all(binding_params) {
321
+ binding_params
234
322
  return [{}]
235
- } // eslint-disable-line no-unused-vars
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
+ }
236
333
  }
237
334
  SQLService.prototype.PreparedStatement = PreparedStatement
238
335
 
@@ -246,29 +343,26 @@ const _target_name4 = q => {
246
343
  q.CREATE?.entity ||
247
344
  q.DROP?.entity ||
248
345
  q.STREAM?.from ||
249
- q.STREAM?.into ||
250
- undefined
251
- 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')
252
348
  if (!target?.ref) return target
253
349
  const [first] = target.ref
254
350
  return first.id || first
255
351
  }
256
352
 
257
353
  const _unquirked = q => {
258
- if (typeof q.INSERT?.into === 'string') q.INSERT.into = { ref: [q.INSERT.into] }
259
- if (typeof q.UPSERT?.into === 'string') q.UPSERT.into = { ref: [q.UPSERT.into] }
260
- if (typeof q.UPDATE?.entity === 'string') q.UPDATE.entity = { ref: [q.UPDATE.entity] }
261
- if (typeof q.DELETE?.from === 'string') q.DELETE.from = { ref: [q.DELETE.from] }
262
- if (typeof q.CREATE?.entity === 'string') q.CREATE.entity = { ref: [q.CREATE.entity] }
263
- if (typeof q.DROP?.entity === 'string') q.DROP.entity = { ref: [q.DROP.entity] }
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] }
264
362
  return q
265
363
  }
266
364
 
267
- const sqls = new (class extends SQLService {
268
- get factory() {
269
- return null
270
- }
271
- })()
365
+ const sqls = new class extends SQLService { get factory() { return null } }
272
366
  cds.extend(cds.ql.Query).with(
273
367
  class {
274
368
  forSQL() {
@@ -276,6 +370,7 @@ cds.extend(cds.ql.Query).with(
276
370
  return this.flat(cqn)
277
371
  }
278
372
  toSQL() {
373
+ if (this.SELECT) this.SELECT.expand = 'root' // Enforces using json functions always for top-level SELECTS
279
374
  let { sql, values } = (cds.db || sqls).cqn2sql(this)
280
375
  return { sql, values } // skipping .cqn property
281
376
  }
@@ -285,4 +380,5 @@ cds.extend(cds.ql.Query).with(
285
380
  },
286
381
  )
287
382
 
288
- module.exports = Object.assign(SQLService, { _target_name4 })
383
+ Object.assign(SQLService, { _target_name4 })
384
+ module.exports = SQLService