@cap-js/db-service 1.0.0 → 1.1.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
@@ -14,11 +14,25 @@ const DEBUG = (() => {
14
14
  })()
15
15
 
16
16
  class CQN2SQLRenderer {
17
+ /**
18
+ * Creates a new CQN2SQL instance for processing a query
19
+ * @constructor
20
+ * @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
21
+ */
17
22
  constructor(context) {
23
+ /**
24
+ * @type {import('@sap/cds/apis/services').ContextProperties}
25
+ */
18
26
  this.context = cds.context || context
27
+ // REVISIT: find a way to make CQN2SQLRenderer work in SQLService as well
28
+ /** @type {CQN2SQLRenderer|unknown} */
19
29
  this.class = new.target // for IntelliSense
20
30
  this.class._init() // is a noop for subsequent calls
21
31
  }
32
+
33
+ /**
34
+ * Initializes the class one first creation to link types to data converters
35
+ */
22
36
  static _init() {
23
37
  const _add_mixins = (aspect, mixins) => {
24
38
  const fqn = this.name + aspect
@@ -37,9 +51,19 @@ class CQN2SQLRenderer {
37
51
  this._init = () => {} // makes this a noop for subsequent calls
38
52
  }
39
53
 
54
+ /**
55
+ * Renders incoming query into SQL and generates binding values
56
+ * @param {import('./infer/cqn').Query} q CQN query to be rendered
57
+ * @param {unknown[]|undefined} vars Values to be used for params
58
+ * @returns {CQN2SQLRenderer|unknown}
59
+ */
40
60
  render(q, vars) {
41
61
  const cmd = q.cmd || Object.keys(q)[0] // SELECT, INSERT, ...
62
+ /**
63
+ * @type {string} the rendered SQL string
64
+ */
42
65
  this.sql = '' // to have it as first property for debugging
66
+ /** @type {unknown[]} */
43
67
  this.values = [] // prepare values, filled in by subroutines
44
68
  this[cmd]((this.cqn = q)) // actual sql rendering happens here
45
69
  if (vars?.length && !this.values.length) this.values = vars
@@ -51,12 +75,21 @@ class CQN2SQLRenderer {
51
75
  return this
52
76
  }
53
77
 
78
+ /**
79
+ * Links the incoming query with the current service model
80
+ * @param {import('./infer/cqn').Query} q
81
+ * @returns {import('./infer/cqn').Query}
82
+ */
54
83
  infer(q) {
55
84
  return q.target ? q : cds_infer(q)
56
85
  }
57
86
 
58
87
  // CREATE Statements ------------------------------------------------
59
88
 
89
+ /**
90
+ * Renders a CREATE query into generic SQL
91
+ * @param {import('./infer/cqn').CREATE} q
92
+ */
60
93
  CREATE(q) {
61
94
  const { target } = q,
62
95
  { query } = target
@@ -71,6 +104,11 @@ class CQN2SQLRenderer {
71
104
  return
72
105
  }
73
106
 
107
+ /**
108
+ * Renders a column clause for the given elements
109
+ * @param {import('./infer/cqn').elements} elements
110
+ * @returns {string} SQL
111
+ */
74
112
  CREATE_elements(elements) {
75
113
  let sql = ''
76
114
  for (let e in elements) {
@@ -82,11 +120,21 @@ class CQN2SQLRenderer {
82
120
  return sql.slice(0, -2)
83
121
  }
84
122
 
123
+ /**
124
+ * Renders a column definition for the given element
125
+ * @param {import('./infer/cqn').element} element
126
+ * @returns {string} SQL
127
+ */
85
128
  CREATE_element(element) {
86
129
  const type = this.type4(element)
87
130
  if (type) return this.quote(element.name) + ' ' + type
88
131
  }
89
132
 
133
+ /**
134
+ * Renders the SQL type definition for the given element
135
+ * @param {import('./infer/cqn').element} element
136
+ * @returns {string}
137
+ */
90
138
  type4(element) {
91
139
  if (!element._type) element = cds.builtin.types[element.type] || element
92
140
  const fn = element[this.class._sqlType]
@@ -95,6 +143,9 @@ class CQN2SQLRenderer {
95
143
  )
96
144
  }
97
145
 
146
+ /** @callback converter */
147
+
148
+ /** @type {Object<string,import('@sap/cds/apis/csn').Definition>} */
98
149
  static TypeMap = {
99
150
  // Utilizing cds.linked inheritance
100
151
  String: e => `NVARCHAR(${e.length || 5000})`,
@@ -120,6 +171,10 @@ class CQN2SQLRenderer {
120
171
 
121
172
  // DROP Statements ------------------------------------------------
122
173
 
174
+ /**
175
+ * Renders a DROP query into generic SQL
176
+ * @param {import('./infer/cqn').DROP} q
177
+ */
123
178
  DROP(q) {
124
179
  const { target } = q
125
180
  const isView = target.query || target.projection
@@ -128,6 +183,10 @@ class CQN2SQLRenderer {
128
183
 
129
184
  // SELECT Statements ------------------------------------------------
130
185
 
186
+ /**
187
+ * Renders a SELECT statement into generic SQL
188
+ * @param {import('./infer/cqn').SELECT} q
189
+ */
131
190
  SELECT(q) {
132
191
  let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized } = q.SELECT
133
192
  if (!expand) expand = q.SELECT.expand = has_expands(q) || has_arrays(q)
@@ -145,16 +204,34 @@ class CQN2SQLRenderer {
145
204
  if (!_empty((x = orderBy))) sql += ` ORDER BY ${this.orderBy(x, localized)}`
146
205
  if (one) sql += ` LIMIT ${this.limit({ rows: { val: 1 } })}`
147
206
  else if ((x = limit)) sql += ` LIMIT ${this.limit(x)}`
148
- if (expand) sql = this.SELECT_expand(q, sql)
207
+ // Expand cannot work without an inferred query
208
+ if (expand) {
209
+ if (!q.elements) cds.error`Query was not inferred and includes expand. For which the metadata is missing.`
210
+ sql = this.SELECT_expand(q, sql)
211
+ }
149
212
  return (this.sql = sql)
150
213
  }
151
214
 
215
+ /**
216
+ * Renders a column clause into generic SQL
217
+ * @param {import('./infer/cqn').SELECT} param0
218
+ * @returns {string} SQL
219
+ */
152
220
  SELECT_columns({ SELECT }) {
153
221
  // REVISIT: We don't have to run x.as through this.column_name(), do we?
154
222
  if (!SELECT.columns) return '*'
155
- return SELECT.columns.map(x => this.column_expr(x) + (typeof x.as === 'string' ? ' as ' + this.quote(x.as) : ''))
223
+ return SELECT.columns.map(x => {
224
+ if (x === '*') return x
225
+ return this.column_expr(x) + (typeof x.as === 'string' ? ' as ' + this.quote(x.as) : '')
226
+ })
156
227
  }
157
228
 
229
+ /**
230
+ * Renders a JSON select around the provided SQL statement
231
+ * @param {import('./infer/cqn').SELECT} param0
232
+ * @param {string} sql
233
+ * @returns {string} SQL
234
+ */
158
235
  SELECT_expand({ SELECT, elements }, sql) {
159
236
  if (!SELECT.columns) return sql
160
237
  if (!elements) return sql
@@ -183,6 +260,11 @@ class CQN2SQLRenderer {
183
260
  return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj})`} as _json_ FROM (${sql})`
184
261
  }
185
262
 
263
+ /**
264
+ * Renders a SELECT column expression into generic SQL
265
+ * @param {import('./infer/cqn').col} x
266
+ * @returns {string} SQL
267
+ */
186
268
  column_expr(x) {
187
269
  if (x.func && !x.as) x.as = x.func
188
270
  if (x?.element?.['@cds.extension']) {
@@ -193,6 +275,11 @@ class CQN2SQLRenderer {
193
275
  return sql
194
276
  }
195
277
 
278
+ /**
279
+ * Renders a FROM clause into generic SQL
280
+ * @param {import('./infer/cqn').source} from
281
+ * @returns {string} SQL
282
+ */
196
283
  from(from) {
197
284
  const { ref, as } = from,
198
285
  _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
@@ -208,18 +295,39 @@ class CQN2SQLRenderer {
208
295
  }
209
296
  }
210
297
 
298
+ /**
299
+ * Renders a WHERE clause into generic SQL
300
+ * @param {import('./infer/cqn').predicate} xpr
301
+ * @returns {string} SQL
302
+ */
211
303
  where(xpr) {
212
304
  return this.xpr({ xpr })
213
305
  }
214
306
 
307
+ /**
308
+ * Renders a HAVING clause into generic SQL
309
+ * @param {import('./infer/cqn').predicate} xpr
310
+ * @returns {string} SQL
311
+ */
215
312
  having(xpr) {
216
313
  return this.xpr({ xpr })
217
314
  }
218
315
 
316
+ /**
317
+ * Renders a groupBy clause into generic SQL
318
+ * @param {import('./infer/cqn').expr[]} clause
319
+ * @returns {string[] | string} SQL
320
+ */
219
321
  groupBy(clause) {
220
322
  return clause.map(c => this.expr(c))
221
323
  }
222
324
 
325
+ /**
326
+ * Renders an orderBy clause into generic SQL
327
+ * @param {import('./infer/cqn').ordering_term[]} orderBy
328
+ * @param {boolean | undefined} localized
329
+ * @returns {string[] | string} SQL
330
+ */
223
331
  orderBy(orderBy, localized) {
224
332
  return orderBy.map(
225
333
  localized
@@ -231,6 +339,12 @@ class CQN2SQLRenderer {
231
339
  )
232
340
  }
233
341
 
342
+ /**
343
+ * Renders an limit clause into generic SQL
344
+ * @param {import('./infer/cqn').limit} param0
345
+ * @returns {string} SQL
346
+ * @throws {Error} When no rows are defined
347
+ */
234
348
  limit({ rows, offset }) {
235
349
  if (!rows) throw new Error('Rows parameter is missing in SELECT.limit(rows, offset)')
236
350
  return !offset ? rows.val : `${rows.val} OFFSET ${offset.val}`
@@ -238,6 +352,11 @@ class CQN2SQLRenderer {
238
352
 
239
353
  // INSERT Statements ------------------------------------------------
240
354
 
355
+ /**
356
+ * Renders an INSERT query into generic SQL
357
+ * @param {import('./infer/cqn').INSERT} q
358
+ * @returns {string} SQL
359
+ */
241
360
  INSERT(q) {
242
361
  const { INSERT } = q
243
362
  return INSERT.entries
@@ -251,6 +370,11 @@ class CQN2SQLRenderer {
251
370
  : cds.error`Missing .entries, .rows, or .values in ${q}`
252
371
  }
253
372
 
373
+ /**
374
+ * Renders an INSERT query with entries property
375
+ * @param {import('./infer/cqn').INSERT} q
376
+ * @returns {string} SQL
377
+ */
254
378
  INSERT_entries(q) {
255
379
  const { INSERT } = q
256
380
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
@@ -262,6 +386,8 @@ class CQN2SQLRenderer {
262
386
  const columns = elements
263
387
  ? ObjectKeys(elements).filter(c => c in elements && !elements[c].virtual && !elements[c].isAssociation)
264
388
  : ObjectKeys(INSERT.entries[0])
389
+
390
+ /** @type {string[]} */
265
391
  this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true).map(c => this.quote(c))
266
392
 
267
393
  const extractions = this.managed(
@@ -294,6 +420,11 @@ class CQN2SQLRenderer {
294
420
  }) SELECT ${extraction} FROM json_each(?)`)
295
421
  }
296
422
 
423
+ /**
424
+ * Renders an INSERT query with rows property
425
+ * @param {import('./infer/cqn').INSERT} q
426
+ * @returns {string} SQL
427
+ */
297
428
  INSERT_rows(q) {
298
429
  const { INSERT } = q
299
430
  const entity = this.name(q.target?.name || INSERT.into.ref[0])
@@ -322,11 +453,21 @@ class CQN2SQLRenderer {
322
453
  }) SELECT ${extraction} FROM json_each(?)`)
323
454
  }
324
455
 
456
+ /**
457
+ * Renders an INSERT query with values property
458
+ * @param {import('./infer/cqn').INSERT} q
459
+ * @returns {string} SQL
460
+ */
325
461
  INSERT_values(q) {
326
462
  let { columns, values } = q.INSERT
327
463
  return this.INSERT_rows({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
328
464
  }
329
465
 
466
+ /**
467
+ * Renders an INSERT query from SELECT query
468
+ * @param {import('./infer/cqn').INSERT} q
469
+ * @returns {string} SQL
470
+ */
330
471
  INSERT_select(q) {
331
472
  const { INSERT } = q
332
473
  const entity = this.name(q.target.name)
@@ -342,19 +483,32 @@ class CQN2SQLRenderer {
342
483
  return this.sql
343
484
  }
344
485
 
486
+ /**
487
+ * Wraps the provided SQL expression for output processing
488
+ * @param {import('./infer/cqn').element} element
489
+ * @param {string} expr
490
+ * @returns {string} SQL
491
+ */
345
492
  output_converter4(element, expr) {
346
493
  const fn = element?.[this.class._convertOutput]
347
494
  return fn?.(expr, element) || expr
348
495
  }
349
496
 
497
+ /** @type {import('./converters').Converters} */
350
498
  static InputConverters = {} // subclasses to override
351
499
 
500
+ /** @type {import('./converters').Converters} */
352
501
  static OutputConverters = {} // subclasses to override
353
502
 
354
503
  static localized = { String: true, UUID: false }
355
504
 
356
505
  // UPSERT Statements ------------------------------------------------
357
506
 
507
+ /**
508
+ * Renders an UPSERT query into generic SQL
509
+ * @param {import('./infer/cqn').UPDATE} q
510
+ * @returns {string} SQL
511
+ */
358
512
  UPSERT(q) {
359
513
  let { UPSERT } = q,
360
514
  sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
@@ -376,6 +530,11 @@ class CQN2SQLRenderer {
376
530
 
377
531
  // UPDATE Statements ------------------------------------------------
378
532
 
533
+ /**
534
+ * Renders an UPDATE query into generic SQL
535
+ * @param {import('./infer/cqn').UPDATE} q
536
+ * @returns {string} SQL
537
+ */
379
538
  UPDATE(q) {
380
539
  const {
381
540
  UPDATE: { entity, with: _with, data, where },
@@ -414,6 +573,11 @@ class CQN2SQLRenderer {
414
573
 
415
574
  // DELETE Statements ------------------------------------------------
416
575
 
576
+ /**
577
+ * Renders a DELETE query into generic SQL
578
+ * @param {import('./infer/cqn').DELETE} param0
579
+ * @returns {string} SQL
580
+ */
417
581
  DELETE({ DELETE: { from, where } }) {
418
582
  let sql = `DELETE FROM ${this.from(from)}`
419
583
  if (where) sql += ` WHERE ${this.where(where)}`
@@ -422,6 +586,11 @@ class CQN2SQLRenderer {
422
586
 
423
587
  // STREAM Statement -------------------------------------------------
424
588
 
589
+ /**
590
+ * Renders a STREAM query into generic SQL
591
+ * @param {import('./infer/cqn').STREAM} q
592
+ * @returns {string} SQL
593
+ */
425
594
  STREAM(q) {
426
595
  let { from, into, where, column, data } = q.STREAM
427
596
  let x, sql
@@ -443,6 +612,12 @@ class CQN2SQLRenderer {
443
612
 
444
613
  // Expression Clauses ---------------------------------------------
445
614
 
615
+ /**
616
+ * Renders an expression object into generic SQL
617
+ * @param {import('./infer/cqn').expr} x
618
+ * @returns {string} SQL
619
+ * @throws {Error} When an unknown un supported expression is provided
620
+ */
446
621
  expr(x) {
447
622
  const wrap = x.cast ? sql => `cast(${sql} as ${this.type4(x.cast)})` : sql => sql
448
623
  if (typeof x === 'string') throw cds.error`Unsupported expr: ${x}`
@@ -456,6 +631,11 @@ class CQN2SQLRenderer {
456
631
  else throw cds.error`Unsupported expr: ${x}`
457
632
  }
458
633
 
634
+ /**
635
+ * Renders an list of expression objects into generic SQL
636
+ * @param {import('./infer/cqn').xpr} param0
637
+ * @returns {string} SQL
638
+ */
459
639
  xpr({ xpr }) {
460
640
  return xpr
461
641
  .map((x, i) => {
@@ -467,21 +647,44 @@ class CQN2SQLRenderer {
467
647
  .join(' ')
468
648
  }
469
649
 
650
+ /**
651
+ * Renders an operation into generic SQL
652
+ * @param {string} x The current operator string
653
+ * @param {Number} i Current index of the operator inside the xpr
654
+ * @param {import('./infer/cqn').predicate[]} xpr The parent xpr in which the operator is used
655
+ * @returns {string} The correct operator string
656
+ */
470
657
  operator(x, i, xpr) {
471
658
  if (x === '=' && xpr[i + 1]?.val === null) return 'is'
472
659
  if (x === '!=') return 'is not'
473
660
  else return x
474
661
  }
475
662
 
663
+ /**
664
+ * Renders an argument place holder into the SQL for prepared statements
665
+ * @param {import('./infer/cqn').ref} param0
666
+ * @returns {string} SQL
667
+ * @throws {Error} When an unsupported ref definition is provided
668
+ */
476
669
  param({ ref }) {
477
670
  if (ref.length > 1) throw cds.error`Unsupported nested ref parameter: ${ref}`
478
671
  return ref[0] === '?' ? '?' : `:${ref}`
479
672
  }
480
673
 
674
+ /**
675
+ * Renders a ref into generic SQL
676
+ * @param {import('./infer/cqn').ref} param0
677
+ * @returns {string} SQL
678
+ */
481
679
  ref({ ref }) {
482
680
  return ref.map(r => this.quote(r)).join('.')
483
681
  }
484
682
 
683
+ /**
684
+ * Renders a value into the correct SQL syntax of a placeholder for a prepared statement
685
+ * @param {import('./infer/cqn').val} param0
686
+ * @returns {string} SQL
687
+ */
485
688
  val({ val }) {
486
689
  switch (typeof val) {
487
690
  case 'function':
@@ -504,43 +707,93 @@ class CQN2SQLRenderer {
504
707
  }
505
708
 
506
709
  static Functions = require('./cql-functions')
710
+ /**
711
+ * Renders a function call into mapped SQL definitions from the Functions definition
712
+ * @param {import('./infer/cqn').func} param0
713
+ * @returns {string} SQL
714
+ */
507
715
  func({ func, args }) {
508
716
  args = (args || []).map(e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) }))
509
717
  return this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
510
718
  }
511
719
 
720
+ /**
721
+ * Renders a list into generic SQL
722
+ * @param {import('./infer/cqn').list} param0
723
+ * @returns {string} SQL
724
+ */
512
725
  list({ list }) {
513
726
  return `(${list.map(e => this.expr(e))})`
514
727
  }
515
728
 
729
+ /**
730
+ * Renders a Regular Expression into its string representation
731
+ * @param {RegExp} o
732
+ * @returns {string} SQL
733
+ */
516
734
  regex(o) {
517
735
  if (is_regexp(o)) return o.source
518
736
  }
519
737
 
738
+ /**
739
+ * Renders the object as a JSON string in generic SQL
740
+ * @param {object} o
741
+ * @returns {string} SQL
742
+ */
520
743
  json(o) {
521
- return JSON.stringify(o)
744
+ return this.string(JSON.stringify(o))
522
745
  }
523
746
 
747
+ /**
748
+ * Renders a javascript string into a generic SQL string
749
+ * @param {string} s
750
+ * @returns {string} SQL
751
+ */
524
752
  string(s) {
525
753
  return `'${s.replace(/'/g, "''")}'`
526
754
  }
527
755
 
756
+ /**
757
+ * Calculates the effect column name
758
+ * @param {import('./infer/cqn').col} col
759
+ * @returns {string} explicit/implicit column alias
760
+ */
528
761
  column_name(col) {
762
+ if (col === '*')
763
+ cds.error`Query was not inferred and includes '*' in the columns. For which there is no column name available.`
529
764
  return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.ref[col.ref.length - 1]
530
765
  }
531
766
 
767
+ /**
768
+ * Calculates the Database name of the given name
769
+ * @param {string|import('./infer/cqn').ref} name
770
+ * @returns {string} Database name
771
+ */
532
772
  name(name) {
533
773
  return (name.id || name).replace(/\./g, '_')
534
774
  }
535
775
 
776
+ /** @type {unknown} */
536
777
  static ReservedWords = {}
778
+ /**
779
+ * Ensures that the given identifier is properly quoted when required by the database
780
+ * @param {string} s
781
+ * @returns {string} SQL
782
+ */
537
783
  quote(s) {
538
784
  if (typeof s !== 'string') return '"' + s + '"'
539
785
  if (s.includes('"')) return '"' + s.replace(/"/g, '""') + '"'
540
- if (s.toUpperCase() in this.class.ReservedWords || /^\d|[$' @./\\]/.test(s)) return '"' + s + '"'
786
+ if (s.toUpperCase() in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
541
787
  return s
542
788
  }
543
789
 
790
+ /**
791
+ * Convers the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
792
+ * @param {object[]} columns
793
+ * @param {import('./infer/cqn').elements} elements
794
+ * @param {Boolean} isUpdate
795
+ * @returns {string[]} Array of SQL expressions for processing input JSON data
796
+ */
544
797
  managed(columns, elements, isUpdate = false) {
545
798
  const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
546
799
  const inputConverterKey = this.class._convertInput
@@ -586,6 +839,11 @@ class CQN2SQLRenderer {
586
839
  })
587
840
  }
588
841
 
842
+ /**
843
+ * Returns the default value
844
+ * @param {string} defaultValue
845
+ * @returns {string}
846
+ */
589
847
  defaultValue(defaultValue = this.context.timestamp.toISOString()) {
590
848
  return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
591
849
  }
@@ -602,4 +860,11 @@ const has_arrays = q => q.elements && Object.values(q.elements).some(e => e.item
602
860
 
603
861
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
604
862
  const _empty = a => !a || a.length === 0
605
- module.exports = Object.assign((q, m) => new CQN2SQLRenderer().render(cqn4sql(q, m), m), { class: CQN2SQLRenderer })
863
+
864
+ /**
865
+ * @param {import('@sap/cds/apis/cqn').Query} q
866
+ * @param {import('@sap/cds/apis/csn').CSN} m
867
+ */
868
+ module.exports = (q, m) => new CQN2SQLRenderer().render(cqn4sql(q, m), m)
869
+ module.exports.class = CQN2SQLRenderer
870
+ module.exports.classDefinition = CQN2SQLRenderer // class is a reserved typescript word