@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/CHANGELOG.md +35 -0
- package/lib/SQLService.js +130 -82
- package/lib/common/DatabaseService.js +6 -14
- package/lib/common/session-context.js +14 -0
- package/lib/cqn2sql.js +165 -96
- package/lib/cqn4sql.js +110 -125
- package/lib/fill-in-keys.js +6 -1
- package/lib/infer/cqn.d.ts +0 -3
- package/lib/infer/index.js +36 -23
- package/lib/infer/join-tree.js +5 -6
- package/package.json +2 -2
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
|
-
|
|
250
|
-
let obj = ''
|
|
255
|
+
const isRoot = SELECT.expand === 'root'
|
|
251
256
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
519
|
-
|
|
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
|
|
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
|
-
|
|
527
|
-
|
|
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: `
|
|
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':
|
|
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)
|
|
771
|
-
if (val instanceof Readable); // go on with default below
|
|
772
|
-
else if (Buffer.isBuffer(val))
|
|
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
|