@cap-js/db-service 1.5.1 → 1.6.1

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,12 @@ const cds = require('@sap/cds/lib')
2
2
  const cds_infer = require('./infer')
3
3
  const cqn4sql = require('./cqn4sql')
4
4
 
5
+ const BINARY_TYPES = {
6
+ 'cds.Binary': 1,
7
+ 'cds.LargeBinary': 1,
8
+ 'cds.hana.BINARY': 1,
9
+ }
10
+
5
11
  const { Readable } = require('stream')
6
12
 
7
13
  const DEBUG = (() => {
@@ -237,7 +243,7 @@ class CQN2SQLRenderer {
237
243
 
238
244
  let cols = SELECT.columns.map(x => {
239
245
  const name = this.column_name(x)
240
- let col = `'${name}',${this.output_converter4(x.element, this.quote(name))}`
246
+ let col = `'$."${name}"',${this.output_converter4(x.element, this.quote(name))}`
241
247
  if (x.SELECT?.count) {
242
248
  // Return both the sub select and the count for @odata.count
243
249
  const qc = cds.ql.clone(x, { columns: [{ func: 'count' }], one: 1, limit: 0, orderBy: 0 })
@@ -246,21 +252,14 @@ class CQN2SQLRenderer {
246
252
  return col
247
253
  }).flat()
248
254
 
249
- // Prevent SQLite from hitting function argument limit of 100
250
- let obj = ''
255
+ const isRoot = SELECT.expand === 'root'
251
256
 
252
- if (cols.length < 50) obj = `json_object(${cols.slice(0, 50)})`
253
- else {
254
- const chunks = []
255
- for (let i = 0; i < cols.length; i += 50) {
256
- chunks.push(`json_object(${cols.slice(i, i + 50)})`)
257
- }
258
- // REVISIT: json_merge is a user defined function, bad performance!
259
- obj = `json_merge(${chunks})`
257
+ // Prevent SQLite from hitting function argument limit of 100
258
+ let obj = "'{}'"
259
+ for (let i = 0; i < cols.length; i += 48) {
260
+ obj = `jsonb_insert(${obj},${cols.slice(i, i + 48)})`
260
261
  }
261
-
262
-
263
- return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj.includes('json_merge') ? `json_insert(${obj})` : obj})`} as _json_ FROM (${sql})`
262
+ return `SELECT ${isRoot || SELECT.one ? obj.replace('jsonb', 'json') : `jsonb_group_array(${obj})`} as _json_ FROM (${sql})`
264
263
  }
265
264
 
266
265
  /**
@@ -400,6 +399,12 @@ class CQN2SQLRenderer {
400
399
  /** @type {string[]} */
401
400
  this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true).map(c => this.quote(c))
402
401
 
402
+ if (!elements) {
403
+ this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
404
+ const param = this.param.bind(this, { ref: ['?'] })
405
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns}) VALUES (${columns.map(param)})`)
406
+ }
407
+
403
408
  const extractions = this.managed(
404
409
  columns.map(c => ({ name: c })),
405
410
  elements,
@@ -426,11 +431,121 @@ class CQN2SQLRenderer {
426
431
 
427
432
  // Include this.values for placeholders
428
433
  /** @type {unknown[][]} */
429
- this.entries = [[...this.values, JSON.stringify(INSERT.entries)]]
434
+ this.entries = []
435
+ if (INSERT.entries[0] instanceof Readable) {
436
+ INSERT.entries[0].type = 'json'
437
+ this.entries = [[...this.values, INSERT.entries[0]]]
438
+ } else {
439
+ const stream = Readable.from(this.INSERT_entries_stream(INSERT.entries), { objectMode: false })
440
+ stream.type = 'json'
441
+ this.entries = [[...this.values, stream]]
442
+ }
443
+
430
444
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
431
445
  }) SELECT ${extraction} FROM json_each(?)`)
432
446
  }
433
447
 
448
+ async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
449
+ const elements = this.cqn.target?.elements || {}
450
+ const transformBase64 = binaryEncoding === 'base64'
451
+ ? a => a
452
+ : a => a != null ? Buffer.from(a, 'base64').toString(binaryEncoding) : a
453
+ const bufferLimit = 65536 // 1 << 16
454
+ let buffer = '['
455
+
456
+ let sep = ''
457
+ for (const row of entries) {
458
+ buffer += `${sep}{`
459
+ if (!sep) sep = ','
460
+
461
+ let sepsub = ''
462
+ for (const key in row) {
463
+ const keyJSON = `${sepsub}${JSON.stringify(key)}:`
464
+ if (!sepsub) sepsub = ','
465
+
466
+ let val = row[key]
467
+ if (val instanceof Readable) {
468
+ buffer += `${keyJSON}"`
469
+
470
+ // TODO: double check that it works
471
+ val.setEncoding(binaryEncoding)
472
+ for await (const chunk of val) {
473
+ buffer += chunk
474
+ if (buffer.length > bufferLimit) {
475
+ yield buffer
476
+ buffer = ''
477
+ }
478
+ }
479
+
480
+ buffer += '"'
481
+ } else {
482
+ if (elements[key]?.type in BINARY_TYPES) {
483
+ val = transformBase64(val)
484
+ }
485
+ buffer += `${keyJSON}${val === undefined ? 'null' : JSON.stringify(val)}`
486
+ }
487
+ }
488
+ buffer += '}'
489
+ if (buffer.length > bufferLimit) {
490
+ yield buffer
491
+ buffer = ''
492
+ }
493
+ }
494
+
495
+ buffer += ']'
496
+ yield buffer
497
+ }
498
+
499
+ async *INSERT_rows_stream(entries, binaryEncoding = 'base64') {
500
+ const elements = this.cqn.target?.elements || {}
501
+ const transformBase64 = binaryEncoding === 'base64'
502
+ ? a => a
503
+ : a => a != null ? Buffer.from(a, 'base64').toString(binaryEncoding) : a
504
+ const bufferLimit = 65536 // 1 << 16
505
+ let buffer = '['
506
+
507
+ let sep = ''
508
+ for (const row of entries) {
509
+ buffer += `${sep}[`
510
+ if (!sep) sep = ','
511
+
512
+ let sepsub = ''
513
+ for (let key = 0; key < row.length; key++) {
514
+ let val = row[key]
515
+ if (val instanceof Readable) {
516
+ buffer += `${sepsub}"`
517
+
518
+ // TODO: double check that it works
519
+ val.setEncoding(binaryEncoding)
520
+ for await (const chunk of val) {
521
+ buffer += chunk
522
+ if (buffer.length > bufferLimit) {
523
+ yield buffer
524
+ buffer = ''
525
+ }
526
+ }
527
+
528
+ buffer += '"'
529
+ } else {
530
+ if (elements[this.columns[key]]?.type in BINARY_TYPES) {
531
+ val = transformBase64(val)
532
+ }
533
+ buffer += `${sepsub}${val === undefined ? 'null' : JSON.stringify(val)}`
534
+ }
535
+
536
+ if (!sepsub) sepsub = ','
537
+ }
538
+ buffer += ']'
539
+ if (buffer.length > bufferLimit) {
540
+ yield buffer
541
+ buffer = ''
542
+ }
543
+ }
544
+
545
+ buffer += ']'
546
+ yield buffer
547
+ }
548
+
434
549
  /**
435
550
  * Renders an INSERT query with rows property
436
551
  * @param {import('./infer/cqn').INSERT} q
@@ -453,7 +568,22 @@ class CQN2SQLRenderer {
453
568
  })
454
569
 
455
570
  this.columns = columns.map(c => this.quote(c))
456
- this.entries = [[JSON.stringify(INSERT.rows)]]
571
+
572
+ if (!elements) {
573
+ this.entries = INSERT.rows
574
+ const param = this.param.bind(this, { ref: ['?'] })
575
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns}) VALUES (${columns.map(param)})`)
576
+ }
577
+
578
+ if (INSERT.rows[0] instanceof Readable) {
579
+ INSERT.rows[0].type = 'json'
580
+ this.entries = [[...this.values, INSERT.rows[0]]]
581
+ } else {
582
+ const stream = Readable.from(this.INSERT_rows_stream(INSERT.rows), { objectMode: false })
583
+ stream.type = 'json'
584
+ this.entries = [[...this.values, stream]]
585
+ }
586
+
457
587
  return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
458
588
  }) SELECT ${extraction} FROM json_each(?)`)
459
589
  }
@@ -515,16 +645,23 @@ class CQN2SQLRenderer {
515
645
  * @returns {string} SQL
516
646
  */
517
647
  UPSERT(q) {
518
- let { UPSERT } = q,
519
- sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
648
+ const { UPSERT } = q
649
+ const elements = q.target?.elements || {}
650
+ let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
520
651
  let keys = q.target?.keys
521
- if (!keys) return (this.sql = sql) // REVISIT: We should converge q.target and q._target
652
+ if (!keys) return this.sql = sql
522
653
  keys = Object.keys(keys).filter(k => !keys[k].isAssociation)
523
654
 
524
655
  let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
525
- updateColumns = updateColumns
526
- .filter(c => !keys.includes(c))
527
- .map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
656
+ updateColumns = updateColumns.filter(c => {
657
+ if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
658
+ let e = elements[c]
659
+ if (!e) return true //> pass through to native SQL columns not in CDS model
660
+ if (e.virtual) return true //> skip virtual elements
661
+ if (e.value) return true //> skip calculated elements
662
+ // if (e.isAssociation) return true //> this breaks a a test in @sap/cds -> need to follow up how to correctly handle deep upserts
663
+ else return true
664
+ }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
528
665
 
529
666
  // temporal data
530
667
  keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
@@ -563,7 +700,7 @@ class CQN2SQLRenderer {
563
700
  columns = columns.map(c => {
564
701
  if (q.elements?.[c.name]?.['@cds.extension']) return {
565
702
  name: 'extensions__',
566
- sql: `json_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
703
+ sql: `jsonb_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
567
704
  }
568
705
  return c
569
706
  })
@@ -588,73 +725,6 @@ class CQN2SQLRenderer {
588
725
  return (this.sql = sql)
589
726
  }
590
727
 
591
- // STREAM Statement -------------------------------------------------
592
-
593
- /**
594
- * Renders a STREAM query into generic SQL
595
- * @param {import('./infer/cqn').STREAM} q
596
- * @returns {string} SQL
597
- */
598
- STREAM(q) {
599
- const { STREAM } = q
600
- return STREAM.from
601
- ? this.STREAM_from(q)
602
- : STREAM.into
603
- ? this.STREAM_into(q)
604
- : cds.error`Missing .form or .into in ${q}`
605
- }
606
-
607
- /**
608
- * Renders a STREAM.into query into generic SQL
609
- * @param {import('./infer/cqn').STREAM} q
610
- * @returns {string} SQL
611
- */
612
- STREAM_into(q) {
613
- const { into, column, where, data } = q.STREAM
614
-
615
- let sql
616
- if (!_empty(column)) {
617
- data.type = 'binary'
618
- const update = UPDATE(into)
619
- .with({ [column]: data })
620
- .where(where)
621
- Object.defineProperty(update, 'target', { value: q.target })
622
- sql = this.UPDATE(update)
623
- } else {
624
- data.type = 'json'
625
- // REVISIT: decide whether dataset streams should behave like INSERT or UPSERT
626
- sql = this.UPSERT(UPSERT([{}]).into(into).forSQL())
627
- this.values = [data]
628
- }
629
-
630
- return (this.sql = sql)
631
- }
632
-
633
- /**
634
- * Renders a STREAM.from query into generic SQL
635
- * @param {import('./infer/cqn').STREAM} q
636
- * @returns {string} SQL
637
- */
638
- STREAM_from(q) {
639
- const { column, from, where, columns } = q.STREAM
640
-
641
- const select = cds.ql
642
- .SELECT(column ? [column] : columns)
643
- .where(where)
644
- .limit(column ? 1 : undefined)
645
-
646
- // SELECT.from() does not accept joins
647
- select.SELECT.from = from
648
-
649
- if (column) {
650
- this.one = true
651
- } else {
652
- select.SELECT.expand = 'root'
653
- this.one = !!from.SELECT?.one
654
- }
655
- return this.SELECT(select.forSQL())
656
- }
657
-
658
728
  // Expression Clauses ---------------------------------------------
659
729
 
660
730
  /**
@@ -747,9 +817,8 @@ class CQN2SQLRenderer {
747
817
  */
748
818
  ref({ ref }) {
749
819
  switch (ref[0]) {
750
- case '$now': return this.func({ func: 'session_context', args: [{ val: '$now', param: false }] })
751
- case '$user':
752
- case '$user.id': return this.func({ func: 'session_context', args: [{ val: '$user.id', param: false }] })
820
+ case '$now': return this.func({ func: 'session_context', args: [{ val: '$now', param: false }] }) // REVISIT: why do we need param: false here?
821
+ case '$user': return this.func({ func: 'session_context', args: [{ val: '$user.'+ref[1]||'id', param: false }] }) // REVISIT: same here?
753
822
  default: return ref.map(r => this.quote(r)).join('.')
754
823
  }
755
824
  }
@@ -767,9 +836,9 @@ class CQN2SQLRenderer {
767
836
  case 'number': return `${val}` // REVISIT for HANA
768
837
  case 'object':
769
838
  if (val === null) return 'NULL'
770
- if (val instanceof Date) return `'${val.toISOString()}'`
771
- if (val instanceof Readable); // go on with default below
772
- else if (Buffer.isBuffer(val)) val = val.toString('base64')
839
+ if (val instanceof Date) val = val.toJSON() // returns null if invalid
840
+ else if (val instanceof Readable); // go on with default below
841
+ else if (Buffer.isBuffer(val)); // go on with default below
773
842
  else if (is_regexp(val)) val = val.source
774
843
  else val = JSON.stringify(val)
775
844
  case 'string': // eslint-disable-line no-fallthrough