@cap-js/sqlite 0.1.0 → 0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@
4
4
  - The format is based on [Keep a Changelog](http://keepachangelog.com/).
5
5
  - This project adheres to [Semantic Versioning](http://semver.org/).
6
6
 
7
+ ## Version 0.2.0 - 2023-05-03
8
+
9
+ - Continuous improvements
10
+
7
11
  ## Version 0.1.0 - 2023-04-04
8
12
 
9
13
  - Initial release
package/README.md CHANGED
@@ -76,7 +76,7 @@ cds repl --profile better-sqlite
76
76
  var { server } = await cds.test('bookshop')
77
77
  var { Books, Authors } = cds.entities
78
78
  await INSERT.into (Books) .entries ({ title: 'Unwritten Book' })
79
- await INSERT.into (Authors) .entries ({ name: 'Upcomming Author' })
79
+ await INSERT.into (Authors) .entries ({ name: 'Upcoming Author' })
80
80
  await SELECT `from ${Books} { title as book, author.name as author, genre.name as genre }`
81
81
  await SELECT `from ${Authors} { books.title as book, name as author, books.genre.name as genre }`
82
82
  await SELECT `from ${Books} { title as book, author[ID<170].name as author, genre.name as genre }`
@@ -174,7 +174,7 @@ The combination of the above-mentioned improvements commonly leads to significan
174
174
 
175
175
  ## Known Limitations & Changes
176
176
 
177
- - Node v14 is no longer supported → will be dropped anyways with upcomming cds7.
177
+ - Node v14 is no longer supported → will be dropped anyways with upcoming cds7.
178
178
  - JOINs and UNIONs by CQN are no longer supported → use plain SQL instead.
179
179
  * CQNs with subqueries require table aliases to refer to elements of outer queries.
180
180
  * CQNs with an empty columns array now throws an error.
@@ -43,16 +43,17 @@ class DatabaseService extends cds.Service {
43
43
  const pool = this.pools[tenant] ??= new Pool (this.pools._factory, tenant)
44
44
  const dbc = this.dbc = await pool.acquire()
45
45
  this._release = (dbc) => pool.release(dbc)
46
- // Setting session context variables
47
- await this.set({
48
- get '$user.id'(){ return _set (this, '$user.id', ctx.user?.id || 'anonymous') },
49
- get '$user.locale'(){ return _set (this, '$user.locale', ctx.locale || cds.env.i18n.default_language) },
50
- get '$valid.from'(){ return _set (this, '$valid.from', ctx._?.['VALID-FROM'] || ctx._?.['VALID-AT'] || '1970-01-01T00:00:00Z') },
51
- get '$valid.to'(){ return _set (this, '$valid.to', ctx._?.['VALID-TO'] || _validTo4(ctx._?.['VALID-AT']) || '9999-11-11T22:22:22Z') },
52
- })
53
- // Run BEGIN
54
- try { await this.send('BEGIN') }
55
- catch (e) { this._release(dbc); throw e }
46
+ try {
47
+ // Setting session context variables
48
+ await this.set({
49
+ get '$user.id'(){ return _set (this, '$user.id', ctx.user?.id || 'anonymous') },
50
+ get '$user.locale'(){ return _set (this, '$user.locale', ctx.locale || cds.env.i18n.default_language) },
51
+ get '$valid.from'(){ return _set (this, '$valid.from', ctx._?.['VALID-FROM']?.toISOString() || ctx._?.['VALID-AT']?.toISOString() || '1970-01-01T00:00:00Z') },
52
+ get '$valid.to'(){ return _set (this, '$valid.to', ctx._?.['VALID-TO']?.toISOString() || _validTo4(ctx._?.['VALID-AT'])?.toISOString() || '9999-11-11T22:22:22Z') },
53
+ })
54
+ // Run BEGIN
55
+ await this.send('BEGIN')
56
+ } catch (e) { this._release(dbc); throw e }
56
57
  return this
57
58
  }
58
59
 
@@ -1,4 +1,5 @@
1
1
  const cds = require('../../../cds'), DEBUG = cds.debug('sql|db')
2
+ const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
2
3
  const DatabaseService = require('../DatabaseService')
3
4
  const cqn4sql = require('./cqn4sql')
4
5
  const { target_name4 } = require('./utils')
@@ -11,7 +12,8 @@ class SQLService extends DatabaseService {
11
12
  this.on([ 'INSERT', 'UPSERT', 'UPDATE', 'DELETE' ], require('./workarounds').input) // REVISIT should be replaced by correct input processing eventually
12
13
  this.on([ 'INSERT', 'UPSERT', 'UPDATE', 'DELETE' ], require('./deep').onDeep)
13
14
  this.on([ 'SELECT' ], this.onSELECT)
14
- this.on([ 'INSERT', 'UPSERT' ], this.onINSERT)
15
+ this.on([ 'INSERT' ], this.onINSERT)
16
+ this.on([ 'UPSERT' ], this.onUPSERT)
15
17
  this.on([ 'UPDATE' ], this.onUPDATE)
16
18
  this.on([ 'DELETE', 'CREATE ENTITY', 'DROP ENTITY' ], this.onSIMPLE)
17
19
  this.on([ 'BEGIN', 'COMMIT', 'ROLLBACK' ], this.onEVENT)
@@ -35,7 +37,6 @@ class SQLService extends DatabaseService {
35
37
  return cqn.SELECT.one || query.SELECT.from.ref?.[0].cardinality?.max === 1 ? rows[0] || null : rows
36
38
  }
37
39
 
38
- /** Handler for INSERT & UPSERT, which support bulk queries */
39
40
  async onINSERT ({ query, data }) {
40
41
  const { sql, entries, cqn } = this.cqn2sql (query, data)
41
42
  if(!sql) return // Do nothing when there is nothing to be done
@@ -44,6 +45,14 @@ class SQLService extends DatabaseService {
44
45
  return new this.class.InsertResults (cqn, results)
45
46
  }
46
47
 
48
+ async onUPSERT ({ query, data }) {
49
+ const { sql, entries } = this.cqn2sql (query, data)
50
+ if(!sql) return // Do nothing when there is nothing to be done
51
+ const ps = await this.prepare(sql)
52
+ const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run()
53
+ return results.reduce((lastValue, currentValue) => lastValue += currentValue.changes, 0)
54
+ }
55
+
47
56
  /** Handler for UPDATE */
48
57
  async onUPDATE (req) {
49
58
  return this.onSIMPLE(req)
@@ -127,10 +136,21 @@ class SQLService extends DatabaseService {
127
136
  }
128
137
  cqn2sql(q,values) {
129
138
  const cqn = this.cqn4sql(q)
130
- return (new this.class.CQN2SQL) .render (cqn, values)
139
+
140
+ const cmd = cqn.cmd || Object.keys(cqn)[0]
141
+ if (cmd in { INSERT:1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
142
+ let resolvedCqn = resolveView(cqn, this.model, this)
143
+ if (resolvedCqn && resolvedCqn[cmd]._transitions?.[0].target) {
144
+ resolvedCqn = resolvedCqn || cqn
145
+ resolvedCqn.target = resolvedCqn?.[cmd]._transitions[0].target || cqn.target
146
+ }
147
+ return (new this.class.CQN2SQL(this.context)) .render (resolvedCqn, values)
148
+ }
149
+ return (new this.class.CQN2SQL(this.context)) .render (cqn, values)
131
150
  }
132
151
  cqn4sql(q) {
133
- if (!this.model?.definitions[target_name4(q)]) return _unquirked(q)
152
+ // REVISIT: move this check to cqn4sql?
153
+ if (!q.SELECT?.from?.join && !this.model?.definitions[target_name4(q)]) return _unquirked(q)
134
154
  return cqn4sql (q, this.model)
135
155
  }
136
156
 
@@ -14,7 +14,8 @@ const DEBUG = (()=>{
14
14
 
15
15
  class CQN2SQLRenderer {
16
16
 
17
- constructor() {
17
+ constructor(context) {
18
+ this.context = cds.context || context
18
19
  this.class = new.target // for IntelliSense
19
20
  this.class._init() // is a noop for subsequent calls
20
21
  }
@@ -55,15 +56,21 @@ class CQN2SQLRenderer {
55
56
  CREATE(q) {
56
57
  const { target } = q, { query } = target
57
58
  const name = this.name(target.name)
58
- return this.sql = !query
59
+ // Don't allow place holders inside views
60
+ delete this.values
61
+ this.sql = (!query || target['@cds.persistence.table'])
59
62
  ? `CREATE TABLE ${name} ( ${this.CREATE_elements(target.elements)} )`
60
63
  : `CREATE VIEW ${name} AS ${this.SELECT(cqn4sql(query))}`
64
+ this.values = []
65
+ return
61
66
  }
62
67
 
63
68
  CREATE_elements(elements) {
64
69
  let sql = ''
65
70
  for (let e in elements) {
66
- const s = this.CREATE_element(elements[e])
71
+ const definition = elements[e]
72
+ if(definition.isAssociation) continue
73
+ const s = this.CREATE_element(definition)
67
74
  if (s) sql += `${s}, `
68
75
  }
69
76
  return sql.slice(0, -2)
@@ -71,7 +78,7 @@ class CQN2SQLRenderer {
71
78
 
72
79
  CREATE_element(element) {
73
80
  const type = this.type4(element)
74
- if (type) return element.name + ' ' + type
81
+ if (type) return this.quote(element.name) + ' ' + type
75
82
  }
76
83
 
77
84
  type4 (element) {
@@ -95,10 +102,12 @@ class CQN2SQLRenderer {
95
102
  Composition: () => false,
96
103
  array: () => 'NCLOB',
97
104
  // HANA types
105
+ /* Disabled as these types are linked to normal cds types
106
+ 'cds.hana.TINYINT': () => 'REAL',
98
107
  'cds.hana.REAL': () => 'REAL',
99
108
  'cds.hana.CHAR': e => `CHAR(${e.length || 1})`,
100
- 'cds.hana.ST_POINT': () => 'CHAR',
101
- 'cds.hana.ST_GEOMETRY': () => 'CHAR',
109
+ 'cds.hana.ST_POINT': () => 'ST_POINT',
110
+ 'cds.hana.ST_GEOMETRY': () => 'ST_GEO',*/
102
111
  }
103
112
 
104
113
 
@@ -316,11 +325,16 @@ class CQN2SQLRenderer {
316
325
  UPSERT(q) {
317
326
  let { UPSERT } = q, sql = this.INSERT ({__proto__:q, INSERT:UPSERT })
318
327
  let keys = q.target?.keys; if (!keys) return this.sql = sql // REVISIT: We should converge q.target and q._target
319
- const updates = this.columns.filter(c => !(c in keys)) .map (c => `${c} = excluded.${c}`)
320
- const conflict = updates.length
321
- ? ` ON CONFLICT(${ Object.keys(keys) }) DO UPDATE SET ` + updates
322
- : ` ON CONFLICT(${ Object.keys(keys) }) DO NOTHING`
323
- return this.sql = `${sql} WHERE true ${conflict}` // REVISIT: Why do we have that "WHERE true" in here?
328
+ keys = Object.keys(keys).filter(k => !keys[k].isAssociation)
329
+
330
+ let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
331
+ updateColumns = updateColumns.filter(c => !keys.includes(c)).map (c => `${this.quote(c)} = excluded.${this.quote(c)}`)
332
+
333
+ keys = keys.map(k => this.quote(k))
334
+ const conflict = updateColumns.length
335
+ ? ` ON CONFLICT(${ keys }) DO UPDATE SET ` + updateColumns
336
+ : ` ON CONFLICT(${ keys }) DO NOTHING`
337
+ return this.sql = `${sql} WHERE true ${conflict}`
324
338
  }
325
339
 
326
340
 
@@ -385,7 +399,7 @@ class CQN2SQLRenderer {
385
399
 
386
400
  xpr({ xpr }) {
387
401
  return xpr.map((x,i) => {
388
- if (x in {LIKE:1,like:1} && is_regexp(xpr[i+1]?.val)) return 'regexp'
402
+ if (x in {LIKE:1,like:1} && is_regexp(xpr[i+1]?.val)) return this.operator('regexp')
389
403
  if (typeof x === 'string') return this.operator(x,i,xpr)
390
404
  if (x.xpr) return `(${this.xpr(x)})`
391
405
  else return this.expr(x)
@@ -409,6 +423,7 @@ class CQN2SQLRenderer {
409
423
 
410
424
  val({ val }) {
411
425
  switch (typeof val) {
426
+ case 'function': throw new Error('Function values not supported.')
412
427
  case 'undefined': return 'NULL'
413
428
  case 'boolean': return val
414
429
  case 'number': return val // REVISIT for HANA
@@ -418,6 +433,7 @@ class CQN2SQLRenderer {
418
433
  if (Buffer.isBuffer(val)) val = val.toString('base64')
419
434
  else val = this.regex(val) || this.json(val)
420
435
  }
436
+ if(!this.values) return this.string(val)
421
437
  this.values.push(val)
422
438
  return '?'
423
439
  }
@@ -425,7 +441,7 @@ class CQN2SQLRenderer {
425
441
  static Functions = require('./func')
426
442
  func({ func, args }) {
427
443
  args = (args||[]).map(e => e === '*' ? e : { __proto__:e, toString:(x=e)=>this.expr(x) })
428
- return this.class.Functions[func]?.(...args) || `${func}(${args})`
444
+ return this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
429
445
  }
430
446
 
431
447
  list({ list }) {
@@ -465,12 +481,9 @@ class CQN2SQLRenderer {
465
481
  const inputConverterKey = this.class._convertInput
466
482
  // Ensure that missing managed columns are added
467
483
  const requiredColumns = !elements ? [] : Object.keys(elements)
468
- .filter(e => (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default)) && !columns.find(c => c.name === e))
484
+ .filter(e => (elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual)) && !columns.find(c => c.name === e))
469
485
  .map(name => ({name,sql:'NULL'}))
470
486
 
471
- // try to call cds.context less frequent, and have a default
472
- const ctx = cds.context || { timestamp: new Date, user: cds.User.anonymous }
473
-
474
487
  return [...columns,...requiredColumns].map(({name,sql}) => {
475
488
  const element = elements?.[name] || {}
476
489
  let extract = sql ?? `value->>'$.${name}'`
@@ -479,11 +492,11 @@ class CQN2SQLRenderer {
479
492
  switch (managed) {
480
493
  case '$user.id':
481
494
  case '$user':
482
- managed = this.string(ctx.user.id)
495
+ managed = this.string(this.context.user.id)
483
496
  break
484
497
  case '$now':
485
498
  // REVISIT fix for date precision
486
- managed = this.string(ctx.timestamp.toISOString())
499
+ managed = this.string(this.context.timestamp.toISOString())
487
500
  break
488
501
  default:
489
502
  managed = undefined
@@ -491,8 +504,7 @@ class CQN2SQLRenderer {
491
504
  if(!isUpdate) {
492
505
  const d = element.default
493
506
  if(d && (d.val !== undefined || d.ref?.[0] === '$now')) {
494
- const defaultValue = d.val ?? (cds.context?.timestamp || new Date()).toISOString()
495
- extract = `(CASE WHEN json_type(value,'$.${name}') IS NULL THEN ${typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue} ELSE ${extract} END)`
507
+ extract = `(CASE WHEN json_type(value,'$.${name}') IS NULL THEN ${this.defaultValue(d.val)} ELSE ${extract} END)`
496
508
  }
497
509
  }
498
510
  return {
@@ -501,6 +513,10 @@ class CQN2SQLRenderer {
501
513
  }
502
514
  })
503
515
  }
516
+
517
+ defaultValue(defaultValue = (this.context.timestamp).toISOString()) {
518
+ return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
519
+ }
504
520
  }
505
521
 
506
522
  // REVISIT: Workaround for JSON.stringify to work with buffers
@@ -26,24 +26,22 @@ const {pseudos} = require('../../ql/pseudos')
26
26
  *
27
27
  * @param {object} query
28
28
  * @param {object} model
29
- * @param {object} context
30
29
  * @returns {object} transformedQuery the transformed query
31
30
  */
32
- function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = false) {
31
+ function cqn4sql(query, model = cds.context?.model || cds.model) {
32
+ const inferred = infer(query, model)
33
33
  if(query.SELECT?.from.args && !query.joinTree)
34
- return infer(query, model)
35
-
36
- let inferred
37
- // do not infer again - e.g. for subquery in from
38
- if(skipInfer)
39
- inferred = query
40
- else
41
- inferred = infer(query, model)
34
+ return inferred
42
35
 
43
36
  const transformedQuery = cds.ql.clone(inferred)
44
-
45
- if(inferred.INSERT || inferred.UPSERT) return inferred // nothing to do
46
37
  const kind = inferred.cmd || Object.keys(inferred)[0]
38
+ if(inferred.INSERT || inferred.UPSERT) {
39
+ const {as} = transformedQuery[kind].into
40
+ transformedQuery[kind].into = {ref: [inferred.target.name]}
41
+ if(as)
42
+ transformedQuery[kind].into.as = as
43
+ return transformedQuery
44
+ }
47
45
  const _ = inferred[kind]
48
46
  if (_) {
49
47
  const { from, entity, where } = _
@@ -445,7 +443,7 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
445
443
  where = subqueryFollowingExists(recent = where, i).SELECT.where
446
444
  i = where.indexOf('exists')
447
445
  }
448
- const existsIndex = recent.lastIndexOf('exists')
446
+ const existsIndex = recent.indexOf('exists')
449
447
  recent.splice (existsIndex, 2, ...where.map (
450
448
  (x) => {
451
449
  return replaceAliasWithSubqueryAlias(x)
@@ -838,7 +836,11 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
838
836
  } else if(token.xpr) {
839
837
  result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
840
838
  } else if(token.func && token.args) {
841
- result.args = getTransformedTokenStream(token.args, $baseLink)
839
+ result.args = token.args.map(t => {
840
+ if(!t.val) // this must not be touched
841
+ return getTransformedTokenStream([t], $baseLink)[0]
842
+ return t
843
+ })
842
844
  }
843
845
 
844
846
  transformedWhere.push(result)
@@ -1175,6 +1177,7 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1175
1177
  // REVISIT: this whole section needs a refactoring, it is too complex and some edge cases may still be not considered...
1176
1178
  const refLinkFaker = (thing) => {
1177
1179
  const {ref} = thing
1180
+ const assocHost = getParentEntity(assocRefLink.definition)
1178
1181
  Object.defineProperty(thing, '$refLinks', {
1179
1182
  value: [],
1180
1183
  writable: true,
@@ -1186,7 +1189,7 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1186
1189
  const target = getParentEntity(prev)
1187
1190
  thing.$refLinks[i] = {definition, target, alias: definition.name}
1188
1191
  return prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
1189
- }, getParentEntity(assocRefLink.definition))
1192
+ }, assocHost)
1190
1193
  };
1191
1194
 
1192
1195
  // comparison in on condition needs to be expanded...
@@ -1272,12 +1275,18 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1272
1275
  }
1273
1276
  return result
1274
1277
  }
1278
+ /**
1279
+ * Recursively calculates the containing entity for a given element.
1280
+ *
1281
+ * @param {CSN.element} element
1282
+ * @returns {CSN.definition} the entity containing the given element
1283
+ */
1275
1284
  function getParentEntity(element) {
1276
1285
  if(!element.kind) // pseudo element
1277
1286
  return element
1278
1287
  if(element.kind === 'entity')
1279
1288
  return element
1280
- else return getParentEntity(element.parent)
1289
+ else return model.definitions[localized(getParentEntity(element.parent))]
1281
1290
  }
1282
1291
  }
1283
1292
 
@@ -1372,7 +1381,7 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1372
1381
  const SELECT = {
1373
1382
  from: {
1374
1383
  ref: [
1375
- nextDefinition.target || nextDefinition.name
1384
+ localized(assocTarget(nextDefinition) || nextDefinition)
1376
1385
  ],
1377
1386
  as: next.alias
1378
1387
  },
@@ -1397,9 +1406,8 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1397
1406
  function localized(definition) {
1398
1407
  if(!isLocalized(definition))
1399
1408
  return definition.name
1400
- const locale = cds.context?.locale || 'en'
1401
- const view = model.definitions[`localized${locale in _translations ? `.${locale}` : ''}.${definition.name}`]
1402
- return view?.name || model.definitions[`localized.${definition.name}`]?.name || definition.name
1409
+ const view = model.definitions[`localized.${definition.name}`]
1410
+ return view?.name || definition.name
1403
1411
  }
1404
1412
 
1405
1413
  /**
@@ -1414,6 +1422,16 @@ function cqn4sql(query, model = cds.context?.model || cds.model, skipInfer = fal
1414
1422
  return inferred.SELECT?.localized && definition['@cds.localized'] !== false
1415
1423
  }
1416
1424
 
1425
+ /**
1426
+ * Get the csn definition of the target of a given association
1427
+ *
1428
+ * @param assoc
1429
+ * @returns the csn definition of the association target or null if it is not an association
1430
+ */
1431
+ function assocTarget(assoc) {
1432
+ return model.definitions[assoc.target] || null
1433
+ }
1434
+
1417
1435
  /**
1418
1436
  * Calculate the flat name for a deeply nested element:
1419
1437
  * @example `entity E { struct: { foo: String} }` => `getFullName(foo)` => `struct_foo`
@@ -1455,7 +1473,5 @@ module.exports = cqn4sql
1455
1473
  function hasLogicalOr(tokenStream) {
1456
1474
  return tokenStream.some(t => t in {'OR': true, 'or': true})
1457
1475
  }
1458
- // revisit don't do sqlite specific stuff here
1459
- const _translations = cds.env.i18n.for_sqlite.reduce((all, l) => ((all[l] = true), all), {})
1460
1476
  const idOnly = (ref) => ref.id || ref
1461
1477
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
@@ -1,5 +1,4 @@
1
1
  const cds = require('@sap/cds')
2
- const { getDBTable } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
3
2
  const { compareJson } = require('@sap/cds/libx/_runtime/cds-services/services/utils/compareJson')
4
3
  const { target_name4 } = require('./utils')
5
4
 
@@ -17,7 +16,11 @@ async function onDeep (req, next) {
17
16
  if (!hasDeep(query, target)) return next()
18
17
  const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true))
19
18
  const queries = getDeepQueries(query, beforeData, target)
20
- const res = await Promise.all(queries.map (q => this.run(q)))
19
+ const res = await Promise.all(queries.map (query => {
20
+ if (query.INSERT) return this.onINSERT({query})
21
+ if (query.UPDATE) return this.onUPDATE({query})
22
+ if (query.DELETE) return this.onSIMPLE({query})
23
+ }))
21
24
  return res[0] ?? 0 // TODO what todo with multiple result responses?
22
25
  }
23
26
 
@@ -61,14 +64,22 @@ const getColumnsFromDataOrKeys = (data, target) => {
61
64
  }
62
65
  }
63
66
 
64
- const _calculateExpandColumns = (compositions, data, expandColumns = [], elementMap = new Map()) => {
67
+ const _calculateExpandColumns = (target, data, expandColumns = [], elementMap = new Map()) => {
68
+ const compositions = target.compositions || {}
69
+
70
+ if(expandColumns.length === 0) {
71
+ // REVISIT: ensure that all keys are included in the expand columns
72
+ expandColumns.push(...getColumnsFromDataOrKeys(data, target))
73
+ }
74
+
65
75
  for (const compName in compositions) {
66
76
  let compositionData
67
- if (data === null) {
77
+ if (data === null || (Array.isArray(data) && !data.length)) {
68
78
  compositionData = null
69
79
  } else {
70
80
  compositionData = data[compName]
71
81
  }
82
+
72
83
  // ignore not provided compositions as nothing happens with them (expect deep delete)
73
84
  if (compositionData === undefined) {
74
85
  // fill columns in case
@@ -90,37 +101,30 @@ const _calculateExpandColumns = (compositions, data, expandColumns = [], element
90
101
  ref: [composition.name],
91
102
  expand: getColumnsFromDataOrKeys(compositionData, composition._target)
92
103
  }
93
- if (expandColumns.length === 0) {
94
- expandColumns.push(...getColumnsFromDataOrKeys(data, composition.parent))
95
- }
96
104
 
97
105
  expandColumns.push(expandColumn)
98
106
  }
99
107
 
100
- const targetCompositions = composition._target.compositions
101
-
102
108
  // expand deep
103
- if (targetCompositions) {
104
- // Make a copy and do not share the same map among brother compositions
105
- // as we're only interested in deep recursions, not wide recursions.
106
- const newElementMap = new Map(elementMap)
107
- newElementMap.set(fqn, (seen && seen + 1) || 1)
108
-
109
- if (composition.is2many) {
110
- // expandColumn.expand = getColumnsFromDataOrKeys(compositionData, composition._target)
111
- if (compositionData === null || compositionData.length === 0) {
112
- // deep delete, get all subitems until recursion depth
113
- _calculateExpandColumns(targetCompositions, compositionData, expandColumn.expand, newElementMap)
114
- return
115
- }
109
+ // Make a copy and do not share the same map among brother compositions
110
+ // as we're only interested in deep recursions, not wide recursions.
111
+ const newElementMap = new Map(elementMap)
112
+ newElementMap.set(fqn, (seen && seen + 1) || 1)
113
+
114
+ if (composition.is2many) {
115
+ // expandColumn.expand = getColumnsFromDataOrKeys(compositionData, composition._target)
116
+ if (compositionData === null || compositionData.length === 0) {
117
+ // deep delete, get all subitems until recursion depth
118
+ _calculateExpandColumns(composition._target, null, expandColumn.expand, newElementMap)
119
+ continue
120
+ }
116
121
 
117
- for (const row of compositionData) {
118
- _calculateExpandColumns(targetCompositions, row, expandColumn.expand, newElementMap)
119
- }
120
- } else {
121
- // to one
122
- _calculateExpandColumns(targetCompositions, compositionData, expandColumn.expand, newElementMap)
122
+ for (const row of compositionData) {
123
+ _calculateExpandColumns(composition._target, row, expandColumn.expand, newElementMap)
123
124
  }
125
+ } else {
126
+ // to one
127
+ _calculateExpandColumns(composition._target, compositionData, expandColumn.expand, newElementMap)
124
128
  }
125
129
  }
126
130
  }
@@ -133,10 +137,8 @@ const getExpandForDeep = (query, target) => {
133
137
  const cqn = SELECT.from(from)
134
138
  if (where) cqn.SELECT.where = where
135
139
 
136
- const compositions = target.compositions || {}
137
-
138
140
  const columns = []
139
- _calculateExpandColumns(compositions, data, columns)
141
+ _calculateExpandColumns(target, data, columns)
140
142
  cqn.columns(columns)
141
143
  return cqn
142
144
  }
@@ -175,16 +177,10 @@ const _getDeepQueries = (diff, target) => {
175
177
 
176
178
  if (target.elements[prop] && _hasPersistenceSkip(target.elements[prop]._target)) {
177
179
  delete diffEntry[prop]
178
- } else if (propData && propData._op) {
179
- // to-one can also contain deep, so call recursively
180
- subQueries.push(..._getDeepQueries([propData], target.elements[prop]._target))
181
- delete diffEntry[prop]
182
- } else if (Array.isArray(propData)) {
183
- // REVISIT can be arrayed too
184
- propData.forEach(subEntry => {
185
- if (subEntry._op) {
180
+ } else if (target.compositions?.[prop]) {
181
+ const arrayed = Array.isArray(propData) ? propData : [propData]
182
+ arrayed.forEach(subEntry => {
186
183
  subQueries.push(..._getDeepQueries([subEntry], target.elements[prop]._target))
187
- }
188
184
  })
189
185
  delete diffEntry[prop]
190
186
  }
@@ -198,16 +194,17 @@ const _getDeepQueries = (diff, target) => {
198
194
  delete diffEntry._old
199
195
  }
200
196
 
201
- // first calculate subqueriesand rm their properties, then build root query
197
+ // first calculate subqueries and rm their properties, then build root query
202
198
  if (op === 'create') {
203
- queries.push(INSERT.into(getDBTable(target)).entries(diffEntry))
199
+ queries.push(INSERT.into(target).entries(diffEntry))
204
200
  } else if (op === 'delete') {
205
- queries.push(DELETE.from(getDBTable(target)).where(diffEntry))
201
+ queries.push(DELETE.from(target).where(diffEntry))
206
202
  } else if (op === 'update') {
207
203
  // TODO do we need the where here?
208
204
  const keys = target.keys
209
- const cqn = UPDATE(getDBTable(target)).with(diffEntry)
205
+ const cqn = UPDATE(target).with(diffEntry)
210
206
  for (const key in keys) {
207
+ if (keys[key].virtual) continue
211
208
  if (!keys[key].isAssociation) {
212
209
  cqn.where(key + '=', diffEntry[key])
213
210
  }
@@ -4,14 +4,15 @@ const StandardFunctions = {
4
4
 
5
5
  // String and Collection Functions
6
6
  // length : (x) => `length(${x})`,
7
- search: (ref, arg) => {
7
+ search: function(ref, arg) {
8
8
  if (!('val' in arg)) throw `SQLite only supports single value arguments for $search`
9
9
  const refs = ref.list || [ref], {toString} = ref
10
- return '(' + refs.map(ref2 => `ifnull(instr(lower(${toString(ref2)}),lower(${arg})),0)`).join(' or ') + ')'
10
+ return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)),this.tolower(arg))).join(' or ') + ')'
11
11
  },
12
12
  concat : (...args) => args.join('||'),
13
13
  contains : (...args) => `ifnull(instr(${args}),0)`,
14
14
  count : (x) => `count(${x||'*'})`,
15
+ countdistinct : (x) => `count(distinct ${x||'*'})`,
15
16
  indexof : (x,y) => `instr(${x},${y}) - 1`, // sqlite instr is 1 indexed
16
17
  startswith : (x,y) => `instr(${x},${y}) = 1`, // sqlite instr is 1 indexed
17
18
  endswith : (x,y) => `instr(${x},${y}) = length(${x}) - length(${y}) +1`,
@@ -1,4 +1,3 @@
1
- const { resolveView } = require('@sap/cds/libx/_runtime/common/utils/resolveView')
2
1
  const cds = require('@sap/cds')
3
2
  const propagateForeignKeys = require('@sap/cds/libx/_runtime/common/utils/propagateForeignKeys')
4
3
  const { enrichDataWithKeysFromWhere } = require('@sap/cds/libx/_runtime/common/utils/keys')
@@ -38,7 +37,7 @@ const generateUUIDandPropagateKeys = (target, data, event) => {
38
37
 
39
38
  const input = async function (req, next) {
40
39
  // REVISIT dummy handler until we have input processing
41
- if (!req.target || !this.model) return next()
40
+ if (!req.target || !this.model || req.target._unresolved) return next()
42
41
 
43
42
  if (req.event === "UPDATE") {
44
43
  // REVISIT for deep update we need to inject the keys first
@@ -56,15 +55,6 @@ const input = async function (req, next) {
56
55
  }
57
56
  }
58
57
 
59
- const cmd = req.query.cmd || Object.keys(req.query)[0]
60
-
61
- const resolved = resolveView(req.query, this.model, this)
62
-
63
- if (resolved && resolved[cmd]._transitions?.[0].target) {
64
- req.query = resolved || req.query
65
- req.target = resolved?.[cmd]._transitions[0].target
66
- }
67
-
68
58
  return next()
69
59
  }
70
60
 
@@ -96,6 +96,11 @@ class SQLiteService extends SQLService {
96
96
  Double: expr => `nullif(quote(${expr}),'NULL')->'$'`,
97
97
  struct: expr => `${expr}->'$'`, // Association + Composition inherits from struct
98
98
  array: expr => `${expr}->'$'`,
99
+ // REVISIT: Timestamp should not loos precision
100
+ Date: e => `strftime('%Y-%m-%d',${e})`,
101
+ Time: e => `strftime('%H:%M:%S',${e})`,
102
+ DateTime: e => `strftime('%Y-%m-%dT%H:%M:%SZ',${e})`,
103
+ Timestamp: e => `strftime('%Y-%m-%dT%H:%M:%fZ',${e})`,
99
104
  }
100
105
 
101
106
  // Used for SQL function expressions
@@ -129,12 +129,14 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
129
129
  * @param {object} arg the arg which shall be augmented
130
130
  * @param {$refLink} $baseLink environment where the first `ref` step shall be resolved in.
131
131
  * For infix filter / expand columns
132
- * @param {boolean} expand whether the `arg` is part of a `column.expand`
132
+ * @param {boolean} expandOrExists whether the `arg` is part of a `column.expand` /
133
+ * preceded by an `exists`.
134
+ * In those cases, unmanaged association paths are allowed .
133
135
  */
134
- function attachRefLinksToArg(arg, $baseLink = null, expand = false) {
136
+ function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
135
137
  const { ref, xpr } = arg;
136
138
  if(xpr)
137
- xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expand))
139
+ xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
138
140
  if (!ref)
139
141
  return;
140
142
  init$refLinks(arg);
@@ -150,10 +152,10 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
150
152
  if (e.target) { // only fk access in infix filter
151
153
  const nextStep = ref[1]?.id || ref[1];
152
154
  // no unmanaged assoc in infix filter path
153
- if (!expand && e.on)
154
- throw new Error(`"${ e.name }" in path "${ arg.ref.join('.') }" must not be an unmanaged association`);
155
+ if (!expandOrExists && e.on)
156
+ throw new Error(`"${ e.name }" in path "${ arg.ref.map(idOnly).join('.') }" must not be an unmanaged association`);
155
157
  // no non-fk traversal in infix filter
156
- if (!expand && nextStep && !(nextStep in e.foreignKeys))
158
+ if (!expandOrExists && nextStep && !(nextStep in e.foreignKeys))
157
159
  throw new Error(`Only foreign keys of "${ e.name }" can be accessed in infix filter`);
158
160
  }
159
161
  arg.$refLinks.push({ definition: e, target: e._target || e });
@@ -179,8 +181,22 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
179
181
 
180
182
  // link refs in where
181
183
  if (step.where) { // REVISIT: why do we need to walk through these so early?
182
- if (arg.$refLinks[i].definition.kind === 'entity' || arg.$refLinks[i].definition._target)
183
- step.where.forEach(each => (each.ref || each.xpr) && attachRefLinksToArg(each, arg.$refLinks[i]));
184
+ if (arg.$refLinks[i].definition.kind === 'entity' || arg.$refLinks[i].definition._target) {
185
+ let existsPredicate = false
186
+ const walkTokenStream = (token) => {
187
+ if (token === 'exists') { // no joins for infix filters along `exists <path>`
188
+ existsPredicate = true;
189
+ } else if(token.xpr) {
190
+ // don't miss an exists within an expression
191
+ token.xpr.forEach(walkTokenStream)
192
+ }
193
+ else {
194
+ attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate)
195
+ existsPredicate = false
196
+ }
197
+ }
198
+ step.where.forEach(walkTokenStream);
199
+ }
184
200
  else
185
201
  throw new Error('A filter can only be provided when navigating along associations');
186
202
  }
@@ -314,7 +330,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
314
330
  }
315
331
  }
316
332
  where.forEach(walkTokenStream);
317
-
318
333
  }
319
334
  if (groupBy) // link $refLinks
320
335
  groupBy.forEach(token => inferQueryElement(token, false));
@@ -341,7 +356,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
341
356
  * In some cases, no joins must be created for non-assoc path traversals:
342
357
  * - for infix filters in `exists assoc[parent.foo='bar']` -> part of semi join
343
358
  */
344
- function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, inExists = false, inInline = false) {
359
+ function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, inExists = false) {
345
360
  if (column.param)
346
361
  return; // parameter references are only resolved into values on execution e.g. :val, :1 or ?
347
362
  if (column.args)
@@ -364,6 +379,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
364
379
  const firstStepIsSelf
365
380
  = !firstStepIsTableAlias && column.ref.length > 1 && [ '$self', '$projection' ].includes(column.ref[0]);
366
381
  const nameSegments = [];
382
+ const skipNameStack = [];
367
383
  let pseudoPath = false;
368
384
  column.ref.forEach((step, i) => {
369
385
  const id = step.id || step;
@@ -377,8 +393,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
377
393
  const { definition, target } = $baseLink;
378
394
  const elements = definition.elements || definition._target?.elements;
379
395
  if (elements && id in elements) {
380
- if (!inExists && isStepJoinRelevant(definition, id, inInline ? null : $baseLink))
381
- Object.defineProperty(column, 'isJoinRelevant', { value: true });
382
396
  column.$refLinks.push({ definition: elements[id], target });
383
397
  }
384
398
  else {
@@ -415,8 +429,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
415
429
  const elements = definition.elements || definition._target?.elements;
416
430
  if (elements && id in elements) {
417
431
  const $refLink = { definition: elements[id], target: column.$refLinks[i - 1].target };
418
- if (!inExists && isStepJoinRelevant(definition, id))
419
- Object.defineProperty(column, 'isJoinRelevant', { value: true });
420
432
  column.$refLinks.push($refLink);
421
433
  }
422
434
  else if (firstStepIsSelf) {
@@ -436,8 +448,19 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
436
448
  stepNotFoundInPredecessor(id, notFoundIn);
437
449
  }
438
450
  const foreignKeyAlias = Array.isArray(definition.keys)
439
- ? definition.keys.find(k => k.ref[0] === id )?.as : null;
440
- nameSegments.push(foreignKeyAlias || id);
451
+ ? definition.keys.find(k =>{
452
+ if(k.ref[0] === id) {
453
+ skipNameStack.push(...k.ref.slice(1))
454
+ return true
455
+ }
456
+ return false
457
+ })?.as : null;
458
+ if(foreignKeyAlias)
459
+ nameSegments.push(foreignKeyAlias);
460
+ else if(skipNameStack[0] === id)
461
+ skipNameStack.shift()
462
+ else
463
+ nameSegments.push(id)
441
464
  }
442
465
 
443
466
  if (step.where) {
@@ -455,6 +478,9 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
455
478
  else if (token.ref || token.xpr) {
456
479
  inferQueryElement(token, false, column.$refLinks[i], skipJoinsForFilter);
457
480
  }
481
+ else if (token.func) {
482
+ token.args?.forEach(arg => inferQueryElement(arg, false, column.$refLinks[i], skipJoinsForFilter));
483
+ }
458
484
  });
459
485
  }
460
486
 
@@ -503,9 +529,11 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
503
529
  return;
504
530
  }
505
531
  }
506
-
507
- if (!inExists && column.isJoinRelevant)
532
+ // check if we need to merg the column `ref` into the join tree of the query
533
+ if (!inExists && isColumnJoinRelevant(column)) {
534
+ Object.defineProperty(column, 'isJoinRelevant', { value: true });
508
535
  joinTree.mergeColumn(column);
536
+ }
509
537
 
510
538
 
511
539
  function resolveInline(col, namePrefix = col.as || col.flatName) {
@@ -623,10 +651,53 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
623
651
  }
624
652
  }
625
653
 
626
- function isStepJoinRelevant(definition, id, inInfixFilter = null) {
627
- return (definition.on || Array.isArray(definition.keys) &&
628
- /* infix filter in column always join relevant, even if fk access */
629
- (inInfixFilter || !definition.keys.some(fk => fk.as === id || fk.ref[fk.ref.length - 1] === id)));
654
+ /**
655
+ * Checks whether or not the `ref` of the given column is join relevant.
656
+ * A `ref` is considered join relevant if it includes an association traversal and:
657
+ * - the association is unmanaged
658
+ * - a non-foreign key access is performed
659
+ * - an infix filter is applied at the association
660
+ *
661
+ * @param {object} column the column with the `ref` to check for join relevance
662
+ * @returns {boolean} true if the column ref needs to be merged into a join tree
663
+ */
664
+ function isColumnJoinRelevant(column) {
665
+ let fkAccess = false;
666
+ let assoc = null
667
+ for(let i = 0; i<column.ref.length; i++) {
668
+ const ref = column.ref[i];
669
+ const link = column.$refLinks[i];
670
+ if(link.definition.on) {
671
+ if(!column.ref[i+1]) {
672
+ if(column.expand && assoc)
673
+ return true;
674
+ // if unmanaged assoc is exposed, ignore it
675
+ return false;
676
+ }
677
+ return true;
678
+ }
679
+ if(assoc && assoc.keys?.some(key => key.ref[0] === ref)) {
680
+ fkAccess = true
681
+ continue;
682
+ }
683
+ if(link.definition.target && link.definition.keys) {
684
+ if(column.ref[i+1] || assoc)
685
+ fkAccess = false;
686
+ else
687
+ fkAccess = true
688
+ assoc = link.definition;
689
+ if(ref.where) {
690
+ // always join relevant except for expand assoc
691
+ if(column.expand && !column.ref[i+1])
692
+ return false;
693
+ return true;
694
+ }
695
+ }
696
+ }
697
+
698
+ if(!assoc) return false
699
+ if(fkAccess) return false
700
+ else return true
630
701
  }
631
702
 
632
703
  /**
@@ -139,7 +139,6 @@ class JoinTree {
139
139
  }
140
140
  i += 1;
141
141
  }
142
-
143
142
  return true;
144
143
  }
145
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/sqlite",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "CDS database service for SQLite",
5
5
  "homepage": "https://cap.cloud.sap/",
6
6
  "keywords": [
@@ -10,7 +10,6 @@
10
10
  ],
11
11
  "author": "SAP SE (https://www.sap.com)",
12
12
  "license": "SEE LICENSE",
13
- "homepage": "https://cap.cloud.sap/",
14
13
  "main": "index.js",
15
14
  "files": [
16
15
  "cds.js",
@@ -30,23 +29,27 @@
30
29
  "pg:up": "docker-compose -f etc/pg-stack.yml up -d",
31
30
  "prettier": "npx prettier --write .",
32
31
  "test": "jest --silent",
32
+ "test:all": "npm run test:none && npm run test:sqlite && npm run test:pg",
33
+ "test:none": "DB=none npm run test",
34
+ "test:sqlite": "DB=sqlite npm run test",
35
+ "test:pg": "npm run pg:up && DB=pg npm run test",
33
36
  "lint": "npx eslint . && npx prettier --check ."
34
37
  },
35
38
  "dependencies": {
36
39
  "better-sqlite3": "^8"
37
40
  },
38
41
  "peerDependencies": {
39
- "@sap/cds": "*"
42
+ "@sap/cds": ">=6.8.0"
40
43
  },
41
44
  "devDependencies": {
42
- "@capire/sflight": "sap-samples/cap-sflight",
43
45
  "@cap-js/sqlite": ".",
46
+ "@capire/sflight": "sap-samples/cap-sflight",
44
47
  "axios": ">=1.3",
45
48
  "chai": "^4.3.7",
46
49
  "chai-as-promised": "^7.1.1",
47
50
  "chai-subset": "^1.6.0",
48
51
  "express": "^4",
49
- "pg": "^8",
50
- "jest": "^29"
52
+ "jest": "^29",
53
+ "pg": "^8"
51
54
  }
52
55
  }