@cap-js/db-service 1.1.0 → 1.2.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,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
@@ -19,35 +21,37 @@ class CQN2SQLRenderer {
19
21
  * @constructor
20
22
  * @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
21
23
  */
22
- constructor(context) {
23
- /**
24
- * @type {import('@sap/cds/apis/services').ContextProperties}
25
- */
26
- this.context = cds.context || context
27
- // REVISIT: find a way to make CQN2SQLRenderer work in SQLService as well
28
- /** @type {CQN2SQLRenderer|unknown} */
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
29
26
  this.class = new.target // for IntelliSense
30
27
  this.class._init() // is a noop for subsequent calls
31
28
  }
32
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
+
33
41
  /**
34
42
  * Initializes the class one first creation to link types to data converters
35
43
  */
36
44
  static _init() {
37
- const _add_mixins = (aspect, mixins) => {
38
- const fqn = this.name + aspect
39
- const types = cds.builtin.types
40
- for (let each in mixins) {
41
- const def = types[each]
42
- if (!def) continue
43
- Object.defineProperty(def, fqn, { value: mixins[each] })
44
- }
45
- 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
46
54
  }
47
- this._localized = _add_mixins(':localized', this.localized)
48
- this._convertInput = _add_mixins(':convertInput', this.InputConverters)
49
- this._convertOutput = _add_mixins(':convertOutput', this.OutputConverters)
50
- this._sqlType = _add_mixins(':sqlType', this.TypeMap)
51
55
  this._init = () => {} // makes this a noop for subsequent calls
52
56
  }
53
57
 
@@ -189,23 +193,22 @@ class CQN2SQLRenderer {
189
193
  */
190
194
  SELECT(q) {
191
195
  let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized } = q.SELECT
192
- if (!expand) expand = q.SELECT.expand = has_expands(q) || has_arrays(q)
193
196
  // REVISIT: When selecting from an entity that is not in the model the from.where are not normalized (as cqn4sql is skipped)
194
197
  if (!where && from?.ref?.length === 1 && from.ref[0]?.where) where = from.ref[0]?.where
195
198
  let columns = this.SELECT_columns(q)
196
- let x,
197
- sql = `SELECT`
199
+ let sql = `SELECT`
198
200
  if (distinct) sql += ` DISTINCT`
199
- if (!_empty((x = columns))) sql += ` ${x}`
200
- if (!_empty((x = from))) sql += ` FROM ${this.from(x)}`
201
- if (!_empty((x = where))) sql += ` WHERE ${this.where(x)}`
202
- if (!_empty((x = groupBy))) sql += ` GROUP BY ${this.groupBy(x)}`
203
- if (!_empty((x = having))) sql += ` HAVING ${this.having(x)}`
204
- if (!_empty((x = orderBy))) sql += ` ORDER BY ${this.orderBy(x, localized)}`
205
- if (one) sql += ` LIMIT ${this.limit({ rows: { val: 1 } })}`
206
- else if ((x = limit)) sql += ` LIMIT ${this.limit(x)}`
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)}`
207
209
  // Expand cannot work without an inferred query
208
210
  if (expand) {
211
+ // REVISIT: Why don't we handle that as an error in SELECT_expand?
209
212
  if (!q.elements) cds.error`Query was not inferred and includes expand. For which the metadata is missing.`
210
213
  sql = this.SELECT_expand(q, sql)
211
214
  }
@@ -217,13 +220,8 @@ class CQN2SQLRenderer {
217
220
  * @param {import('./infer/cqn').SELECT} param0
218
221
  * @returns {string} SQL
219
222
  */
220
- SELECT_columns({ SELECT }) {
221
- // REVISIT: We don't have to run x.as through this.column_name(), do we?
222
- if (!SELECT.columns) return '*'
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
- })
223
+ SELECT_columns(q) {
224
+ return (q.SELECT.columns ?? ['*']).map(x => this.column_expr(x, q))
227
225
  }
228
226
 
229
227
  /**
@@ -234,28 +232,23 @@ class CQN2SQLRenderer {
234
232
  */
235
233
  SELECT_expand({ SELECT, elements }, sql) {
236
234
  if (!SELECT.columns) return sql
237
- if (!elements) return sql
238
- let cols = !SELECT.columns
239
- ? ['*']
240
- : SELECT.columns.map(x => {
241
- const name = this.column_name(x)
242
- // REVISIT: can be removed when alias handling is resolved properly
243
- const d = elements[name] || elements[name.substring(1, name.length - 1)]
244
- let col = `'$."${name}"',${this.output_converter4(d, this.quote(name))}`
245
-
246
- if (x.SELECT?.count) {
247
- // Return both the sub select and the count for @odata.count
248
- const qc = cds.ql.clone(x, { columns: [{ func: 'count' }], one: 1, limit: 0, orderBy: 0 })
249
- col += `, '$."${name}@odata.count"',${this.expr(qc)}`
250
- }
251
- return col
252
- })
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
+ return [col, `'${name}@odata.count',${this.expr(qc)}`]
243
+ }
244
+ return col
245
+ }).flat()
253
246
 
254
247
  // Prevent SQLite from hitting function argument limit of 100
255
- let colsLength = cols.length
256
- let obj = "'{}'"
257
- for (let i = 0; i < colsLength; i += 48) {
258
- obj = `json_insert(${obj},${cols.slice(i, i + 48)})`
248
+ let obj = ''
249
+ for (let i = 0; i < cols.length; i += 50) {
250
+ const n = `json_object(${cols.slice(i, i + 50)})`
251
+ obj = obj ? `json_patch(${obj},${n})` : n
259
252
  }
260
253
  return `SELECT ${SELECT.one || SELECT.expand === 'root' ? obj : `json_group_array(${obj})`} as _json_ FROM (${sql})`
261
254
  }
@@ -265,34 +258,41 @@ class CQN2SQLRenderer {
265
258
  * @param {import('./infer/cqn').col} x
266
259
  * @returns {string} SQL
267
260
  */
268
- column_expr(x) {
269
- if (x.func && !x.as) x.as = x.func
261
+ column_expr(x, q) {
262
+ if (x === '*') return '*'
263
+ ///////////////////////////////////////////////////////////////////////////////////////
264
+ // REVISIT: that should move out of here!
270
265
  if (x?.element?.['@cds.extension']) {
271
- x.as = x.as || x.element.name
272
- return `extensions__->${this.string('$."' + x.element.name + '"')}`
266
+ return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
273
267
  }
268
+ ///////////////////////////////////////////////////////////////////////////////////////
274
269
  let sql = this.expr(x)
270
+ let alias = this.column_alias4(x, q)
271
+ if (alias) sql += ' as ' + this.quote(alias)
275
272
  return sql
276
273
  }
277
274
 
275
+ /**
276
+ * Extracts the column alias from a SELECT column expression
277
+ * @param {import('./infer/cqn').col} x
278
+ * @returns {string}
279
+ */
280
+ column_alias4(x) {
281
+ return typeof x.as === 'string' ? x.as : x.func
282
+ }
283
+
278
284
  /**
279
285
  * Renders a FROM clause into generic SQL
280
286
  * @param {import('./infer/cqn').source} from
281
287
  * @returns {string} SQL
282
288
  */
283
289
  from(from) {
284
- const { ref, as } = from,
285
- _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
290
+ const { ref, as } = from
291
+ const _aliased = as ? s => s + ` as ${this.quote(as)}` : s => s
286
292
  if (ref) return _aliased(this.quote(this.name(ref[0])))
287
293
  if (from.SELECT) return _aliased(`(${this.SELECT(from)})`)
288
- if (from.join) {
289
- const {
290
- join,
291
- args: [left, right],
292
- on,
293
- } = from
294
- return `${this.from(left)} ${join} JOIN ${this.from(right)} ON ${this.xpr({ xpr: on })}`
295
- }
294
+ if (from.join)
295
+ return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
296
296
  }
297
297
 
298
298
  /**
@@ -414,8 +414,10 @@ class CQN2SQLRenderer {
414
414
  .filter(a => a)
415
415
  .map(c => c.sql)
416
416
 
417
- this.entries = [[JSON.stringify(INSERT.entries)]]
418
- return (this.sql = `INSERT INTO ${entity}${alias ? ' as ' + this.quote(alias) : ''} (${
417
+ // Include this.values for placeholders
418
+ /** @type {unknown[][]} */
419
+ this.entries = [[...this.values, JSON.stringify(INSERT.entries)]]
420
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
419
421
  this.columns
420
422
  }) SELECT ${extraction} FROM json_each(?)`)
421
423
  }
@@ -433,14 +435,11 @@ class CQN2SQLRenderer {
433
435
  if (!INSERT.columns && !elements) {
434
436
  throw cds.error`Cannot insert rows without columns or elements`
435
437
  }
436
- let columns = INSERT.columns || (elements && ObjectKeys(elements))
437
- if (elements) {
438
- columns = columns.filter(c => c in elements && !elements[c].virtual && !elements[c].isAssociation)
439
- }
438
+ let columns = INSERT.columns || (elements && ObjectKeys(elements).filter(c => !elements[c].virtual && !elements[c].isAssociation))
440
439
  this.columns = columns.map(c => this.quote(c))
441
440
 
442
441
  const inputConverterKey = this.class._convertInput
443
- const extraction = columns.map((c, i) => {
442
+ const extraction = columns.map((c,i) => {
444
443
  const element = elements?.[c] || {}
445
444
  const extract = `value->>'$[${i}]'`
446
445
  const converter = element[inputConverterKey] || (e => e)
@@ -448,7 +447,7 @@ class CQN2SQLRenderer {
448
447
  })
449
448
 
450
449
  this.entries = [[JSON.stringify(INSERT.rows)]]
451
- return (this.sql = `INSERT INTO ${entity}${alias ? ' as ' + this.quote(alias) : ''} (${
450
+ return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
452
451
  this.columns
453
452
  }) SELECT ${extraction} FROM json_each(?)`)
454
453
  }
@@ -536,30 +535,26 @@ class CQN2SQLRenderer {
536
535
  * @returns {string} SQL
537
536
  */
538
537
  UPDATE(q) {
539
- const {
540
- UPDATE: { entity, with: _with, data, where },
541
- } = q,
542
- elements = q.target?.elements
538
+ const { entity, with: _with, data, where } = q.UPDATE
539
+ const elements = q.target?.elements
543
540
  let sql = `UPDATE ${this.name(entity.ref?.[0] || entity)}`
544
541
  if (entity.as) sql += ` AS ${entity.as}`
542
+
545
543
  let columns = []
546
- if (data)
547
- for (let c in data)
548
- if (!elements || (c in elements && !elements[c].virtual)) {
549
- columns.push({ name: c, sql: this.val({ val: data[c] }) })
550
- }
551
- if (_with)
552
- for (let c in _with)
544
+ if (data) _add (data, val => this.val({val}))
545
+ if (_with) _add (_with, x => this.expr(x))
546
+ function _add (data, sql4) {
547
+ for (let c in data) {
553
548
  if (!elements || (c in elements && !elements[c].virtual)) {
554
- columns.push({ name: c, sql: this.expr(_with[c]) })
549
+ columns.push({ name: c, sql: sql4(data[c]) })
555
550
  }
551
+ }
552
+ }
556
553
 
557
554
  columns = columns.map(c => {
558
- if (q.elements?.[c.name]?.['@cds.extension']) {
559
- return {
560
- name: 'extensions__',
561
- sql: `json_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
562
- }
555
+ if (q.elements?.[c.name]?.['@cds.extension']) return {
556
+ name: 'extensions__',
557
+ sql: `json_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
563
558
  }
564
559
  return c
565
560
  })
@@ -592,24 +587,65 @@ class CQN2SQLRenderer {
592
587
  * @returns {string} SQL
593
588
  */
594
589
  STREAM(q) {
595
- let { from, into, where, column, data } = q.STREAM
596
- let x, sql
597
- // reading stream
598
- if (from) {
599
- sql = `SELECT`
600
- if (!_empty((x = column))) sql += ` ${this.quote(x)}`
601
- if (!_empty((x = from))) sql += ` FROM ${this.from(x)}`
590
+ const { STREAM } = q
591
+ return STREAM.from
592
+ ? this.STREAM_from(q)
593
+ : STREAM.into
594
+ ? this.STREAM_into(q)
595
+ : cds.error`Missing .form or .into in ${q}`
596
+ }
597
+
598
+ /**
599
+ * Renders a STREAM.into query into generic SQL
600
+ * @param {import('./infer/cqn').STREAM} q
601
+ * @returns {string} SQL
602
+ */
603
+ STREAM_into(q) {
604
+ const { into, column, where, data } = q.STREAM
605
+
606
+ let sql
607
+ if (!_empty(column)) {
608
+ data.type = 'binary'
609
+ const update = UPDATE(into)
610
+ .with({ [column]: data })
611
+ .where(where)
612
+ Object.defineProperty(update, 'target', { value: q.target })
613
+ sql = this.UPDATE(update)
602
614
  } else {
603
- // writing stream
604
- const entity = this.name(q.target?.name || into.ref[0])
605
- sql = `UPDATE ${this.quote(entity)}${into.as ? ` AS ${into.as}` : ``} SET ${this.quote(column)}=?`
606
- this.entries = [data]
615
+ data.type = 'json'
616
+ // REVISIT: decide whether dataset streams should behave like INSERT or UPSERT
617
+ sql = this.UPSERT(UPSERT([{}]).into(into).forSQL())
618
+ this.values = [data]
607
619
  }
608
- if (!_empty((x = where))) sql += ` WHERE ${this.where(x)}`
609
- if (from) sql += ` LIMIT ${this.limit({ rows: { val: 1 } })}`
620
+
610
621
  return (this.sql = sql)
611
622
  }
612
623
 
624
+ /**
625
+ * Renders a STREAM.from query into generic SQL
626
+ * @param {import('./infer/cqn').STREAM} q
627
+ * @returns {string} SQL
628
+ */
629
+ STREAM_from(q) {
630
+ const { column, from, where, columns } = q.STREAM
631
+
632
+ const select = cds.ql
633
+ .SELECT(column ? [column] : columns)
634
+ .where(where)
635
+ .limit(column ? 1 : undefined)
636
+
637
+ // SELECT.from() does not accept joins
638
+ select.SELECT.from = from
639
+
640
+ if (column) {
641
+ this.one = true
642
+ } else {
643
+ select.SELECT.expand = 'root'
644
+ this.one = !!from.SELECT?.one
645
+ }
646
+ return this.SELECT(select.forSQL())
647
+ }
648
+
613
649
  // Expression Clauses ---------------------------------------------
614
650
 
615
651
  /**
@@ -655,11 +691,35 @@ class CQN2SQLRenderer {
655
691
  * @returns {string} The correct operator string
656
692
  */
657
693
  operator(x, i, xpr) {
658
- if (x === '=' && xpr[i + 1]?.val === null) return 'is'
659
- if (x === '!=') return 'is not'
694
+
695
+ // Translate = to IS NULL for rhs operand being NULL literal
696
+ if (x === '=') return xpr[i+1]?.val === null ? 'is' : '='
697
+
698
+ // Translate == to IS NOT NULL for rhs operand being NULL literal, otherwise ...
699
+ // Translate == to IS NOT DISTINCT FROM, unless both operands cannot be NULL
700
+ if (x === '==') return xpr[i+1]?.val === null ? 'is' : _not_null(i-1) && _not_null(i+1) ? '=' : this.is_not_distinct_from_
701
+
702
+ // Translate != to IS NULL for rhs operand being NULL literal, otherwise...
703
+ // Translate != to IS DISTINCT FROM, unless both operands cannot be NULL
704
+ if (x === '!=') return xpr[i+1]?.val === null ? 'is not' : _not_null(i-1) && _not_null(i+1) ? '<>' : this.is_distinct_from_
705
+
660
706
  else return x
707
+
708
+ /** Checks if the operand at xpr[i+-1] can be NULL. @returns true if not */
709
+ function _not_null(i) {
710
+ const operand = xpr[i]
711
+ if (!operand) return false
712
+ if (operand.val != null) return true // non-null values are not null
713
+ let element = operand.element
714
+ if (!element) return false
715
+ if (element.key) return true // primary keys usually should not be null
716
+ if (element.notNull) return true // not null elements cannot be null
717
+ }
661
718
  }
662
719
 
720
+ get is_distinct_from_() { return 'is distinct from' }
721
+ get is_not_distinct_from_() { return 'is not distinct from' }
722
+
663
723
  /**
664
724
  * Renders an argument place holder into the SQL for prepared statements
665
725
  * @param {import('./infer/cqn').ref} param0
@@ -677,7 +737,12 @@ class CQN2SQLRenderer {
677
737
  * @returns {string} SQL
678
738
  */
679
739
  ref({ ref }) {
680
- return ref.map(r => this.quote(r)).join('.')
740
+ switch (ref[0]) {
741
+ case '$now': return this.func({ func: 'session_context', args: [{ val: '$now' }]})
742
+ case '$user':
743
+ case '$user.id': return this.func({ func: 'session_context', args: [{ val: '$user.id' }]})
744
+ default: return ref.map(r => this.quote(r)).join('.')
745
+ }
681
746
  }
682
747
 
683
748
  /**
@@ -687,22 +752,21 @@ class CQN2SQLRenderer {
687
752
  */
688
753
  val({ val }) {
689
754
  switch (typeof val) {
690
- case 'function':
691
- throw new Error('Function values not supported.')
692
- case 'undefined':
693
- return 'NULL'
694
- case 'boolean':
695
- return val
696
- case 'number':
697
- return val // REVISIT for HANA
755
+ case 'function': throw new Error('Function values not supported.')
756
+ case 'undefined': return 'NULL'
757
+ case 'boolean': return `${val}`
758
+ case 'number': return `${val}` // REVISIT for HANA
698
759
  case 'object':
699
760
  if (val === null) return 'NULL'
700
761
  if (val instanceof Date) return `'${val.toISOString()}'`
701
- if (Buffer.isBuffer(val)) val = val.toString('base64')
702
- else val = this.regex(val) || this.json(val)
762
+ if (val instanceof Readable) ; // go on with default below
763
+ else if (Buffer.isBuffer(val)) val = val.toString('base64')
764
+ else if (is_regexp(val)) val = val.source
765
+ else val = JSON.stringify(val)
766
+ case 'string': // eslint-disable-line no-fallthrough
703
767
  }
704
768
  if (!this.values) return this.string(val)
705
- this.values.push(val)
769
+ else this.values.push(val)
706
770
  return '?'
707
771
  }
708
772
 
@@ -727,25 +791,7 @@ class CQN2SQLRenderer {
727
791
  }
728
792
 
729
793
  /**
730
- * Renders a Regular Expression into its string representation
731
- * @param {RegExp} o
732
- * @returns {string} SQL
733
- */
734
- regex(o) {
735
- if (is_regexp(o)) return o.source
736
- }
737
-
738
- /**
739
- * Renders the object as a JSON string in generic SQL
740
- * @param {object} o
741
- * @returns {string} SQL
742
- */
743
- json(o) {
744
- return this.string(JSON.stringify(o))
745
- }
746
-
747
- /**
748
- * Renders a javascript string into a generic SQL string
794
+ * Renders a javascript string into a SQL string literal
749
795
  * @param {string} s
750
796
  * @returns {string} SQL
751
797
  */
@@ -760,8 +806,9 @@ class CQN2SQLRenderer {
760
806
  */
761
807
  column_name(col) {
762
808
  if (col === '*')
809
+ // 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?
763
810
  cds.error`Query was not inferred and includes '*' in the columns. For which there is no column name available.`
764
- return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.ref[col.ref.length - 1]
811
+ return (typeof col.as === 'string' && col.as) || ('val' in col && col.val + '') || col.func || col.ref.at(-1)
765
812
  }
766
813
 
767
814
  /**
@@ -783,7 +830,8 @@ class CQN2SQLRenderer {
783
830
  quote(s) {
784
831
  if (typeof s !== 'string') return '"' + s + '"'
785
832
  if (s.includes('"')) return '"' + s.replace(/"/g, '""') + '"'
786
- if (s.toUpperCase() in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
833
+ // Column names like "Order" clash with "ORDER" keyword so toUpperCase is required
834
+ if (s in this.class.ReservedWords || /^\d|[$' ?@./\\]/.test(s)) return '"' + s + '"'
787
835
  return s
788
836
  }
789
837
 
@@ -796,46 +844,39 @@ class CQN2SQLRenderer {
796
844
  */
797
845
  managed(columns, elements, isUpdate = false) {
798
846
  const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
799
- const inputConverterKey = this.class._convertInput
847
+ const { _convertInput } = this.class
800
848
  // Ensure that missing managed columns are added
801
849
  const requiredColumns = !elements
802
850
  ? []
803
851
  : Object.keys(elements)
804
852
  .filter(
805
853
  e =>
806
- (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual)) &&
854
+ (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
807
855
  !columns.find(c => c.name === e),
808
856
  )
809
857
  .map(name => ({ name, sql: 'NULL' }))
810
858
 
811
859
  return [...columns, ...requiredColumns].map(({ name, sql }) => {
812
- const element = elements?.[name] || {}
813
- let extract = sql ?? `value->>'$."${name}"'`
814
- const converter = element[inputConverterKey] || (e => e)
815
- let managed = element[annotation]?.['=']
816
- switch (managed) {
817
- case '$user.id':
818
- case '$user':
819
- managed = this.string(this.context.user.id)
820
- break
821
- case '$now':
822
- managed = this.string(this.context.timestamp.toISOString())
823
- break
824
- default:
825
- managed = undefined
826
- }
827
- if (!isUpdate) {
860
+ let element = elements?.[name] || {}
861
+ if (!sql) sql = `value->>'$."${name}"'`
862
+
863
+ let converter = element[_convertInput]
864
+ if (converter && sql[0] !== '$') sql = converter(sql, element)
865
+
866
+ let val = _managed[element[annotation]?.['=']]
867
+ if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
868
+
869
+ else if (!isUpdate && element.default) {
828
870
  const d = element.default
829
- if (d && (d.val !== undefined || d.ref?.[0] === '$now')) {
830
- extract = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(
831
- d.val,
832
- )} ELSE ${extract} END)`
871
+ if (d.val !== undefined || d.ref?.[0] === '$now') {
872
+ // REVISIT: d.ref is not used afterwards
873
+ sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${
874
+ this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
875
+ } ELSE ${sql} END)`
833
876
  }
834
877
  }
835
- return {
836
- name,
837
- sql: converter(managed === undefined ? extract : `coalesce(${extract}, ${managed})`, element),
838
- }
878
+
879
+ return { name, sql }
839
880
  })
840
881
  }
841
882
 
@@ -844,6 +885,7 @@ class CQN2SQLRenderer {
844
885
  * @param {string} defaultValue
845
886
  * @returns {string}
846
887
  */
888
+ // REVISIT: This is a strange method, also overridden inconsistently in postgres
847
889
  defaultValue(defaultValue = this.context.timestamp.toISOString()) {
848
890
  return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
849
891
  }
@@ -855,8 +897,11 @@ Buffer.prototype.toJSON = function () {
855
897
  }
856
898
 
857
899
  const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || []
858
- const has_expands = q => q.SELECT.columns?.some(c => c.SELECT?.expand)
859
- const has_arrays = q => q.elements && Object.values(q.elements).some(e => e.items)
900
+ const _managed = {
901
+ '$user.id': '$user.id',
902
+ $user: '$user.id',
903
+ $now: '$now',
904
+ }
860
905
 
861
906
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
862
907
  const _empty = a => !a || a.length === 0