@cap-js/db-service 1.18.0 → 1.19.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,20 @@
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
+ ## [1.19.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.18.0...db-service-v1.19.0) (2025-03-31)
8
+
9
+
10
+ ### Added
11
+
12
+ * **forUpdate:** ignore locked ([#1074](https://github.com/cap-js/cds-dbs/issues/1074)) ([163480b](https://github.com/cap-js/cds-dbs/commit/163480b245b18a2829cd871c2f053c82bcc1abef))
13
+ * support recursive cqn queries ([#1089](https://github.com/cap-js/cds-dbs/issues/1089)) ([f09b0f8](https://github.com/cap-js/cds-dbs/commit/f09b0f815c3788349f3d39419990cd1c00963b7d))
14
+
15
+
16
+ ### Fixed
17
+
18
+ * assign more technical, implicit table aliases ([#1082](https://github.com/cap-js/cds-dbs/issues/1082)) ([1f8925a](https://github.com/cap-js/cds-dbs/commit/1f8925a5d4bcc0123f3abbee2c65ed77877da591))
19
+ * consider `nulls first | last` on `orderBy` ([#1064](https://github.com/cap-js/cds-dbs/issues/1064)) ([c6bed60](https://github.com/cap-js/cds-dbs/commit/c6bed60f0d93b9f4a73c976727f30172707c60d9)), closes [#1062](https://github.com/cap-js/cds-dbs/issues/1062)
20
+
7
21
  ## [1.18.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.17.2...db-service-v1.18.0) (2025-03-04)
8
22
 
9
23
 
@@ -34,7 +34,7 @@ module.exports = class InsertResult {
34
34
  })
35
35
  }
36
36
 
37
- const { target } = this.query
37
+ const target = this.query._target
38
38
  if (!target?.keys) return (super[iterator] = this.results[iterator])
39
39
  const keys = Object.keys(target.keys),
40
40
  [k1] = keys
package/lib/SQLService.js CHANGED
@@ -25,7 +25,7 @@ class SQLService extends DatabaseService {
25
25
  this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
26
26
  if (cds.env.features.db_strict) {
27
27
  this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => {
28
- const elements = query.target?.elements
28
+ const elements = query._target?.elements
29
29
  if (!elements) return
30
30
  const kind = query.kind || Object.keys(query)[0]
31
31
  const operation = query[kind]
@@ -120,10 +120,10 @@ class SQLService extends DatabaseService {
120
120
  async onSELECT({ query, data }) {
121
121
  // REVISIT: for custom joins, infer is called twice, which is bad
122
122
  // --> make cds.infer properly work with custom joins and remove this
123
- if (!query.target) {
123
+ if (!(query._target instanceof cds.entity)) {
124
124
  try { this.infer(query) } catch { /**/ }
125
125
  }
126
- if (query.target && !query.target._unresolved) {
126
+ if (!query._target?._unresolved) { // REVISIT: use query._target instead
127
127
  // Will return multiple rows with objects inside
128
128
  query.SELECT.expand = 'root'
129
129
  }
@@ -267,7 +267,7 @@ class SQLService extends DatabaseService {
267
267
  )
268
268
  // Prepare and run deep query, à la CQL`DELETE from Foo[pred]:comp1.comp2...`
269
269
  const query = DELETE.from({ ref: [...from.ref, c.name] })
270
- query.target = c._target
270
+ query._target = c._target
271
271
  return this.onDELETE({ query, depth, visited: [...visited], target: c._target })
272
272
  }),
273
273
  )
@@ -382,7 +382,7 @@ class SQLService extends DatabaseService {
382
382
  if (kind in { INSERT: 1, DELETE: 1, UPSERT: 1, UPDATE: 1 }) {
383
383
  q = resolveView(q, this.model, this) // REVISIT: before resolveView was called on flat cqn obtained from cqn4sql -> is it correct to call on original q instead?
384
384
  let target = q[kind]._transitions?.[0].target
385
- if (target) q.target = target // REVISIT: Why isn't that done in resolveView?
385
+ if (target) q._target = target // REVISIT: Why isn't that done in resolveView?
386
386
  }
387
387
  let cqn2sql = new this.class.CQN2SQL(this)
388
388
  return cqn2sql.render(q, values)
@@ -23,8 +23,8 @@ class TemporalSessionContext extends SessionContext {
23
23
  get '$valid.to'() {
24
24
  return (super['$valid.to'] =
25
25
  this.ctx._?.['VALID-TO'] ??
26
- this.ctx._?.['VALID-AT']?.replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || '') ??
27
- new Date().toISOString().replace(/(\dZ?)$/, d => parseInt(d[0]) + 1 + d[1] || ''))
26
+ this.ctx._?.['VALID-AT']?.replace(/\.(\d*)(Z?)$/, (_, d, z) => `.${parseInt(d) + 1}${z}`) ??
27
+ (new Date(Date.now() + 1)).toISOString())
28
28
  }
29
29
  }
30
30
 
package/lib/cqn2sql.js CHANGED
@@ -25,7 +25,7 @@ class CQN2SQLRenderer {
25
25
  if (cds.env.sql.names === 'quoted') {
26
26
  this.class.prototype.name = (name, query) => {
27
27
  const e = name.id || name
28
- return (query?.target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
28
+ return (query?._target || this.model?.definitions[e])?.['@cds.persistence.name'] || e
29
29
  }
30
30
  this.class.prototype.quote = (s) => `"${String(s).replace(/"/g, '""')}"`
31
31
  }
@@ -105,7 +105,7 @@ class CQN2SQLRenderer {
105
105
  * @returns {import('./infer/cqn').Query}
106
106
  */
107
107
  infer(q) {
108
- return q.target ? q : cds_infer(q)
108
+ return q._target instanceof cds.entity ? q : cds_infer(q)
109
109
  }
110
110
 
111
111
  cqn4sql(q) {
@@ -226,7 +226,7 @@ class CQN2SQLRenderer {
226
226
  * @param {import('./infer/cqn').SELECT} q
227
227
  */
228
228
  SELECT(q) {
229
- let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized, forUpdate, forShareLock } =
229
+ let { from, expand, where, groupBy, having, orderBy, limit, one, distinct, localized, forUpdate, forShareLock, recurse } =
230
230
  q.SELECT
231
231
 
232
232
  if (from?.join && !q.SELECT.columns) {
@@ -239,12 +239,13 @@ class CQN2SQLRenderer {
239
239
  let sql = `SELECT`
240
240
  if (distinct) sql += ` DISTINCT`
241
241
  if (!_empty(columns)) sql += ` ${columns}`
242
- if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
242
+ if (recurse) sql += ` FROM ${this.SELECT_recurse(q)}`
243
+ else if (!_empty(from)) sql += ` FROM ${this.from(from, q)}`
243
244
  else sql += this.from_dummy()
244
- if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
245
- if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
246
- if (!_empty(having)) sql += ` HAVING ${this.having(having)}`
247
- if (!_empty(orderBy)) sql += ` ORDER BY ${this.orderBy(orderBy, localized)}`
245
+ if (!recurse && !_empty(where)) sql += ` WHERE ${this.where(where)}`
246
+ if (!recurse && !_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
247
+ if (!recurse && !_empty(having)) sql += ` HAVING ${this.having(having)}`
248
+ if (!recurse && !_empty(orderBy)) sql += ` ORDER BY ${this.orderBy(orderBy, localized)}`
248
249
  if (one) limit = Object.assign({}, limit, { rows: { val: 1 } })
249
250
  if (limit) sql += ` LIMIT ${this.limit(limit)}`
250
251
  if (forUpdate) sql += ` ${this.forUpdate(forUpdate)}`
@@ -257,6 +258,10 @@ class CQN2SQLRenderer {
257
258
  return (this.sql = sql)
258
259
  }
259
260
 
261
+ SELECT_recurse() {
262
+ cds.error`Feature "recurse" queries not supported.`
263
+ }
264
+
260
265
  /**
261
266
  * Renders a column clause into generic SQL
262
267
  * @param {import('./infer/cqn').SELECT} param0
@@ -421,14 +426,15 @@ class CQN2SQLRenderer {
421
426
  * @returns {string[] | string} SQL
422
427
  */
423
428
  orderBy(orderBy, localized) {
424
- return orderBy.map(
425
- localized
426
- ? c =>
427
- this.expr(c) +
429
+ return orderBy.map(c => {
430
+ const o = localized
431
+ ? this.expr(c) +
428
432
  (c.element?.[this.class._localized] ? ' COLLATE NOCASE' : '') +
429
433
  (c.sort?.toLowerCase() === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
430
- : c => this.expr(c) + (c.sort?.toLowerCase() === 'desc' || c.sort === -1 ? ' DESC' : ' ASC'),
431
- )
434
+ : this.expr(c) + (c.sort?.toLowerCase() === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
435
+ if (c.nulls) return o + ' NULLS ' + (c.nulls.toLowerCase() === 'first' ? 'FIRST' : 'LAST')
436
+ return o
437
+ })
432
438
  }
433
439
 
434
440
  /**
@@ -448,9 +454,10 @@ class CQN2SQLRenderer {
448
454
  * @returns {string} SQL
449
455
  */
450
456
  forUpdate(update) {
451
- const { wait, of } = update
457
+ const { wait, of, ignoreLocked } = update
452
458
  let sql = 'FOR UPDATE'
453
459
  if (!_empty(of)) sql += ` OF ${of.map(x => this.expr(x)).join(', ')}`
460
+ if (ignoreLocked) sql += ' IGNORE LOCKED'
454
461
  if (typeof wait === 'number') sql += ` WAIT ${wait}`
455
462
  return sql
456
463
  }
@@ -495,7 +502,7 @@ class CQN2SQLRenderer {
495
502
  */
496
503
  INSERT_entries(q) {
497
504
  const { INSERT } = q
498
- const elements = q.elements || q.target?.elements
505
+ const elements = q.elements || q._target?.elements
499
506
  if (!elements && !INSERT.entries?.length) {
500
507
  return // REVISIT: mtx sends an insert statement without entries and no reference entity
501
508
  }
@@ -507,7 +514,7 @@ class CQN2SQLRenderer {
507
514
  this.columns = columns
508
515
 
509
516
  const alias = INSERT.into.as
510
- const entity = this.name(q.target?.name || INSERT.into.ref[0], q)
517
+ const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
511
518
  if (!elements) {
512
519
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
513
520
  const param = this.param.bind(this, { ref: ['?'] })
@@ -533,7 +540,7 @@ class CQN2SQLRenderer {
533
540
  }
534
541
 
535
542
  async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
536
- const elements = this.cqn.target?.elements || {}
543
+ const elements = this.cqn._target?.elements || {}
537
544
  const bufferLimit = 65536 // 1 << 16
538
545
  let buffer = '['
539
546
 
@@ -582,7 +589,7 @@ class CQN2SQLRenderer {
582
589
  }
583
590
 
584
591
  async *INSERT_rows_stream(entries, binaryEncoding = 'base64') {
585
- const elements = this.cqn.target?.elements || {}
592
+ const elements = this.cqn._target?.elements || {}
586
593
  const bufferLimit = 65536 // 1 << 16
587
594
  let buffer = '['
588
595
 
@@ -635,9 +642,9 @@ class CQN2SQLRenderer {
635
642
  */
636
643
  INSERT_rows(q) {
637
644
  const { INSERT } = q
638
- const entity = this.name(q.target?.name || INSERT.into.ref[0], q)
645
+ const entity = this.name(q._target?.name || INSERT.into.ref[0], q)
639
646
  const alias = INSERT.into.as
640
- const elements = q.elements || q.target?.elements
647
+ const elements = q.elements || q._target?.elements
641
648
  const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`
642
649
 
643
650
  if (!elements) {
@@ -681,9 +688,9 @@ class CQN2SQLRenderer {
681
688
  */
682
689
  INSERT_select(q) {
683
690
  const { INSERT } = q
684
- const entity = this.name(q.target.name, q)
691
+ const entity = this.name(q._target.name, q)
685
692
  const alias = INSERT.into.as
686
- const elements = q.elements || q.target?.elements || {}
693
+ const elements = q.elements || q._target?.elements || {}
687
694
  const columns = (this.columns = (INSERT.columns || ObjectKeys(elements)).filter(
688
695
  c => c in elements && !elements[c].virtual && !elements[c].isAssociation,
689
696
  ))
@@ -724,15 +731,15 @@ class CQN2SQLRenderer {
724
731
  const { UPSERT } = q
725
732
 
726
733
  let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
727
- if (!q.target?.keys) return sql
734
+ if (!q._target?.keys) return sql
728
735
  const keys = []
729
- for (const k of ObjectKeys(q.target?.keys)) {
730
- const element = q.target.keys[k]
736
+ for (const k of ObjectKeys(q._target?.keys)) {
737
+ const element = q._target.keys[k]
731
738
  if (element.isAssociation || element.virtual) continue
732
739
  keys.push(k)
733
740
  }
734
741
 
735
- const elements = q.target?.elements || {}
742
+ const elements = q._target?.elements || {}
736
743
  // temporal data
737
744
  for (const k of ObjectKeys(elements)) {
738
745
  if (elements[k]['@cds.valid.from']) keys.push(k)
@@ -749,7 +756,7 @@ class CQN2SQLRenderer {
749
756
  .filter(c => keys.includes(c.name))
750
757
  .map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)
751
758
 
752
- const entity = this.name(q.target?.name || UPSERT.into.ref[0], q)
759
+ const entity = this.name(q._target?.name || UPSERT.into.ref[0], q)
753
760
  sql = `SELECT ${managed.map(c => c.upsert
754
761
  .replace(/value->/g, '"$$$$value$$$$"->')
755
762
  .replace(/json_type\(value,/g, 'json_type("$$$$value$$$$",'))
@@ -778,7 +785,7 @@ class CQN2SQLRenderer {
778
785
  */
779
786
  UPDATE(q) {
780
787
  const { entity, with: _with, data, where } = q.UPDATE
781
- const elements = q.target?.elements
788
+ const elements = q._target?.elements
782
789
  let sql = `UPDATE ${this.quote(this.name(entity.ref?.[0] || entity, q))}`
783
790
  if (entity.as) sql += ` AS ${this.quote(entity.as)}`
784
791
 
package/lib/cqn4sql.js CHANGED
@@ -1,10 +1,11 @@
1
1
  'use strict'
2
2
 
3
3
  const cds = require('@sap/cds')
4
+ cds.infer.target ??= q => q._target || q.target // instanceof cds.entity ? q._target : q.target
4
5
 
5
6
  const infer = require('./infer')
6
7
  const { computeColumnsToBeSearched } = require('./search')
7
- const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement } = require('./utils')
8
+ const { prettyPrintRef, isCalculatedOnRead, isCalculatedElement, getImplicitAlias } = require('./utils')
8
9
 
9
10
  /**
10
11
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -62,16 +63,16 @@ function cqn4sql(originalQuery, model) {
62
63
  }
63
64
  // query modifiers can also be defined in from ref leaf infix filter
64
65
  // > SELECT from bookshop.Books[order by price] {ID}
65
- if(inferred.SELECT?.from.ref) {
66
- for(const [key, val] of Object.entries(inferred.SELECT.from.ref.at(-1))) {
67
- if(key in { orderBy: 1, groupBy: 1 }) {
68
- if(inferred.SELECT[key]) inferred.SELECT[key].push(...val)
66
+ if (inferred.SELECT?.from.ref) {
67
+ for (const [key, val] of Object.entries(inferred.SELECT.from.ref.at(-1))) {
68
+ if (key in { orderBy: 1, groupBy: 1 }) {
69
+ if (inferred.SELECT[key]) inferred.SELECT[key].push(...val)
69
70
  else inferred.SELECT[key] = val
70
- } else if(key === 'limit') {
71
+ } else if (key === 'limit') {
71
72
  // limit defined on the query has precedence
72
- if(!inferred.SELECT.limit) inferred.SELECT.limit = val
73
- } else if(key === 'having') {
74
- if(!inferred.SELECT.having) inferred.SELECT.having = val
73
+ if (!inferred.SELECT.limit) inferred.SELECT.limit = val
74
+ } else if (key === 'having') {
75
+ if (!inferred.SELECT.having) inferred.SELECT.having = val
75
76
  else inferred.SELECT.having.push('and', ...val)
76
77
  }
77
78
  }
@@ -224,7 +225,8 @@ function cqn4sql(originalQuery, model) {
224
225
  */
225
226
  function transformQueryForInsertUpsert(kind) {
226
227
  const { as } = transformedQuery[kind].into
227
- transformedQuery[kind].into = { ref: [inferred.target.name] }
228
+ const target = cds.infer.target (inferred) // REVISIT: we should reliably use inferred._target instead
229
+ transformedQuery[kind].into = { ref: [target.name] }
228
230
  if (as) transformedQuery[kind].into.as = as
229
231
  return transformedQuery
230
232
  }
@@ -800,7 +802,7 @@ function cqn4sql(originalQuery, model) {
800
802
  } else {
801
803
  outerAlias = transformedQuery.SELECT.from.as
802
804
  subqueryFromRef = [
803
- ...(transformedQuery.SELECT.from.ref || /* subq in from */ [transformedQuery.SELECT.from.target.name]),
805
+ ...(transformedQuery.SELECT.from.ref || /* subq in from */ transformedQuery.SELECT.from.SELECT.from.ref),
804
806
  ...ref,
805
807
  ]
806
808
  }
@@ -816,16 +818,18 @@ function cqn4sql(originalQuery, model) {
816
818
  return _subqueryForGroupBy(column, baseRef, columnAlias)
817
819
  }
818
820
 
819
- // we need to respect the aliases of the outer query, so the columnAlias might not be suitable
820
- // as table alias for the correlated subquery
821
- const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)
821
+ // Alias in expand subquery is derived from but not equal to
822
+ // the alias of the column because to account for potential ambiguities
823
+ // the alias cannot be addressed anyways
824
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(getImplicitAlias(columnAlias), inferred.outerQueries)
822
825
 
823
826
  // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
824
827
  const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
825
828
  const subqueryBase = {}
826
829
  const queryModifiers = { ...column }
827
830
  for (const [key, value] of Object.entries(queryModifiers)) {
828
- if (key in { limit: 1, orderBy: 1, groupBy: 1, excluding: 1, where: 1, having: 1, count: 1 }) subqueryBase[key] = value
831
+ if (key in { limit: 1, orderBy: 1, groupBy: 1, excluding: 1, where: 1, having: 1, count: 1 })
832
+ subqueryBase[key] = value
829
833
  }
830
834
 
831
835
  const subquery = {
@@ -869,6 +873,7 @@ function cqn4sql(originalQuery, model) {
869
873
  const existsSubqueryAlias = recent[existsIndex + 1].SELECT.from.as
870
874
  if (existsSubqueryAlias === x.ref?.[0]) return { ref: [outer, ...x.ref.slice(1)] }
871
875
  if (x.xpr) x.xpr = x.xpr.map(replaceAliasWithSubqueryAlias)
876
+ if (x.args) x.args = x.args.map(replaceAliasWithSubqueryAlias)
872
877
  return x
873
878
  }
874
879
  return subq
@@ -898,37 +903,39 @@ function cqn4sql(originalQuery, model) {
898
903
  // expand with wildcard vanishes as expand is part of the group by (OData $apply + $expand)
899
904
  return null
900
905
  }
901
- const expandedColumns = column.expand.flatMap(expand => {
902
- if (!expand.ref) return expand
903
- const fullRef = [...baseRef, ...expand.ref]
904
-
905
- if (expand.expand) {
906
- const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
907
- if(nested) {
908
- setElementOnColumns(nested, expand.element)
909
- elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
906
+ const expandedColumns = column.expand
907
+ .flatMap(expand => {
908
+ if (!expand.ref) return expand
909
+ const fullRef = [...baseRef, ...expand.ref]
910
+
911
+ if (expand.expand) {
912
+ const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
913
+ if (nested) {
914
+ setElementOnColumns(nested, expand.element)
915
+ elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
916
+ }
917
+ return nested
910
918
  }
911
- return nested
912
- }
913
919
 
914
- const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
915
- if (!groupByRef) {
916
- throw new Error(
917
- `The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
918
- )
919
- }
920
+ const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
921
+ if (!groupByRef) {
922
+ throw new Error(
923
+ `The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
924
+ )
925
+ }
920
926
 
921
- const copy = Object.create(groupByRef)
922
- // always alias for this special case, so that they nested element names match the expected result structure
923
- // otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
924
- copy.as = expand.as || expand.ref.at(-1)
925
- const tableAlias = getTableAlias(copy)
926
- const res = getFlatColumnsFor(copy, { tableAlias })
927
- res.forEach(c => {
928
- elements[c.as || c.ref.at(-1)] = c.element
927
+ const copy = Object.create(groupByRef)
928
+ // always alias for this special case, so that they nested element names match the expected result structure
929
+ // otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
930
+ copy.as = expand.as || expand.ref.at(-1)
931
+ const tableAlias = getTableAlias(copy)
932
+ const res = getFlatColumnsFor(copy, { tableAlias })
933
+ res.forEach(c => {
934
+ elements[c.as || c.ref.at(-1)] = c.element
935
+ })
936
+ return res
929
937
  })
930
- return res
931
- }).filter(c => c)
938
+ .filter(c => c)
932
939
 
933
940
  if (expandedColumns.length === 0) {
934
941
  return null
@@ -1063,7 +1070,8 @@ function cqn4sql(originalQuery, model) {
1063
1070
  outerQueries.push(inferred)
1064
1071
  Object.defineProperty(q, 'outerQueries', { value: outerQueries })
1065
1072
  }
1066
- if (isLocalized(inferred.target)) q.SELECT.localized = true
1073
+ const target = cds.infer.target (inferred) // REVISIT: we should reliably use inferred._target instead
1074
+ if (isLocalized(target)) q.SELECT.localized = true
1067
1075
  if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
1068
1076
  return cqn4sql(q, model)
1069
1077
 
@@ -1071,7 +1079,7 @@ function cqn4sql(originalQuery, model) {
1071
1079
  if (q.SELECT.from.uniqueSubqueryAlias) return
1072
1080
  const last = q.SELECT.from.ref.at(-1)
1073
1081
  const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
1074
- getLastStringSegment(last.id || last),
1082
+ getImplicitAlias(last.id || last),
1075
1083
  inferred.outerQueries,
1076
1084
  )
1077
1085
  Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
@@ -1400,7 +1408,7 @@ function cqn4sql(originalQuery, model) {
1400
1408
  j = nextAssocIndex
1401
1409
  }
1402
1410
 
1403
- const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
1411
+ const as = getNextAvailableTableAlias(getImplicitAlias(next.alias))
1404
1412
  next.alias = as
1405
1413
  if (!next.definition.target) {
1406
1414
  let type = next.definition.type
@@ -1696,12 +1704,12 @@ function cqn4sql(originalQuery, model) {
1696
1704
  function _transformFrom() {
1697
1705
  if (typeof from === 'string') {
1698
1706
  // normalize to `ref`, i.e. for `UPDATE.entity('bookshop.Books')`
1699
- return { transformedFrom: { ref: [from], as: getLastStringSegment(from) } }
1707
+ return { transformedFrom: { ref: [from], as: getImplicitAlias(from) } }
1700
1708
  }
1701
1709
  transformedFrom.as =
1702
1710
  from.uniqueSubqueryAlias ||
1703
1711
  from.as ||
1704
- getLastStringSegment(transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1].definition.name)
1712
+ getImplicitAlias(transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1].definition.name)
1705
1713
  const whereExistsSubSelects = []
1706
1714
  const filterConditions = []
1707
1715
  const refReverse = [...from.ref].reverse()
@@ -1723,14 +1731,17 @@ function cqn4sql(originalQuery, model) {
1723
1731
  .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1724
1732
  next = $refLinksReverse[nextStepIndex]
1725
1733
  }
1726
- let as = getLastStringSegment(next.alias)
1734
+ let as = getImplicitAlias(next.alias)
1727
1735
  /**
1728
1736
  * for an `expand` subquery, we do not need to add
1729
1737
  * the table alias of the `expand` host to the join tree
1730
1738
  * --> This is an artificial query, which will later be correlated
1731
1739
  * with the main query alias. see @function expandColumn()
1740
+ * There is one exception:
1741
+ * - if current and next have the same alias, we need to assign a new alias to the next
1742
+ *
1732
1743
  */
1733
- if (!(inferred.SELECT?.expand === true)) {
1744
+ if (!(inferred.SELECT?.expand === true && current.alias.toLowerCase() !== as.toLowerCase())) {
1734
1745
  as = getNextAvailableTableAlias(as)
1735
1746
  }
1736
1747
  next.alias = as
@@ -1917,9 +1928,11 @@ function cqn4sql(originalQuery, model) {
1917
1928
  })
1918
1929
  let pseudoPath = false
1919
1930
  ref.reduce((prev, res, i) => {
1920
- if (res === '$self')
1931
+ if (res === '$self') {
1921
1932
  // next is resolvable in entity
1933
+ thing.$refLinks.push({ definition: prev, target: prev })
1922
1934
  return prev
1935
+ }
1923
1936
  if (res in pseudos.elements) {
1924
1937
  pseudoPath = true
1925
1938
  thing.$refLinks.push({ definition: pseudos.elements[res], target: pseudos })
@@ -1931,7 +1944,11 @@ function cqn4sql(originalQuery, model) {
1931
1944
  const definition =
1932
1945
  prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
1933
1946
  const target = getParentEntity(definition)
1934
- thing.$refLinks[i] = { definition, target, alias: definition.name }
1947
+ thing.$refLinks[i] = {
1948
+ definition,
1949
+ target,
1950
+ alias: definition === assocRefLink.definition ? assocRefLink.alias : definition.name,
1951
+ }
1935
1952
  return prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
1936
1953
  }, assocHost)
1937
1954
  }
@@ -1956,15 +1973,16 @@ function cqn4sql(originalQuery, model) {
1956
1973
  lhs.ref[0] in { $self: true, $projection: true } ? getParentEntity(assocRefLink.definition) : target,
1957
1974
  )
1958
1975
  else {
1959
- const lhsLeafArt = lhs.ref && lhs.$refLinks.at(-1).definition
1960
- const rhsLeafArt = rhs.ref && rhs.$refLinks.at(-1).definition
1976
+ const lhsLeafDef = lhs.ref && lhs.$refLinks.at(-1).definition
1977
+ const rhsLeafDef = rhs.ref && rhs.$refLinks.at(-1).definition
1978
+ const lhsFirstDef = lhs.$refLinks.find(r => r.definition.kind === 'element')?.definition
1961
1979
  // compare structures in on-condition
1962
- if ((lhsLeafArt?.target && rhsLeafArt?.target) || (lhsLeafArt?.elements && rhsLeafArt?.elements)) {
1980
+ if ((lhsLeafDef?.target && rhsLeafDef?.target) || (lhsLeafDef?.elements && rhsLeafDef?.elements)) {
1963
1981
  if (rhs.$refLinks[0].definition !== assocRefLink.definition) {
1964
1982
  rhs.ref.unshift(targetSideRefLink.alias)
1965
1983
  rhs.$refLinks.unshift(targetSideRefLink)
1966
1984
  }
1967
- if (lhs.$refLinks[0].definition !== assocRefLink.definition) {
1985
+ if (lhsFirstDef !== assocRefLink.definition) {
1968
1986
  lhs.ref.unshift(targetSideRefLink.alias)
1969
1987
  lhs.$refLinks.unshift(targetSideRefLink)
1970
1988
  }
@@ -1974,16 +1992,15 @@ function cqn4sql(originalQuery, model) {
1974
1992
  i += res.length
1975
1993
  continue
1976
1994
  }
1977
- // naive assumption: if first step is the association itself, all following ref steps must be resolvable
1995
+ // assumption: if first step is the association itself, all following ref steps must be resolvable
1978
1996
  // within target `assoc.assoc.fk` -> `assoc.assoc_fk`
1979
1997
  else if (
1980
- lhs.$refLinks[0]?.definition ===
1981
- getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name]
1998
+ lhsFirstDef === getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name]
1982
1999
  )
1983
- result[i].ref = [assocRefLink.alias, lhs.ref.slice(1).join('_')]
2000
+ result[i].ref = [assocRefLink.alias, lhs.ref.slice(lhs.ref[0] === '$self' ? 2 : 1).join('_')]
1984
2001
  // naive assumption: if the path starts with an association which is not the association from
1985
2002
  // which the on-condition originates, it must be a foreign key and hence resolvable in the source
1986
- else if (lhs.$refLinks[0]?.definition.target) result[i].ref = [result[i].ref.join('_')]
2003
+ else if (lhsFirstDef?.target) result[i].ref = [result[i].ref.join('_')]
1987
2004
  }
1988
2005
  }
1989
2006
  if (backlink) {
@@ -2006,7 +2023,7 @@ function cqn4sql(originalQuery, model) {
2006
2023
  // sanity check: error out if we can't produce a join
2007
2024
  if (backlink.keys.length === 0) {
2008
2025
  throw new Error(
2009
- `Path step “${assocRefLink.alias}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
2026
+ `Path step “${assocRefLink.definition.name}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
2010
2027
  )
2011
2028
  }
2012
2029
  // managed backlink -> calculate fk-pk pairs
@@ -2032,7 +2049,7 @@ function cqn4sql(originalQuery, model) {
2032
2049
  if (lhs.ref[0] !== assocRefLink.alias && lhs.ref[0] !== targetSideRefLink.alias) {
2033
2050
  // we need to find correct table alias for the structured access
2034
2051
  const { definition } = lhs.$refLinks[0]
2035
- if (definition === assocRefLink.definition) {
2052
+ if (definition === getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name]) {
2036
2053
  // first step is the association itself -> use it's name as it becomes the table alias
2037
2054
  result[i].ref.splice(0, 1, assocRefLink.alias)
2038
2055
  } else if (
@@ -2050,6 +2067,7 @@ function cqn4sql(originalQuery, model) {
2050
2067
  }
2051
2068
  /**
2052
2069
  * Recursively calculates the containing entity for a given element.
2070
+ * If the query is localized, this function may return the localized entity.
2053
2071
  *
2054
2072
  * @param {CSN.element} element
2055
2073
  * @returns {CSN.definition} the entity containing the given element
@@ -2243,7 +2261,7 @@ function cqn4sql(originalQuery, model) {
2243
2261
  * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2244
2262
  */
2245
2263
  function getSearchTerm(search, query) {
2246
- const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
2264
+ const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
2247
2265
  const searchIn = computeColumnsToBeSearched(inferred, entity)
2248
2266
  if (searchIn.length > 0) {
2249
2267
  const xpr = search
@@ -2304,7 +2322,7 @@ function cqn4sql(originalQuery, model) {
2304
2322
  if (
2305
2323
  inferred.SELECT?.from.uniqueSubqueryAlias &&
2306
2324
  !inferred.SELECT?.from.as &&
2307
- firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
2325
+ getImplicitAlias(firstStep) === getImplicitAlias(transformedQuery.SELECT.from.ref[0])
2308
2326
  ) {
2309
2327
  return inferred.SELECT?.from.uniqueSubqueryAlias
2310
2328
  }
@@ -2313,7 +2331,7 @@ function cqn4sql(originalQuery, model) {
2313
2331
  }
2314
2332
 
2315
2333
  function getCombinedElementAlias(node) {
2316
- return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index)
2334
+ return inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index
2317
2335
  }
2318
2336
  }
2319
2337
  function getTransformedFunctionArgs(args, $baseLink = null) {
@@ -2386,17 +2404,6 @@ function hasLogicalOr(tokenStream) {
2386
2404
  return tokenStream.some(t => t in { OR: true, or: true })
2387
2405
  }
2388
2406
 
2389
- /**
2390
- * Returns the last segment of a string after the last dot.
2391
- *
2392
- * @param {string} str - The input string.
2393
- * @returns {string} The last segment of the string after the last dot. If there is no dot in the string, the function returns the original string.
2394
- */
2395
- function getLastStringSegment(str) {
2396
- const index = str.lastIndexOf('.')
2397
- return index != -1 ? str.substring(index + 1) : str
2398
- }
2399
-
2400
2407
  function getParentEntity(element) {
2401
2408
  if (element.kind === 'entity') return element
2402
2409
  else return getParentEntity(element.parent)
@@ -39,9 +39,9 @@ async function onDeep(req, next) {
39
39
  // const target = query.sources[Object.keys(query.sources)[0]]
40
40
  if (!this.model?.definitions[_target_name4(req.query)]) return next()
41
41
 
42
- const { target } = this.infer(query)
43
- if (!hasDeep(query, target)) return next()
42
+ if (!hasDeep(query)) return next()
44
43
 
44
+ const target = this.infer(query)._target
45
45
  const beforeData = query.INSERT ? [] : await this.run(getExpandForDeep(query, target, true))
46
46
  if (query.UPDATE && !beforeData.length) return 0
47
47
 
@@ -64,10 +64,10 @@ async function onDeep(req, next) {
64
64
  return rootResult ?? beforeData.length
65
65
  }
66
66
 
67
- const hasDeep = (q, target) => {
67
+ const hasDeep = (q) => {
68
68
  const data = q.INSERT?.entries || (q.UPDATE?.data && [q.UPDATE.data]) || (q.UPDATE?.with && [q.UPDATE.with])
69
69
  if (data)
70
- for (const c in target.compositions) {
70
+ for (const c in q._target.compositions) {
71
71
  for (const row of data) if (row[c] !== undefined) return true
72
72
  }
73
73
  }
@@ -59,7 +59,7 @@ module.exports = async function fill_in_keys(req, next) {
59
59
  // REVISIT dummy handler until we have input processing
60
60
  if (!req.target || !this.model || req.target._unresolved) return next()
61
61
  // only for deep update
62
- if (req.event === 'UPDATE' && hasDeep(req.query, req.target)) {
62
+ if (req.event === 'UPDATE' && hasDeep(req.query)) {
63
63
  // REVISIT for deep update we need to inject the keys first
64
64
  enrichDataWithKeysFromWhere(req.data, req, this)
65
65
  }
@@ -4,7 +4,7 @@ const cds = require('@sap/cds')
4
4
 
5
5
  const JoinTree = require('./join-tree')
6
6
  const { pseudos } = require('./pseudos')
7
- const { isCalculatedOnRead } = require('../utils')
7
+ const { isCalculatedOnRead, getImplicitAlias } = require('../utils')
8
8
  const cdsTypes = cds.linked({
9
9
  definitions: {
10
10
  Timestamp: { type: 'cds.Timestamp' },
@@ -47,21 +47,16 @@ function infer(originalQuery, model) {
47
47
  const sources = inferTarget(_.from || _.into || _.entity, {})
48
48
  const joinTree = new JoinTree(sources)
49
49
  const aliases = Object.keys(sources)
50
+ const target = aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery
50
51
  Object.defineProperties(inferred, {
51
52
  // REVISIT: public, or for local reuse, or in cqn4sql only?
52
53
  sources: { value: sources, writable: true },
53
- target: {
54
- value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery,
55
- writable: true,
56
- }, // REVISIT: legacy?
54
+ _target: { value: target, writable: true, configurable: true }, // REVISIT: legacy?
57
55
  })
58
56
  // also enrich original query -> writable because it may be inferred again
59
57
  Object.defineProperties(originalQuery, {
60
58
  sources: { value: sources, writable: true },
61
- target: {
62
- value: aliases.length === 1 ? getDefinitionFromSources(sources, aliases[0]) : originalQuery,
63
- writable: true,
64
- },
59
+ _target: { value: target, writable: true, configurable: true },
65
60
  })
66
61
  if (originalQuery.SELECT || originalQuery.DELETE || originalQuery.UPDATE) {
67
62
  $combinedElements = inferCombinedElements()
@@ -97,7 +92,7 @@ function infer(originalQuery, model) {
97
92
  * Each key is a query source alias, and its value is the corresponding CSN Definition.
98
93
  * @returns {object} The updated `querySources` object with inferred sources from the `from` clause.
99
94
  */
100
- function inferTarget(from, querySources) {
95
+ function inferTarget(from, querySources, useTechnicalAlias = true) {
101
96
  const { ref } = from
102
97
  if (ref) {
103
98
  const { id, args } = ref[0]
@@ -119,14 +114,14 @@ function infer(originalQuery, model) {
119
114
  from.uniqueSubqueryAlias ||
120
115
  from.as ||
121
116
  (ref.length === 1
122
- ? first.substring(first.lastIndexOf('.') + 1)
123
- : (ref.at(-1).id || ref.at(-1)));
117
+ ? getImplicitAlias(first, useTechnicalAlias)
118
+ : getImplicitAlias(ref.at(-1).id || ref.at(-1), useTechnicalAlias));
124
119
  if (alias in querySources) throw new Error(`Duplicate alias "${alias}"`)
125
120
  querySources[alias] = { definition: target, args }
126
121
  const last = from.$refLinks.at(-1)
127
122
  last.alias = alias
128
123
  } else if (from.args) {
129
- from.args.forEach(a => inferTarget(a, querySources))
124
+ from.args.forEach(a => inferTarget(a, querySources, false))
130
125
  } else if (from.SELECT) {
131
126
  const subqueryInFrom = infer(from, model) // we need the .elements in the sources
132
127
  // if no explicit alias is provided, we make up one
@@ -136,7 +131,7 @@ function infer(originalQuery, model) {
136
131
  } else if (typeof from === 'string') {
137
132
  // TODO: Create unique alias, what about duplicates?
138
133
  const definition = getDefinition(from) || cds.error`"${from}" not found in the definitions of your model`
139
- querySources[from.substring(from.lastIndexOf('.') + 1)] = { definition }
134
+ querySources[getImplicitAlias(from, useTechnicalAlias)] = { definition }
140
135
  } else if (from.SET) {
141
136
  infer(from, model)
142
137
  }
package/lib/utils.js CHANGED
@@ -38,9 +38,34 @@ function isCalculatedElement(def) {
38
38
  return def?.value
39
39
  }
40
40
 
41
+ /**
42
+ * Calculates the implicit table alias for a given string.
43
+ *
44
+ * Based on the last part of the string, the implicit alias is calculated
45
+ * by taking the first character and prepending it with '$'.
46
+ * A leading '$' is removed if the last part already starts with '$'.
47
+ *
48
+ * @example
49
+ * getImplicitAlias('Books') => '$B'
50
+ * getImplicitAlias('bookshop.Books') => '$B'
51
+ * getImplicitAlias('bookshop.$B') => '$B'
52
+ *
53
+ * @param {string} str - The input string.
54
+ * @returns {string}
55
+ */
56
+ function getImplicitAlias(str, useTechnicalAlias = true) {
57
+ const index = str.lastIndexOf('.')
58
+ if(useTechnicalAlias) {
59
+ const postfix = (index != -1 ? str.substring(index + 1) : str).replace(/^\$/, '')[0] || /* str === '$' */ '$'
60
+ return '$' + postfix
61
+ }
62
+ return index != -1 ? str.substring(index + 1) : str
63
+ }
64
+
41
65
  // export the function to be used in other modules
42
66
  module.exports = {
43
67
  prettyPrintRef,
44
68
  isCalculatedOnRead,
45
- isCalculatedElement
69
+ isCalculatedElement,
70
+ getImplicitAlias,
46
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "CDS base database service",
5
5
  "homepage": "https://github.com/cap-js/cds-dbs/tree/main/db-service#cds-base-database-service",
6
6
  "repository": {
@@ -16,7 +16,6 @@
16
16
  ],
17
17
  "author": "SAP SE (https://www.sap.com)",
18
18
  "main": "index.js",
19
- "types": "./dist/index.d.ts",
20
19
  "files": [
21
20
  "lib",
22
21
  "CHANGELOG.md"