@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/lib/cqn2sql.js CHANGED
@@ -2,6 +2,8 @@ const cds = require('@sap/cds/lib')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
4
 
5
+ const { Readable } = require('stream')
6
+
5
7
  const DEBUG = (() => {
6
8
  let DEBUG = cds.debug('sql-json')
7
9
  if (DEBUG) return DEBUG
@@ -14,32 +16,58 @@ const DEBUG = (() => {
14
16
  })()
15
17
 
16
18
  class CQN2SQLRenderer {
17
- constructor(context) {
18
- this.context = cds.context || context
19
+ /**
20
+ * Creates a new CQN2SQL instance for processing a query
21
+ * @constructor
22
+ * @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
23
+ */
24
+ constructor(srv) {
25
+ this.context = srv?.context || cds.context // Using srv.context is required due to stakeholders doing unmanaged txs without cds.context being set
19
26
  this.class = new.target // for IntelliSense
20
27
  this.class._init() // is a noop for subsequent calls
21
28
  }
29
+
30
+ static _add_mixins (aspect, mixins) {
31
+ const fqn = this.name + aspect
32
+ const types = cds.builtin.types
33
+ for (let each in mixins) {
34
+ const def = types[each]
35
+ if (!def) continue
36
+ Object.defineProperty(def, fqn, { value: mixins[each] })
37
+ }
38
+ return fqn
39
+ }
40
+
41
+ /**
42
+ * Initializes the class one first creation to link types to data converters
43
+ */
22
44
  static _init() {
23
- const _add_mixins = (aspect, mixins) => {
24
- const fqn = this.name + aspect
25
- const types = cds.builtin.types
26
- for (let each in mixins) {
27
- const def = types[each]
28
- if (!def) continue
29
- Object.defineProperty(def, fqn, { value: mixins[each] })
30
- }
31
- return fqn
45
+ this._localized = this._add_mixins(':localized', this.localized)
46
+ this._convertInput = this._add_mixins(':convertInput', this.InputConverters)
47
+ this._convertOutput = this._add_mixins(':convertOutput', this.OutputConverters)
48
+ this._sqlType = this._add_mixins(':sqlType', this.TypeMap)
49
+ // Have all-uppercase all-lowercase, and capitalized keywords to speed up lookups
50
+ for (let each in this.ReservedWords) {
51
+ // ORDER
52
+ this.ReservedWords[each[0] + each.slice(1).toLowerCase()] = 1 // Order
53
+ this.ReservedWords[each.toLowerCase()] = 1 // order
32
54
  }
33
- this._localized = _add_mixins(':localized', this.localized)
34
- this._convertInput = _add_mixins(':convertInput', this.InputConverters)
35
- this._convertOutput = _add_mixins(':convertOutput', this.OutputConverters)
36
- this._sqlType = _add_mixins(':sqlType', this.TypeMap)
37
55
  this._init = () => {} // makes this a noop for subsequent calls
38
56
  }
39
57
 
58
+ /**
59
+ * Renders incoming query into SQL and generates binding values
60
+ * @param {import('./infer/cqn').Query} q CQN query to be rendered
61
+ * @param {unknown[]|undefined} vars Values to be used for params
62
+ * @returns {CQN2SQLRenderer|unknown}
63
+ */
40
64
  render(q, vars) {
41
65
  const cmd = q.cmd || Object.keys(q)[0] // SELECT, INSERT, ...
66
+ /**
67
+ * @type {string} the rendered SQL string
68
+ */
42
69
  this.sql = '' // to have it as first property for debugging
70
+ /** @type {unknown[]} */
43
71
  this.values = [] // prepare values, filled in by subroutines
44
72
  this[cmd]((this.cqn = q)) // actual sql rendering happens here
45
73
  if (vars?.length && !this.values.length) this.values = vars
@@ -51,12 +79,21 @@ class CQN2SQLRenderer {
51
79
  return this
52
80
  }
53
81
 
82
+ /**
83
+ * Links the incoming query with the current service model
84
+ * @param {import('./infer/cqn').Query} q
85
+ * @returns {import('./infer/cqn').Query}
86
+ */
54
87
  infer(q) {
55
88
  return q.target ? q : cds_infer(q)
56
89
  }
57
90
 
58
91
  // CREATE Statements ------------------------------------------------
59
92
 
93
+ /**
94
+ * Renders a CREATE query into generic SQL
95
+ * @param {import('./infer/cqn').CREATE} q
96
+ */
60
97
  CREATE(q) {
61
98
  const { target } = q,
62
99
  { query } = target
@@ -71,6 +108,11 @@ class CQN2SQLRenderer {
71
108
  return
72
109
  }
73
110
 
111
+ /**
112
+ * Renders a column clause for the given elements
113
+ * @param {import('./infer/cqn').elements} elements
114
+ * @returns {string} SQL
115
+ */
74
116
  CREATE_elements(elements) {
75
117
  let sql = ''
76
118
  for (let e in elements) {
@@ -82,11 +124,21 @@ class CQN2SQLRenderer {
82
124
  return sql.slice(0, -2)
83
125
  }
84
126
 
127
+ /**
128
+ * Renders a column definition for the given element
129
+ * @param {import('./infer/cqn').element} element
130
+ * @returns {string} SQL
131
+ */
85
132
  CREATE_element(element) {
86
133
  const type = this.type4(element)
87
134
  if (type) return this.quote(element.name) + ' ' + type
88
135
  }
89
136
 
137
+ /**
138
+ * Renders the SQL type definition for the given element
139
+ * @param {import('./infer/cqn').element} element
140
+ * @returns {string}
141
+ */
90
142
  type4(element) {
91
143
  if (!element._type) element = cds.builtin.types[element.type] || element
92
144
  const fn = element[this.class._sqlType]
@@ -95,6 +147,9 @@ class CQN2SQLRenderer {
95
147
  )
96
148
  }
97
149
 
150
+ /** @callback converter */
151
+
152
+ /** @type {Object<string,import('@sap/cds/apis/csn').Definition>} */
98
153
  static TypeMap = {
99
154
  // Utilizing cds.linked inheritance
100
155
  String: e => `NVARCHAR(${e.length || 5000})`,
@@ -120,6 +175,10 @@ class CQN2SQLRenderer {
120
175
 
121
176
  // DROP Statements ------------------------------------------------
122
177
 
178
+ /**
179
+ * Renders a DROP query into generic SQL
180
+ * @param {import('./infer/cqn').DROP} q
181
+ */
123
182
  DROP(q) {
124
183
  const { target } = q
125
184
  const isView = target.query || target.projection
@@ -128,98 +187,146 @@ class CQN2SQLRenderer {
128
187
 
129
188
  // SELECT Statements ------------------------------------------------
130
189
 
190
+ /**
191
+ * Renders a SELECT statement into generic SQL
192
+ * @param {import('./infer/cqn').SELECT} q
193
+ */
131
194
  SELECT(q) {
132
195
  let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized } = q.SELECT
133
- if (!expand) expand = q.SELECT.expand = has_expands(q) || has_arrays(q)
134
196
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
135
197
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
136
198
  let columns = this.SELECT_columns(q)
137
- let x,
138
- sql = `SELECT`
199
+ let sql = `SELECT`
139
200
  if (distinct) sql += ` DISTINCT`
140
- if (!_empty((x = columns))) sql += ` ${x}`
141
- if (!_empty((x = from))) sql += ` FROM ${this.from(x)}`
142
- if (!_empty((x = where))) sql += ` WHERE ${this.where(x)}`
143
- if (!_empty((x = groupBy))) sql += ` GROUP BY ${this.groupBy(x)}`
144
- if (!_empty((x = having))) sql += ` HAVING ${this.having(x)}`
145
- if (!_empty((x = orderBy))) sql += ` ORDER BY ${this.orderBy(x, localized)}`
146
- if (one) sql += ` LIMIT ${this.limit({ rows: { val: 1 } })}`
147
- else if ((x = limit)) sql += ` LIMIT ${this.limit(x)}`
148
- if (expand) sql = this.SELECT_expand(q, sql)
201
+ if (!_empty(columns)) sql += ` ${columns}`
202
+ if (!_empty(from)) sql += ` FROM ${this.from(from)}`
203
+ if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
204
+ if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
205
+ if (!_empty(having)) sql += ` HAVING ${this.having(having)}`
206
+ if (!_empty(orderBy)) sql += ` ORDER BY ${this.orderBy(orderBy, localized)}`
207
+ if (one) limit = Object.assign({}, limit, { rows: { val: 1 } })
208
+ if (limit) sql += ` LIMIT ${this.limit(limit)}`
209
+ // Expand cannot work without an inferred query
210
+ if (expand) {
211
+ // REVISIT: Why don't we handle that as an error in SELECT_expand?
212
+ if (!q.elements) cds.error`Query was not inferred and includes expand. For which the metadata is missing.`
213
+ sql = this.SELECT_expand(q, sql)
214
+ }
149
215
  return (this.sql = sql)
150
216
  }
151
217
 
152
- SELECT_columns({ SELECT }) {
153
- // REVISIT: We don't have to run x.as through this.column_name(), do we?
154
- if (!SELECT.columns) return '*'
155
- return SELECT.columns.map(x => this.column_expr(x) + (typeof x.as === 'string' ? ' as ' + this.quote(x.as) : ''))
218
+ /**
219
+ * Renders a column clause into generic SQL
220
+ * @param {import('./infer/cqn').SELECT} param0
221
+ * @returns {string} SQL
222
+ */
223
+ SELECT_columns(q) {
224
+ return (q.SELECT.columns ?? ['*']).map(x => this.column_expr(x, q))
156
225
  }
157
226
 
227
+ /**
228
+ * Renders a JSON select around the provided SQL statement
229
+ * @param {import('./infer/cqn').SELECT} param0
230
+ * @param {string} sql
231
+ * @returns {string} SQL
232
+ */
158
233
  SELECT_expand({ SELECT, elements }, sql) {
159
234
  if (!SELECT.columns) return sql
160
- if (!elements) return sql
161
- let cols = !SELECT.columns
162
- ? ['*']
163
- : SELECT.columns.map(x => {
164
- const name = this.column_name(x)
165
- // REVISIT: can be removed when alias handling is resolved properly
166
- const d = elements[name] || elements[name.substring(1, name.length - 1)]
167
- let col = `'$."${name}"',${this.output_converter4(d, this.quote(name))}`
168
-
169
- if (x.SELECT?.count) {
170
- // Return both the sub select and the count for @odata.count
171
- const qc = cds.ql.clone(x, { columns: [{ func: 'count' }], one: 1, limit: 0, orderBy: 0 })
172
- col += `, '$."${name}@odata.count"',${this.expr(qc)}`
173
- }
174
- return col
175
- })
235
+ if (!elements) return sql // REVISIT: Above we say this is an error condition, but here we say it's ok?
236
+ let cols = SELECT.columns.map(x => {
237
+ const name = this.column_name(x)
238
+ let col = `'$."${name}"',${this.output_converter4(x.element, this.quote(name))}`
239
+ if (x.SELECT?.count) {
240
+ // Return both the sub select and the count for @odata.count
241
+ const qc = cds.ql.clone(x, { columns: [{ func: 'count' }], one: 1, limit: 0, orderBy: 0 })
242
+ col += `, '$."${name}@odata.count"',${this.expr(qc)}`
243
+ }
244
+ return col
245
+ })
176
246
 
177
247
  // Prevent SQLite from hitting function argument limit of 100
178
- let colsLength = cols.length
179
248
  let obj = "'{}'"
180
- for (let i = 0; i < colsLength; i += 48) {
249
+ for (let i = 0; i < cols.length; i += 48) {
181
250
  obj = `json_insert(${obj},${cols.slice(i, i + 48)})`
182
251
  }
183
252
  return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj})`} as _json_ FROM (${sql})`
184
253
  }
185
254
 
186
- column_expr(x) {
187
- if (x.func && !x.as) x.as = x.func
255
+ /**
256
+ * Renders a SELECT column expression into generic SQL
257
+ * @param {import('./infer/cqn').col} x
258
+ * @returns {string} SQL
259
+ */
260
+ column_expr(x, q) {
261
+ if (x === '*') return '*'
262
+ ///////////////////////////////////////////////////////////////////////////////////////
263
+ // REVISIT: that should move out of here!
188
264
  if (x?.element?.['@cds.extension']) {
189
- x.as = x.as || x.element.name
190
- return `extensions__->${this.string('$."' + x.element.name + '"')}`
265
+ return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
191
266
  }
267
+ ///////////////////////////////////////////////////////////////////////////////////////
192
268
  let sql = this.expr(x)
269
+ let alias = this.column_alias4(x, q)
270
+ if (alias) sql += ' as ' + this.quote(alias)
193
271
  return sql
194
272
  }
195
273
 
274
+ /**
275
+ * Extracts the column alias from a SELECT column expression
276
+ * @param {import('./infer/cqn').col} x
277
+ * @returns {string}
278
+ */
279
+ column_alias4(x) {
280
+ return typeof x.as === 'string' ? x.as : x.func
281
+ }
282
+
283
+ /**
284
+ * Renders a FROM clause into generic SQL
285
+ * @param {import('./infer/cqn').source} from
286
+ * @returns {string} SQL
287
+ */
196
288
  from(from) {
197
- const { ref, as } = from,
198
- _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
289
+ const { ref, as } = from
290
+ const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
199
291
  if (ref) return _aliased(this.quote(this.name(ref[0])))
200
292
  if (from.SELECT) return _aliased(`(${this.SELECT(from)})`)
201
- if (from.join) {
202
- const {
203
- join,
204
- args: [left, right],
205
- on,
206
- } = from
207
- return `${this.from(left)} ${join} JOIN ${this.from(right)} ON ${this.xpr({ xpr: on })}`
208
- }
293
+ if (from.join)
294
+ return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
209
295
  }
210
296
 
297
+ /**
298
+ * Renders a WHERE clause into generic SQL
299
+ * @param {import('./infer/cqn').predicate} xpr
300
+ * @returns {string} SQL
301
+ */
211
302
  where(xpr) {
212
303
  return this.xpr({ xpr })
213
304
  }
214
305
 
306
+ /**
307
+ * Renders a HAVING clause into generic SQL
308
+ * @param {import('./infer/cqn').predicate} xpr
309
+ * @returns {string} SQL
310
+ */
215
311
  having(xpr) {
216
312
  return this.xpr({ xpr })
217
313
  }
218
314
 
315
+ /**
316
+ * Renders a groupBy clause into generic SQL
317
+ * @param {import('./infer/cqn').expr[]} clause
318
+ * @returns {string[] | string} SQL
319
+ */
219
320
  groupBy(clause) {
220
321
  return clause.map(c => this.expr(c))
221
322
  }
222
323
 
324
+ /**
325
+ * Renders an orderBy clause into generic SQL
326
+ * @param {import('./infer/cqn').ordering_term[]} orderBy
327
+ * @param {boolean | undefined} localized
328
+ * @returns {string[] | string} SQL
329
+ */
223
330
  orderBy(orderBy, localized) {
224
331
  return orderBy.map(
225
332
  localized
@@ -231,6 +338,12 @@ class CQN2SQLRenderer {
231
338
  )
232
339
  }
233
340
 
341
+ /**
342
+ * Renders an limit clause into generic SQL
343
+ * @param {import('./infer/cqn').limit} param0
344
+ * @returns {string} SQL
345
+ * @throws {Error} When no rows are defined
346
+ */
234
347
  limit({ rows, offset }) {
235
348
  if (!rows) throw new Error('Rows parameter is missing in SELECT.limit(rows, offset)')
236
349
  return !offset ? rows.val : `${rows.val} OFFSET ${offset.val}`
@@ -238,6 +351,11 @@ class CQN2SQLRenderer {
238
351
 
239
352
  // INSERT Statements ------------------------------------------------
240
353
 
354
+ /**
355
+ * Renders an INSERT query into generic SQL
356
+ * @param {import('./infer/cqn').INSERT} q
357
+ * @returns {string} SQL
358
+ */
241
359
  INSERT(q) {
242
360
  const { INSERT } = q
243
361
  return INSERT.entries
@@ -251,6 +369,11 @@ class CQN2SQLRenderer {
251
369
  : cds.error`Missing .entries, .rows, or .values in ${q}`
252
370
  }
253
371
 
372
+ /**
373
+ * Renders an INSERT query with entries property
374
+ * @param {import('./infer/cqn').INSERT} q
375
+ * @returns {string} SQL
376
+ */
254
377
  INSERT_entries(q) {
255
378
  const { INSERT } = q
256
379
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
@@ -262,6 +385,8 @@ class CQN2SQLRenderer {
262
385
  const columns = elements
263
386
  ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].isAssociation)
264
387
  : ObjectKeys(INSERT.entries[0])
388
+
389
+ /** @type {string[]} */
265
390
  this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true).map(c => this.quote(c))
266
391
 
267
392
  const extractions = this.managed(
@@ -288,12 +413,19 @@ class CQN2SQLRenderer {
288
413
  .filter(a => a)
289
414
  .map(c => c.sql)
290
415
 
291
- this.entries = [[JSON.stringify(INSERT.entries)]]
292
- return (this.sql = `INSERT INTO ${entity}${alias ? ' as ' + this.quote(alias) : ''} (${
416
+ // Include this.values for placeholders
417
+ /** @type {unknown[][]} */
418
+ this.entries = [[...this.values, JSON.stringify(INSERT.entries)]]
419
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
293
420
  this.columns
294
421
  }) SELECT ${extraction} FROM json_each(?)`)
295
422
  }
296
423
 
424
+ /**
425
+ * Renders an INSERT query with rows property
426
+ * @param {import('./infer/cqn').INSERT} q
427
+ * @returns {string} SQL
428
+ */
297
429
  INSERT_rows(q) {
298
430
  const { INSERT } = q
299
431
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
@@ -302,14 +434,11 @@ class CQN2SQLRenderer {
302
434
  if (!INSERT.columns && !elements) {
303
435
  throw cds.error`Cannot insert rows without columns or elements`
304
436
  }
305
- let columns = INSERT.columns || (elements && ObjectKeys(elements))
306
- if (elements) {
307
- columns = columns.filter(c => c in elements && !elements[c].virtual && !elements[c].isAssociation)
308
- }
437
+ let columns = INSERT.columns || (elements && ObjectKeys(elements).filter(c => !elements[c].virtual && !elements[c].isAssociation))
309
438
  this.columns = columns.map(c => this.quote(c))
310
439
 
311
440
  const inputConverterKey = this.class._convertInput
312
- const extraction = columns.map((c, i) => {
441
+ const extraction = columns.map((c,i) => {
313
442
  const element = elements?.[c] || {}
314
443
  const extract = `value->>'$[${i}]'`
315
444
  const converter = element[inputConverterKey] || (e => e)
@@ -317,16 +446,26 @@ class CQN2SQLRenderer {
317
446
  })
318
447
 
319
448
  this.entries = [[JSON.stringify(INSERT.rows)]]
320
- return (this.sql = `INSERT INTO ${entity}${alias ? ' as ' + this.quote(alias) : ''} (${
449
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
321
450
  this.columns
322
451
  }) SELECT ${extraction} FROM json_each(?)`)
323
452
  }
324
453
 
454
+ /**
455
+ * Renders an INSERT query with values property
456
+ * @param {import('./infer/cqn').INSERT} q
457
+ * @returns {string} SQL
458
+ */
325
459
  INSERT_values(q) {
326
460
  let { columns, values } = q.INSERT
327
461
  return this.INSERT_rows({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
328
462
  }
329
463
 
464
+ /**
465
+ * Renders an INSERT query from SELECT query
466
+ * @param {import('./infer/cqn').INSERT} q
467
+ * @returns {string} SQL
468
+ */
330
469
  INSERT_select(q) {
331
470
  const { INSERT } = q
332
471
  const entity = this.name(q.target.name)
@@ -342,19 +481,32 @@ class CQN2SQLRenderer {
342
481
  return this.sql
343
482
  }
344
483
 
484
+ /**
485
+ * Wraps the provided SQL expression for output processing
486
+ * @param {import('./infer/cqn').element} element
487
+ * @param {string} expr
488
+ * @returns {string} SQL
489
+ */
345
490
  output_converter4(element, expr) {
346
491
  const fn = element?.[this.class._convertOutput]
347
492
  return fn?.(expr, element) || expr
348
493
  }
349
494
 
495
+ /** @type {import('./converters').Converters} */
350
496
  static InputConverters = {} // subclasses to override
351
497
 
498
+ /** @type {import('./converters').Converters} */
352
499
  static OutputConverters = {} // subclasses to override
353
500
 
354
501
  static localized = { String: true, UUID: false }
355
502
 
356
503
  // UPSERT Statements ------------------------------------------------
357
504
 
505
+ /**
506
+ * Renders an UPSERT query into generic SQL
507
+ * @param {import('./infer/cqn').UPDATE} q
508
+ * @returns {string} SQL
509
+ */
358
510
  UPSERT(q) {
359
511
  let { UPSERT } = q,
360
512
  sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
@@ -376,31 +528,32 @@ class CQN2SQLRenderer {
376
528
 
377
529
  // UPDATE Statements ------------------------------------------------
378
530
 
531
+ /**
532
+ * Renders an UPDATE query into generic SQL
533
+ * @param {import('./infer/cqn').UPDATE} q
534
+ * @returns {string} SQL
535
+ */
379
536
  UPDATE(q) {
380
- const {
381
- UPDATE: { entity, with: _with, data, where },
382
- } = q,
383
- elements = q.target?.elements
537
+ const { entity, with: _with, data, where } = q.UPDATE
538
+ const elements = q.target?.elements
384
539
  let sql = `UPDATE ${this.name(entity.ref?.[0] || entity)}`
385
540
  if (entity.as) sql += ` AS ${entity.as}`
541
+
386
542
  let columns = []
387
- if (data)
388
- for (let c in data)
543
+ if (data) _add (data, val => this.val({val}))
544
+ if (_with) _add (_with, x => this.expr(x))
545
+ function _add (data, sql4) {
546
+ for (let c in data) {
389
547
  if (!elements || (c in elements && !elements[c].virtual)) {
390
- columns.push({ name: c, sql: this.val({ val: data[c] }) })
391
- }
392
- if (_with)
393
- for (let c in _with)
394
- if (!elements || (c in elements && !elements[c].virtual)) {
395
- columns.push({ name: c, sql: this.expr(_with[c]) })
548
+ columns.push({ name: c, sql: sql4(data[c]) })
396
549
  }
550
+ }
551
+ }
397
552
 
398
553
  columns = columns.map(c => {
399
- if (q.elements?.[c.name]?.['@cds.extension']) {
400
- return {
401
- name: 'extensions__',
402
- sql: `json_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
403
- }
554
+ if (q.elements?.[c.name]?.['@cds.extension']) return {
555
+ name: 'extensions__',
556
+ sql: `json_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
404
557
  }
405
558
  return c
406
559
  })
@@ -414,6 +567,11 @@ class CQN2SQLRenderer {
414
567
 
415
568
  // DELETE Statements ------------------------------------------------
416
569
 
570
+ /**
571
+ * Renders a DELETE query into generic SQL
572
+ * @param {import('./infer/cqn').DELETE} param0
573
+ * @returns {string} SQL
574
+ */
417
575
  DELETE({ DELETE: { from, where } }) {
418
576
  let sql = `DELETE FROM ${this.from(from)}`
419
577
  if (where) sql += ` WHERE ${this.where(where)}`
@@ -422,27 +580,79 @@ class CQN2SQLRenderer {
422
580
 
423
581
  // STREAM Statement -------------------------------------------------
424
582
 
583
+ /**
584
+ * Renders a STREAM query into generic SQL
585
+ * @param {import('./infer/cqn').STREAM} q
586
+ * @returns {string} SQL
587
+ */
425
588
  STREAM(q) {
426
- let { from, into, where, column, data } = q.STREAM
427
- let x, sql
428
- // reading stream
429
- if (from) {
430
- sql = `SELECT`
431
- if (!_empty((x = column))) sql += ` ${this.quote(x)}`
432
- if (!_empty((x = from))) sql += ` FROM ${this.from(x)}`
589
+ const { STREAM } = q
590
+ return STREAM.from
591
+ ? this.STREAM_from(q)
592
+ : STREAM.into
593
+ ? this.STREAM_into(q)
594
+ : cds.error`Missing .form or .into in ${q}`
595
+ }
596
+
597
+ /**
598
+ * Renders a STREAM.into query into generic SQL
599
+ * @param {import('./infer/cqn').STREAM} q
600
+ * @returns {string} SQL
601
+ */
602
+ STREAM_into(q) {
603
+ const { into, column, where, data } = q.STREAM
604
+
605
+ let sql
606
+ if (!_empty(column)) {
607
+ data.type = 'binary'
608
+ const update = UPDATE(into)
609
+ .with({ [column]: data })
610
+ .where(where)
611
+ Object.defineProperty(update, 'target', { value: q.target })
612
+ sql = this.UPDATE(update)
433
613
  } else {
434
- // writing stream
435
- const entity = this.name(q.target?.name || into.ref[0])
436
- sql = `UPDATE ${this.quote(entity)}${into.as ? ` AS ${into.as}` : ``} SET ${this.quote(column)}=?`
437
- this.entries = [data]
614
+ data.type = 'json'
615
+ // REVISIT: decide whether dataset streams should behave like INSERT or UPSERT
616
+ sql = this.UPSERT(UPSERT([{}]).into(into).forSQL())
617
+ this.values = [data]
438
618
  }
439
- if (!_empty((x = where))) sql += ` WHERE ${this.where(x)}`
440
- if (from) sql += ` LIMIT ${this.limit({ rows: { val: 1 } })}`
619
+
441
620
  return (this.sql = sql)
442
621
  }
443
622
 
623
+ /**
624
+ * Renders a STREAM.from query into generic SQL
625
+ * @param {import('./infer/cqn').STREAM} q
626
+ * @returns {string} SQL
627
+ */
628
+ STREAM_from(q) {
629
+ const { column, from, where, columns } = q.STREAM
630
+
631
+ const select = cds.ql
632
+ .SELECT(column ? [column] : columns)
633
+ .where(where)
634
+ .limit(column ? 1 : undefined)
635
+
636
+ // SELECT.from() does not accept joins
637
+ select.SELECT.from = from
638
+
639
+ if (column) {
640
+ this.one = true
641
+ } else {
642
+ select.SELECT.expand = 'root'
643
+ this.one = !!from.SELECT?.one
644
+ }
645
+ return this.SELECT(select.forSQL())
646
+ }
647
+
444
648
  // Expression Clauses ---------------------------------------------
445
649
 
650
+ /**
651
+ * Renders an expression object into generic SQL
652
+ * @param {import('./infer/cqn').expr} x
653
+ * @returns {string} SQL
654
+ * @throws {Error} When an unknown un supported expression is provided
655
+ */
446
656
  expr(x) {
447
657
  const wrap = x.cast ? sql => `cast(${sql} as ${this.type4(x.cast)})` : sql => sql
448
658
  if (typeof x === 'string') throw cds.error`Unsupported expr: ${x}`
@@ -456,6 +666,11 @@ class CQN2SQLRenderer {
456
666
  else throw cds.error`Unsupported expr: ${x}`
457
667
  }
458
668
 
669
+ /**
670
+ * Renders an list of expression objects into generic SQL
671
+ * @param {import('./infer/cqn').xpr} param0
672
+ * @returns {string} SQL
673
+ */
459
674
  xpr({ xpr }) {
460
675
  return xpr
461
676
  .map((x, i) => {
@@ -467,125 +682,209 @@ class CQN2SQLRenderer {
467
682
  .join(' ')
468
683
  }
469
684
 
685
+ /**
686
+ * Renders an operation into generic SQL
687
+ * @param {string} x The current operator string
688
+ * @param {Number} i Current index of the operator inside the xpr
689
+ * @param {import('./infer/cqn').predicate[]} xpr The parent xpr in which the operator is used
690
+ * @returns {string} The correct operator string
691
+ */
470
692
  operator(x, i, xpr) {
471
- if (x === '=' && xpr[i + 1]?.val === null) return 'is'
472
- if (x === '!=') return 'is not'
693
+
694
+ // Translate = to IS NULL for rhs operand being NULL literal
695
+ if (x === '=') return xpr[i+1]?.val === null ? 'is' : '='
696
+
697
+ // Translate == to IS NOT NULL for rhs operand being NULL literal, otherwise ...
698
+ // Translate == to IS NOT DISTINCT FROM, unless both operands cannot be NULL
699
+ if (x === '==') return xpr[i+1]?.val === null ? 'is' : _not_null(i-1) && _not_null(i+1) ? '=' : this.is_not_distinct_from_
700
+
701
+ // Translate != to IS NULL for rhs operand being NULL literal, otherwise...
702
+ // Translate != to IS DISTINCT FROM, unless both operands cannot be NULL
703
+ if (x === '!=') return xpr[i+1]?.val === null ? 'is not' : _not_null(i-1) && _not_null(i+1) ? '<>' : this.is_distinct_from_
704
+
473
705
  else return x
706
+
707
+ /** Checks if the operand at xpr[i+-1] can be NULL. @returns true if not */
708
+ function _not_null(i) {
709
+ const operand = xpr[i]
710
+ if (!operand) return false
711
+ if (operand.val != null) return true // non-null values are not null
712
+ let element = operand.element
713
+ if (!element) return false
714
+ if (element.key) return true // primary keys usually should not be null
715
+ if (element.notNull) return true // not null elements cannot be null
716
+ }
474
717
  }
475
718
 
719
+ get is_distinct_from_() { return 'is distinct from' }
720
+ get is_not_distinct_from_() { return 'is not distinct from' }
721
+
722
+ /**
723
+ * Renders an argument place holder into the SQL for prepared statements
724
+ * @param {import('./infer/cqn').ref} param0
725
+ * @returns {string} SQL
726
+ * @throws {Error} When an unsupported ref definition is provided
727
+ */
476
728
  param({ ref }) {
477
729
  if (ref.length > 1) throw cds.error`Unsupported nested ref parameter: ${ref}`
478
730
  return ref[0] === '?' ? '?' : `:${ref}`
479
731
  }
480
732
 
733
+ /**
734
+ * Renders a ref into generic SQL
735
+ * @param {import('./infer/cqn').ref} param0
736
+ * @returns {string} SQL
737
+ */
481
738
  ref({ ref }) {
482
- return ref.map(r => this.quote(r)).join('.')
739
+ switch (ref[0]) {
740
+ case '$now': return this.func({ func: 'session_context', args: [{ val: '$now' }]})
741
+ case '$user':
742
+ case '$user.id': return this.func({ func: 'session_context', args: [{ val: '$user.id' }]})
743
+ default: return ref.map(r => this.quote(r)).join('.')
744
+ }
483
745
  }
484
746
 
747
+ /**
748
+ * Renders a value into the correct SQL syntax of a placeholder for a prepared statement
749
+ * @param {import('./infer/cqn').val} param0
750
+ * @returns {string} SQL
751
+ */
485
752
  val({ val }) {
486
753
  switch (typeof val) {
487
- case 'function':
488
- throw new Error('Function values not supported.')
489
- case 'undefined':
490
- return 'NULL'
491
- case 'boolean':
492
- return val
493
- case 'number':
494
- return val // REVISIT for HANA
754
+ case 'function': throw new Error('Function values not supported.')
755
+ case 'undefined': return 'NULL'
756
+ case 'boolean': return `${val}`
757
+ case 'number': return `${val}` // REVISIT for HANA
495
758
  case 'object':
496
759
  if (val === null) return 'NULL'
497
760
  if (val instanceof Date) return `'${val.toISOString()}'`
498
- if (Buffer.isBuffer(val)) val = val.toString('base64')
499
- else val = this.regex(val) || this.json(val)
761
+ if (val instanceof Readable) ; // go on with default below
762
+ else if (Buffer.isBuffer(val)) val = val.toString('base64')
763
+ else if (is_regexp(val)) val = val.source
764
+ else val = JSON.stringify(val)
765
+ case 'string': // eslint-disable-line no-fallthrough
500
766
  }
501
767
  if (!this.values) return this.string(val)
502
- this.values.push(val)
768
+ else this.values.push(val)
503
769
  return '?'
504
770
  }
505
771
 
506
772
  static Functions = require('./cql-functions')
773
+ /**
774
+ * Renders a function call into mapped SQL definitions from the Functions definition
775
+ * @param {import('./infer/cqn').func} param0
776
+ * @returns {string} SQL
777
+ */
507
778
  func({ func, args }) {
508
779
  args = (args || []).map(e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) }))
509
780
  return this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
510
781
  }
511
782
 
783
+ /**
784
+ * Renders a list into generic SQL
785
+ * @param {import('./infer/cqn').list} param0
786
+ * @returns {string} SQL
787
+ */
512
788
  list({ list }) {
513
789
  return `(${list.map(e => this.expr(e))})`
514
790
  }
515
791
 
516
- regex(o) {
517
- if (is_regexp(o)) return o.source
518
- }
519
-
520
- json(o) {
521
- return JSON.stringify(o)
522
- }
523
-
792
+ /**
793
+ * Renders a javascript string into a SQL string literal
794
+ * @param {string} s
795
+ * @returns {string} SQL
796
+ */
524
797
  string(s) {
525
798
  return `'${s.replace(/'/g, "''")}'`
526
799
  }
527
800
 
801
+ /**
802
+ * Calculates the effect column name
803
+ * @param {import('./infer/cqn').col} col
804
+ * @returns {string} explicit/implicit column alias
805
+ */
528
806
  column_name(col) {
529
- return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.ref[col.ref.length - 1]
807
+ if (col === '*')
808
+ // REVISIT: When could this ever happen? I think this is only about that irrealistic test whech uses column_name to implement SELECT_columns. We should eliminate column_name as its only used and designed for use in SELECT_expand, isn't it?
809
+ cds.error`Query was not inferred and includes '*' in the columns. For which there is no column name available.`
810
+ return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
530
811
  }
531
812
 
813
+ /**
814
+ * Calculates the Database name of the given name
815
+ * @param {string|import('./infer/cqn').ref} name
816
+ * @returns {string} Database name
817
+ */
532
818
  name(name) {
533
819
  return (name.id || name).replace(/\./g, '_')
534
820
  }
535
821
 
822
+ /** @type {unknown} */
536
823
  static ReservedWords = {}
824
+ /**
825
+ * Ensures that the given identifier is properly quoted when required by the database
826
+ * @param {string} s
827
+ * @returns {string} SQL
828
+ */
537
829
  quote(s) {
538
830
  if (typeof s !== 'string') return '"' + s + '"'
539
831
  if (s.includes('"')) return '"' + s.replace(/"/g, '""') + '"'
540
- if (s.toUpperCase() in this.class.ReservedWords || /^\d|[$' @./\\]/.test(s)) return '"' + s + '"'
832
+ // Column names like "Order" clash with "ORDER" keyword so toUpperCase is required
833
+ if (s in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
541
834
  return s
542
835
  }
543
836
 
837
+ /**
838
+ * Convers the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
839
+ * @param {object[]} columns
840
+ * @param {import('./infer/cqn').elements} elements
841
+ * @param {Boolean} isUpdate
842
+ * @returns {string[]} Array of SQL expressions for processing input JSON data
843
+ */
544
844
  managed(columns, elements, isUpdate = false) {
545
845
  const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
546
- const inputConverterKey = this.class._convertInput
846
+ const { _convertInput } = this.class
547
847
  // Ensure that missing managed columns are added
548
848
  const requiredColumns = !elements
549
849
  ? []
550
850
  : Object.keys(elements)
551
851
  .filter(
552
852
  e =>
553
- (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual)) &&
853
+ (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
554
854
  !columns.find(c => c.name === e),
555
855
  )
556
856
  .map(name => ({ name, sql: 'NULL' }))
557
857
 
558
858
  return [...columns, ...requiredColumns].map(({ name, sql }) => {
559
- const element = elements?.[name] || {}
560
- let extract = sql ?? `value->>'$."${name}"'`
561
- const converter = element[inputConverterKey] || (e => e)
562
- let managed = element[annotation]?.['=']
563
- switch (managed) {
564
- case '$user.id':
565
- case '$user':
566
- managed = this.string(this.context.user.id)
567
- break
568
- case '$now':
569
- managed = this.string(this.context.timestamp.toISOString())
570
- break
571
- default:
572
- managed = undefined
573
- }
574
- if (!isUpdate) {
859
+ let element = elements?.[name] || {}
860
+ if (!sql) sql = `value->>'$."${name}"'`
861
+
862
+ let converter = element[_convertInput]
863
+ if (converter && sql[0] !== '$') sql = converter(sql, element)
864
+
865
+ let val = _managed[element[annotation]?.['=']]
866
+ if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
867
+
868
+ else if (!isUpdate && element.default) {
575
869
  const d = element.default
576
- if (d && (d.val !== undefined || d.ref?.[0] === '$now')) {
577
- extract = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(
578
- d.val,
579
- )} ELSE ${extract} END)`
870
+ if (d.val !== undefined || d.ref?.[0] === '$now') {
871
+ // REVISIT: d.ref is not used afterwards
872
+ sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${
873
+ this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
874
+ } ELSE ${sql} END)`
580
875
  }
581
876
  }
582
- return {
583
- name,
584
- sql: converter(managed === undefined ? extract : `coalesce(${extract}, ${managed})`, element),
585
- }
877
+
878
+ return { name, sql }
586
879
  })
587
880
  }
588
881
 
882
+ /**
883
+ * Returns the default value
884
+ * @param {string} defaultValue
885
+ * @returns {string}
886
+ */
887
+ // REVISIT: This is a strange method, also overridden inconsistently in postgres
589
888
  defaultValue(defaultValue = this.context.timestamp.toISOString()) {
590
889
  return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
591
890
  }
@@ -597,9 +896,19 @@ Buffer.prototype.toJSON = function () {
597
896
  }
598
897
 
599
898
  const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || []
600
- const has_expands = q => q.SELECT.columns?.some(c => c.SELECT?.expand)
601
- const has_arrays = q => q.elements && Object.values(q.elements).some(e => e.items)
899
+ const _managed = {
900
+ '$user.id': '$user.id',
901
+ $user: '$user.id',
902
+ $now: '$now',
903
+ }
602
904
 
603
905
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
604
906
  const _empty = a => !a || a.length === 0
605
- module.exports = Object.assign((q, m) => new CQN2SQLRenderer().render(cqn4sql(q, m), m), { class: CQN2SQLRenderer })
907
+
908
+ /**
909
+ * @param {import('@sap/cds/apis/cqn').Query} q
910
+ * @param {import('@sap/cds/apis/csn').CSN} m
911
+ */
912
+ module.exports = (q, m) => new CQN2SQLRenderer().render(cqn4sql(q, m), m)
913
+ module.exports.class = CQN2SQLRenderer
914
+ module.exports.classDefinition = CQN2SQLRenderer // class is a reserved typescript word