@cap-js/db-service 1.3.1 → 1.4.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
@@ -1,10 +1,35 @@
1
- # Change Log
1
+ # Changelog
2
2
 
3
3
  - All notable changes to this project are documented in this file.
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 1.3.1 - 2023-10-10
7
+ ## [1.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.2...db-service-v1.4.0) (2023-11-20)
8
+
9
+
10
+ ### Added
11
+
12
+ * **`UPDATE`/`DELETE`:** Enable path expressions for improved data manipulation ([#325](https://github.com/cap-js/cds-dbs/issues/325)) ([94f0776](https://github.com/cap-js/cds-dbs/commit/94f077661cffad8f137dc692a2cb9b0ae5e4d75b))
13
+ * **temporal data:** add time slice key to conflict clause ([#249](https://github.com/cap-js/cds-dbs/issues/249)) ([67b8edf](https://github.com/cap-js/cds-dbs/commit/67b8edf9b7f6b0fbab0010d7c93ed03a01e103ed))
14
+ * use place holders for update and delete ([#323](https://github.com/cap-js/cds-dbs/issues/323)) ([81472b9](https://github.com/cap-js/cds-dbs/commit/81472b971183f701e401247611310be56745a87a))
15
+
16
+
17
+ ### Fixed
18
+
19
+ * align time function behavior ([#322](https://github.com/cap-js/cds-dbs/issues/322)) ([c3ab40a](https://github.com/cap-js/cds-dbs/commit/c3ab40a007c105465349dd2f612178367b8e713a))
20
+ * **calculated elements:** path expressions in `func.args` within `xpr` ([#321](https://github.com/cap-js/cds-dbs/issues/321)) ([cee25e3](https://github.com/cap-js/cds-dbs/commit/cee25e33cf289592a87779cfa34dddc53e467676))
21
+ * Disconnect db service on shutdown ([#327](https://github.com/cap-js/cds-dbs/issues/327)) ([8471bda](https://github.com/cap-js/cds-dbs/commit/8471bda44fc030205abec45b1581b2cf6ed7c800))
22
+ * non-fk access in filter conditions are properly rejected ([#336](https://github.com/cap-js/cds-dbs/issues/336)) ([4c948fe](https://github.com/cap-js/cds-dbs/commit/4c948fecead1de562e1583886516413e131a39aa))
23
+ * **search:** check calculated columns at any depth ([#310](https://github.com/cap-js/cds-dbs/issues/310)) ([8fd6153](https://github.com/cap-js/cds-dbs/commit/8fd6153dfcd472a6d95c33faa58c4b3f96f485df))
24
+
25
+ ## [1.3.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.1...db-service-v1.3.2) (2023-10-13)
26
+
27
+
28
+ ### Fixed
29
+
30
+ - preserve $count for result of SELECT queries ([#280](https://github.com/cap-js/cds-dbs/issues/280)) ([23bef24](https://github.com/cap-js/cds-dbs/commit/23bef245e62952a57ed82afcfd238c0b294b2e9e))
31
+
32
+ ## [1.3.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.3.0...db-service-v1.3.1) (2023-10-10)
8
33
 
9
34
  ### Fixed
10
35
 
package/README.md CHANGED
@@ -12,6 +12,11 @@ This project is open to feature requests/suggestions, bug reports etc. via [GitH
12
12
 
13
13
  Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
14
14
 
15
+ ## Versioning
16
+
17
+ This library follows [Semantic Versioning](https://semver.org/).
18
+ All notable changes are documented in [CHANGELOG.md](CHANGELOG.md).
19
+
15
20
  ## Code of Conduct
16
21
 
17
22
  We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
package/lib/SQLService.js CHANGED
@@ -18,7 +18,7 @@ class SQLService extends DatabaseService {
18
18
  init() {
19
19
  this.on(['SELECT'], this.transformStreamFromCQN)
20
20
  this.on(['UPDATE'], this.transformStreamIntoCQN)
21
- this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT should be replaced by correct input processing eventually
21
+ this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT: should be replaced by correct input processing eventually
22
22
  this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep)
23
23
  this.on(['SELECT'], this.onSELECT)
24
24
  this.on(['INSERT'], this.onINSERT)
@@ -79,7 +79,10 @@ class SQLService extends DatabaseService {
79
79
  let rows = await ps.all(values)
80
80
  if (rows.length)
81
81
  if (cqn.SELECT.expand) rows = rows.map(r => (typeof r._json_ === 'string' ? JSON.parse(r._json_) : r._json_ || r))
82
- if (cqn.SELECT.count) rows.$count = await this.count(query, rows)
82
+ if (cqn.SELECT.count) {
83
+ // REVISIT: the runtime always expects that the count is preserved with .map, required for renaming in mocks
84
+ return SQLService._arrayWithCount(rows, await this.count(query, rows))
85
+ }
83
86
  return cqn.SELECT.one || query.SELECT.from?.ref?.[0].cardinality?.max === 1 ? rows[0] : rows
84
87
  }
85
88
 
@@ -252,6 +255,17 @@ class SQLService extends DatabaseService {
252
255
  */
253
256
  static CQN2SQL = require('./cqn2sql').class
254
257
 
258
+ // REVISIT: There must be a better way!
259
+ // preserves $count for .map calls on array
260
+ static _arrayWithCount = function (a, count) {
261
+ const _map = a.map
262
+ const map = function (..._) { return SQLService._arrayWithCount(_map.call(a, ..._), count) }
263
+ return Object.defineProperties(a, {
264
+ $count: { value: count, enumerable: false, configurable: true, writable: true },
265
+ map: { value: map, enumerable: false, configurable: true, writable: true }
266
+ })
267
+ }
268
+
255
269
  /** @param {unknown[]} args */
256
270
  constructor(...args) {
257
271
  super(...args)
@@ -387,6 +401,7 @@ const _unquirked = q => {
387
401
  return q
388
402
  }
389
403
 
404
+
390
405
  const sqls = new class extends SQLService { get factory() { return null } }
391
406
  cds.extend(cds.ql.Query).with(
392
407
  class {
@@ -6,10 +6,16 @@ const cds = require('@sap/cds/lib')
6
6
  /** @typedef {unknown} DatabaseDriver */
7
7
 
8
8
  class DatabaseService extends cds.Service {
9
+
10
+ init() {
11
+ cds.on('shutdown', () => this.disconnect())
12
+ return super.init()
13
+ }
14
+
9
15
  /**
10
16
  * Dictionary of connection pools per tenant
11
17
  */
12
- pools = { _factory: this.factory }
18
+ pools = Object.setPrototypeOf({}, { _factory: this.factory })
13
19
 
14
20
  /**
15
21
  * Return a pool factory + options property as expected by
@@ -44,7 +50,9 @@ class DatabaseService extends cds.Service {
44
50
  async begin() {
45
51
  // We expect tx.begin() being called for an txed db service
46
52
  const ctx = this.context
47
- if (!ctx) return this.tx().begin() // REVISIT: Is this correct? When does this happen?
53
+
54
+ // If .begin is called explicitly it starts a new transaction and executes begin
55
+ if (!ctx) return this.tx().begin()
48
56
 
49
57
  // REVISIT: tenant should be undefined if !this.isMultitenant
50
58
  let isMultitenant = 'multiTenant' in this.options ? this.options.multiTenant : cds.env.requires.multitenancy
@@ -111,11 +119,18 @@ class DatabaseService extends cds.Service {
111
119
  * @param {string} tenant
112
120
  */
113
121
  async disconnect(tenant) {
114
- const pool = this.pools[tenant]
115
- if (!pool) return
116
- await pool.drain()
117
- await pool.clear()
118
- delete this.pools[tenant]
122
+ const _disconnect = async tenant => {
123
+ const pool = this.pools[tenant]
124
+ if (!pool) {
125
+ return
126
+ }
127
+ await pool.drain()
128
+ await pool.clear()
129
+ delete this.pools[tenant]
130
+ }
131
+ if (tenant == null)
132
+ return Promise.all(Object.keys(this.pools).map(_disconnect))
133
+ return _disconnect(tenant)
119
134
  }
120
135
 
121
136
  /**
@@ -149,37 +149,37 @@ const StandardFunctions = {
149
149
  * Generates SQL statement that produces the year of a given timestamp
150
150
  * @param {string} x
151
151
  * @returns {string}
152
- */
152
+ * /
153
153
  year: x => `cast( strftime('%Y',${x}) as Integer )`,
154
154
  /**
155
155
  * Generates SQL statement that produces the month of a given timestamp
156
156
  * @param {string} x
157
157
  * @returns {string}
158
- */
158
+ * /
159
159
  month: x => `cast( strftime('%m',${x}) as Integer )`,
160
160
  /**
161
161
  * Generates SQL statement that produces the day of a given timestamp
162
162
  * @param {string} x
163
163
  * @returns {string}
164
- */
164
+ * /
165
165
  day: x => `cast( strftime('%d',${x}) as Integer )`,
166
166
  /**
167
167
  * Generates SQL statement that produces the hours of a given timestamp
168
168
  * @param {string} x
169
169
  * @returns {string}
170
- */
170
+ * /
171
171
  hour: x => `cast( strftime('%H',${x}) as Integer )`,
172
172
  /**
173
173
  * Generates SQL statement that produces the minutes of a given timestamp
174
174
  * @param {string} x
175
175
  * @returns {string}
176
- */
176
+ * /
177
177
  minute: x => `cast( strftime('%M',${x}) as Integer )`,
178
178
  /**
179
179
  * Generates SQL statement that produces the seconds of a given timestamp
180
180
  * @param {string} x
181
181
  * @returns {string}
182
- */
182
+ * /
183
183
  second: x => `cast( strftime('%S',${x}) as Integer )`,
184
184
 
185
185
  /**
package/lib/cqn2sql.js CHANGED
@@ -70,7 +70,7 @@ class CQN2SQLRenderer {
70
70
  /** @type {unknown[]} */
71
71
  this.values = [] // prepare values, filled in by subroutines
72
72
  this[cmd]((this.cqn = q)) // actual sql rendering happens here
73
- if (vars?.length && !this.values.length) this.values = vars
73
+ if (vars?.length && !this.values?.length) this.values = vars
74
74
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
75
75
  DEBUG?.(
76
76
  this.sql,
@@ -527,6 +527,9 @@ class CQN2SQLRenderer {
527
527
  .filter(c => !keys.includes(c))
528
528
  .map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)
529
529
 
530
+ // temporal data
531
+ keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))
532
+
530
533
  keys = keys.map(k => this.quote(k))
531
534
  const conflict = updateColumns.length
532
535
  ? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
@@ -872,7 +875,6 @@ class CQN2SQLRenderer {
872
875
 
873
876
  let val = _managed[element[annotation]?.['=']]
874
877
  if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
875
-
876
878
  else if (!isUpdate && element.default) {
877
879
  const d = element.default
878
880
  if (d.val !== undefined || d.ref?.[0] === '$now') {
package/lib/cqn4sql.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const cds = require('@sap/cds/lib')
4
- const { computeColumnsToBeSearched } = require('@sap/cds/libx/_runtime/cds-services/services/utils/columns.js')
4
+ const { computeColumnsToBeSearched } = require('./search')
5
5
 
6
6
  const infer = require('./infer')
7
7
 
@@ -12,7 +12,7 @@ const infer = require('./infer')
12
12
  const eqOps = [['is'], ['='] /* ['=='] */]
13
13
  /**
14
14
  * For operators of <notEqOps>, do the same but use or instead of and.
15
- * This ensures that not struc == <value> is the same as struc != <value>.
15
+ * This ensures that not struct == <value> is the same as struct != <value>.
16
16
  */
17
17
  const notEqOps = [['is', 'not'], ['<>'], ['!=']]
18
18
  /**
@@ -96,7 +96,46 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
96
96
  }
97
97
 
98
98
  if (queryNeedsJoins) {
99
- transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
99
+ if (inferred.UPDATE || inferred.DELETE) {
100
+ const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
101
+ const subquery = {
102
+ SELECT: {
103
+ from: { ...transformedFrom },
104
+ columns: [], // primary keys of the query target will be added later
105
+ where: [...transformedProp.where],
106
+ },
107
+ }
108
+ // The alias of the original query is now the alias for the subquery
109
+ // so that potential references in the where clause to the alias match.
110
+ // Hence, replace the alias of the original query with the next
111
+ // available alias, so that each alias is unique.
112
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
113
+ transformedFrom.as = uniqueSubqueryAlias
114
+
115
+ // calculate the primary keys of the target entity, there is always exactly
116
+ // one query source for UPDATE / DELETE
117
+ const queryTarget = Object.values(originalQuery.sources)[0]
118
+ const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
119
+ const primaryKey = { list: [] }
120
+ keys.forEach(k => {
121
+ // cqn4sql will add the table alias to the column later, no need to add it here
122
+ subquery.SELECT.columns.push({ ref: [k.name] })
123
+
124
+ // add the alias of the main query to the list of primary key references
125
+ primaryKey.list.push({ ref: [transformedFrom.as, k.name] })
126
+ })
127
+
128
+ const transformedSubquery = cqn4sql(subquery)
129
+
130
+ // replace where condition of original query with the transformed subquery
131
+ // correlate UPDATE / DELETE query with subquery by primary key matches
132
+ transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
133
+
134
+ if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
135
+ else transformedQuery.DELETE.from = transformedFrom
136
+ } else {
137
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
138
+ }
100
139
  }
101
140
  }
102
141
  }
@@ -668,12 +707,24 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
668
707
  // everything after the wildcard, is a potential replacement
669
708
  // in the wildcard expansion
670
709
  const replace = []
710
+
711
+ const baseRef = col.ref || []
712
+ const baseRefLinks = col.$refLinks || []
713
+
714
+ // column has no ref, then it is an anonymous expand:
715
+ // select from books { { * } as bar }
716
+ // only possible if there is exactly one query source
717
+ if (!baseRef.length) {
718
+ const [tableAlias, definition] = Object.entries(inferred.sources)[0]
719
+ baseRef.push(tableAlias)
720
+ baseRefLinks.push({ definition, source: definition })
721
+ }
671
722
  // we need to make the refs absolute
672
723
  col[prop].slice(wildcardIndex + 1).forEach(c => {
673
724
  const fakeColumn = { ...c }
674
725
  if (fakeColumn.ref) {
675
- fakeColumn.ref = [...col.ref, ...fakeColumn.ref]
676
- fakeColumn.$refLinks = [...col.$refLinks, ...c.$refLinks]
726
+ fakeColumn.ref = [...baseRef, ...fakeColumn.ref]
727
+ fakeColumn.$refLinks = [...baseRefLinks, ...c.$refLinks]
677
728
  }
678
729
  replace.push(fakeColumn)
679
730
  })
@@ -682,15 +733,15 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
682
733
  // fake the ref since excluding only has strings
683
734
  col.excluding.forEach(c => {
684
735
  const fakeColumn = {
685
- ref: [...col.ref, c],
736
+ ref: [...baseRef, c],
686
737
  }
687
738
  exclude.push(fakeColumn)
688
739
  })
689
740
  }
690
741
 
691
- if (col.$refLinks[col.$refLinks.length - 1].definition.kind === 'entity')
692
- res.push(...getColumnsForWildcard(exclude, replace))
693
- else
742
+ if (baseRefLinks.at(-1).definition.kind === 'entity') {
743
+ res.push(...getColumnsForWildcard(exclude, replace, col.as))
744
+ } else
694
745
  res.push(
695
746
  ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getQuerySourceName(col) }, [], {
696
747
  exclude,
@@ -907,12 +958,14 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
907
958
  *
908
959
  * Furthermore, foreign keys (FK) for OData CSN and blobs are excluded from the wildcard expansion.
909
960
  *
910
- * @param {Array} exclude - An optional list of columns to be excluded during the wildcard expansion.
911
- * @param {Array} replace - An optional list of columns to replace during the wildcard expansion.
961
+ * @param {array} exclude - An optional list of columns to be excluded during the wildcard expansion.
962
+ * @param {array} replace - An optional list of columns to replace during the wildcard expansion.
963
+ * @param {string} baseName - the explicit alias of the column.
964
+ * Only possible for anonymous expands on implicit table alias: `select from books { { * } as FOO }`
912
965
  *
913
966
  * @returns {Array} Returns an array of explicit columns derived from the wildcard.
914
967
  */
915
- function getColumnsForWildcard(exclude = [], replace = []) {
968
+ function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
916
969
  const wildcardColumns = []
917
970
  Object.keys(inferred.$combinedElements)
918
971
  .filter(k => !exclude.includes(k))
@@ -927,7 +980,13 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
927
980
  } else if (isCalculatedOnRead(element)) {
928
981
  wildcardColumns.push(resolveCalculatedElement(replace.find(r => r.as === k) || element))
929
982
  } else {
930
- const flatColumns = getFlatColumnsFor(element, { tableAlias: index }, [], { exclude, replace }, true)
983
+ const flatColumns = getFlatColumnsFor(
984
+ element,
985
+ { tableAlias: index, baseName },
986
+ [],
987
+ { exclude, replace },
988
+ true,
989
+ )
931
990
  wildcardColumns.push(...flatColumns)
932
991
  }
933
992
  })
@@ -969,7 +1028,16 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
969
1028
  * Columns excluded in a wildcard expansion or replaced by other columns are also handled accordingly.
970
1029
  *
971
1030
  * @param {object} column - The structured element which needs to be expanded.
972
- * @param {string} baseName - The prefixes of the column reference (joined with '_'). Optional.
1031
+ * @param {{
1032
+ * columnAlias: string
1033
+ * tableAlias: string
1034
+ * baseName: string
1035
+ * }} names - configuration object for naming parameters:
1036
+ * columnAlias - The explicit alias which the user has defined for the column.
1037
+ * For instance `{ struct.foo as bar}` will be transformed into
1038
+ * `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
1039
+ * tableAlias - The table alias to prepend to the column name. Optional.
1040
+ * baseName - The prefixes of the column reference (joined with '_'). Optional.
973
1041
  * @param {string} columnAlias - The explicit alias which the user has defined for the column.
974
1042
  * For instance `{ struct.foo as bar}` will be transformed into
975
1043
  * `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
@@ -1352,7 +1420,7 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1352
1420
  if (flatRhs) {
1353
1421
  const flatLhs = flattenWithBaseName(token)
1354
1422
 
1355
- //Revisit: Early exit here? We kndow we cant compare the structs, however we do not know exactly why
1423
+ //REVISIT: Early exit here? We kndow we cant compare the structs, however we do not know exactly why
1356
1424
  // --> calculate error message or exit early? See test "proper error if structures cannot be compared / too many elements on lhs"
1357
1425
  if (flatRhs.length !== flatLhs.length)
1358
1426
  // make sure we can compare both structures
@@ -1419,10 +1487,10 @@ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
1419
1487
 
1420
1488
  function assertNoStructInXpr(token, inInfixFilter = false) {
1421
1489
  if (!inInfixFilter && token.$refLinks?.[token.$refLinks.length - 1].definition.target)
1422
- // revisit: let this through if not requested otherwise
1490
+ // REVISIT: let this through if not requested otherwise
1423
1491
  rejectAssocInExpression()
1424
1492
  if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1425
- // revisit: let this through if not requested otherwise
1493
+ // REVISIT: let this through if not requested otherwise
1426
1494
  rejectStructInExpression()
1427
1495
 
1428
1496
  function rejectAssocInExpression() {
@@ -173,7 +173,9 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
173
173
  const nextStep = ref[1]?.id || ref[1]
174
174
  // no unmanaged assoc in infix filter path
175
175
  if (!expandOrExists && e.on)
176
- throw new Error(`"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`)
176
+ throw new Error(
177
+ `"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
178
+ )
177
179
  // no non-fk traversal in infix filter
178
180
  if (!expandOrExists && nextStep && !(nextStep in e.foreignKeys))
179
181
  throw new Error(`Only foreign keys of "${e.name}" can be accessed in infix filter`)
@@ -339,13 +341,11 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
339
341
  }
340
342
 
341
343
  // walk over all paths in other query properties
342
- if (where)
343
- walkTokenStream(where)
344
- if (groupBy)
345
- groupBy.forEach(token => inferQueryElement(token, false))
346
- if (having)
347
- walkTokenStream(having)
348
- if (_.with) // consider UPDATE.with
344
+ if (where) walkTokenStream(where)
345
+ if (groupBy) groupBy.forEach(token => inferQueryElement(token, false))
346
+ if (having) walkTokenStream(having)
347
+ if (_.with)
348
+ // consider UPDATE.with
349
349
  Object.values(_.with).forEach(val => inferQueryElement(val, false))
350
350
 
351
351
  return queryElements
@@ -355,8 +355,8 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
355
355
  * on the information whether the next token is resolved within an `exists` predicates.
356
356
  * If such a token has an infix filter, it is not join relevant, because the filter
357
357
  * condition is applied to the generated `exists <subquery>` condition.
358
- *
359
- * @param {array} tokenStream
358
+ *
359
+ * @param {array} tokenStream
360
360
  */
361
361
  function walkTokenStream(tokenStream) {
362
362
  let skipJoins
@@ -476,12 +476,10 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
476
476
  // if any path step points to an artifact with `@cds.persistence.skip`
477
477
  // we must ignore the element from the queries elements
478
478
  let isPersisted = true
479
- const firstStepIsTableAlias =
480
- (column.ref.length > 1 && column.ref[0] in sources) ||
481
- // nested projection on table alias
482
- (column.ref.length === 1 && column.ref[0] in sources && column.inline)
479
+ const firstStepIsTableAlias = column.ref.length > 1 && column.ref[0] in sources
483
480
  const firstStepIsSelf =
484
481
  !firstStepIsTableAlias && column.ref.length > 1 && ['$self', '$projection'].includes(column.ref[0])
482
+ const expandOnTableAlias = column.ref.length === 1 && column.ref[0] in sources && (column.expand || column.inline)
485
483
  const nameSegments = []
486
484
  // if a (segment) of a (structured) foreign key is renamed, we must not include
487
485
  // the aliased ref segments into the name of the final foreign key which is e.g. used in
@@ -501,20 +499,7 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
501
499
  const elements = definition.elements || definition._target?.elements
502
500
  if (elements && id in elements) {
503
501
  const element = elements[id]
504
- if (!inNestedProjection && !inCalcElement && element.target) {
505
- // only fk access in infix filter
506
- const nextStep = column.ref[1]?.id || column.ref[1]
507
- // no unmanaged assoc in infix filter path
508
- if (!inExists && element.on)
509
- throw new Error(
510
- `"${element.name}" in path "${column.ref
511
- .map(idOnly)
512
- .join('.')}" must not be an unmanaged association`,
513
- )
514
- // no non-fk traversal in infix filter
515
- if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
516
- throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
517
- }
502
+ rejectNonFkAccess(element)
518
503
  const resolvableIn = definition.target ? definition._target : target
519
504
  column.$refLinks.push({ definition: elements[id], target: resolvableIn })
520
505
  } else {
@@ -535,6 +520,12 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
535
520
  const $refLink = { definition, target: $combinedElements[id][0].tableAlias }
536
521
  column.$refLinks.push($refLink)
537
522
  nameSegments.push(id)
523
+ } else if (expandOnTableAlias) {
524
+ // expand on table alias
525
+ column.$refLinks.push({
526
+ definition: sources[id],
527
+ target: sources[id],
528
+ })
538
529
  } else {
539
530
  stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements)
540
531
  }
@@ -545,14 +536,16 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
545
536
 
546
537
  if (firstStepIsSelf && element?.isAssociation) {
547
538
  throw new Error(
548
- `Paths starting with “$self” must not contain steps of type “cds.Association”: ref: [ ${column.ref.map(
549
- idOnly,
550
- ).join(', ')} ]`,
539
+ `Paths starting with “$self” must not contain steps of type “cds.Association”: ref: [ ${column.ref
540
+ .map(idOnly)
541
+ .join(', ')} ]`,
551
542
  )
552
543
  }
553
544
 
554
545
  const target = definition._target || column.$refLinks[i - 1].target
555
546
  if (element) {
547
+ if($baseLink)
548
+ rejectNonFkAccess(element)
556
549
  const $refLink = { definition: elements[id], target }
557
550
  column.$refLinks.push($refLink)
558
551
  } else if (firstStepIsSelf) {
@@ -619,7 +612,12 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
619
612
  if (insertIntoQueryElements) queryElements[column.as || flatName] = getCopyWithAnnos(column, base)
620
613
  } else if (column.expand) {
621
614
  const elements = resolveExpand(column)
622
- if (insertIntoQueryElements) queryElements[column.as || flatName] = elements
615
+ let elementName
616
+ // expand on table alias
617
+ if (column.$refLinks.length === 1 && column.$refLinks[0].definition.kind === 'entity')
618
+ elementName = column.$refLinks[0].alias
619
+ else elementName = column.as || flatName
620
+ if (insertIntoQueryElements) queryElements[elementName] = elements
623
621
  } else if (column.inline && insertIntoQueryElements) {
624
622
  const elements = resolveInline(column)
625
623
  queryElements = { ...queryElements, ...elements }
@@ -649,6 +647,29 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
649
647
  }
650
648
  }
651
649
  }
650
+
651
+ /**
652
+ * Check if the next step in the ref is foreign key of `element`
653
+ * if not, an error is thrown.
654
+ *
655
+ * @param {CSN.Element} element if this is an association, the next step must be a foreign key of the element.
656
+ */
657
+ function rejectNonFkAccess(element) {
658
+ if (!inNestedProjection && !inCalcElement && element.target) {
659
+ // only fk access in infix filter
660
+ const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
661
+ // no unmanaged assoc in infix filter path
662
+ if (!inExists && element.on)
663
+ throw new Error(
664
+ `"${element.name}" in path "${column.ref
665
+ .map(idOnly)
666
+ .join('.')}" must not be an unmanaged association`
667
+ )
668
+ // no non-fk traversal in infix filter
669
+ if (nextStep && element.foreignKeys && !(nextStep in element.foreignKeys))
670
+ throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
671
+ }
672
+ }
652
673
  })
653
674
 
654
675
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
@@ -671,10 +692,6 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
671
692
  ? { ref: [...baseColumn.ref, ...column.ref], $refLinks: [...baseColumn.$refLinks, ...column.$refLinks] }
672
693
  : column
673
694
  if (isColumnJoinRelevant(colWithBase)) {
674
- if (originalQuery.UPDATE)
675
- throw new Error(
676
- 'Path expressions for UPDATE statements are not supported. Use “where exists” with infix filters instead.',
677
- )
678
695
  Object.defineProperty(column, 'isJoinRelevant', { value: true })
679
696
  joinTree.mergeColumn(colWithBase, originalQuery.outerQueries)
680
697
  }
@@ -752,8 +769,8 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
752
769
  */
753
770
  function resolveExpand(col) {
754
771
  const { expand, $refLinks } = col
755
- const $leafLink = $refLinks?.[$refLinks.length - 1]
756
- if ($leafLink?.definition._target) {
772
+ const $leafLink = $refLinks?.[$refLinks.length - 1] || inferred.SELECT.from.$refLinks.at(-1) // fallback to anonymous expand
773
+ if ($leafLink.definition._target) {
757
774
  const expandSubquery = {
758
775
  SELECT: {
759
776
  from: $leafLink.definition._target.name,
@@ -864,20 +881,26 @@ function infer(originalQuery, model = cds.context?.model || cds.model) {
864
881
  if (leafOfCalculatedElementRef.value) mergePathsIntoJoinTree(leafOfCalculatedElementRef.value, basePath)
865
882
 
866
883
  mergePathIfNecessary(basePath, arg)
867
- } else if (arg.xpr) {
868
- arg.xpr.forEach(step => {
884
+ } else if (arg.xpr || arg.args) {
885
+ const prop = arg.xpr ? 'xpr' : 'args'
886
+ arg[prop].forEach(step => {
887
+ const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
869
888
  if (step.ref) {
870
- const subPath = { $refLinks: [...basePath.$refLinks], ref: [...basePath.ref] }
871
889
  step.$refLinks.forEach((link, i) => {
872
890
  const { definition } = link
873
891
  if (definition.value) {
874
- mergePathsIntoJoinTree(definition.value)
892
+ mergePathsIntoJoinTree(definition.value, subPath)
875
893
  } else {
876
894
  subPath.$refLinks.push(link)
877
895
  subPath.ref.push(step.ref[i])
878
896
  }
879
897
  })
880
898
  mergePathIfNecessary(subPath, step)
899
+ } else if (step.args || step.xpr) {
900
+ const nestedProp = step.xpr ? 'xpr' : 'args'
901
+ step[nestedProp].forEach(a => {
902
+ mergePathsIntoJoinTree(a, subPath)
903
+ })
881
904
  }
882
905
  })
883
906
  }
@@ -148,7 +148,6 @@ class JoinTree {
148
148
  *
149
149
  * It begins by inferring the source of the given column, which is the table alias where the column is resolvable.
150
150
  * Each step during this process represents a node in the join tree. If a node already exists in the tree, the current step is replaced by the already merged node.
151
- * For each step, it checks whether it has been seen before. If so, it resets the $refLink to point to the already merged $refLink.
152
151
  * If not, it creates a new Node and ensures proper aliasing and foreign key access.
153
152
  *
154
153
  * @param {object} col - The column object to be merged into the existing join tree. This object should have the properties $refLinks and ref.
package/lib/search.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict'
2
+
3
+ const DRAFT_COLUMNS_UNION = {
4
+ IsActiveEntity: 1,
5
+ HasActiveEntity: 1,
6
+ HasDraftEntity: 1,
7
+ DraftAdministrativeData_DraftUUID: 1,
8
+ SiblingEntity: 1,
9
+ DraftAdministrativeData: 1,
10
+ }
11
+ const DEFAULT_SEARCHABLE_TYPE = 'cds.String'
12
+
13
+ /**
14
+ * This method gets all columns for an entity.
15
+ * It includes the generated foreign keys from managed associations, structured elements and complex and custom types.
16
+ * As well, it provides the annotations starting with '@' for each column.
17
+ *
18
+ * @param {object} entity - the csn entity
19
+ * @param {object} [options]
20
+ * @param [options.onlyNames=false] - decides if the column name or the csn representation of the column should be returned
21
+ * @param [options.filterDraft=false] - indicates whether the draft columns should be filtered if the entity is draft enabled
22
+ * @param [options.removeIgnore=false]
23
+ * @param [options.filterVirtual=false]
24
+ * @param [options.keysOnly=false]
25
+ * @returns {Array<object>} - array of columns
26
+ */
27
+ const getColumns = (
28
+ entity,
29
+ { onlyNames = false, removeIgnore = false, filterDraft = true, filterVirtual = false, keysOnly = false },
30
+ ) => {
31
+ const skipDraft = filterDraft && entity._isDraftEnabled
32
+ const columns = []
33
+ const elements = entity.elements
34
+
35
+ for (const each in elements) {
36
+ const element = elements[each]
37
+ if (element.isAssociation) continue
38
+ if (filterVirtual && element.virtual) continue
39
+ if (removeIgnore && element['@cds.api.ignore']) continue
40
+ if (skipDraft && each in DRAFT_COLUMNS_UNION) continue
41
+ if (keysOnly && !element.key) continue
42
+ columns.push(onlyNames ? each : element)
43
+ }
44
+
45
+ return columns
46
+ }
47
+
48
+ const _isColumnCalculated = (query, columnName) => {
49
+ if (!query) return false
50
+ if (query.SELECT?.columns?.find(col => col.xpr && col.as === columnName)) return true
51
+ return _isColumnCalculated(query._target?.query, columnName)
52
+ }
53
+
54
+ const _getSearchableColumns = entity => {
55
+ const columnsOptions = { removeIgnore: true, filterVirtual: true }
56
+ const columns = getColumns(entity, columnsOptions)
57
+ const cdsSearchTerm = '@cds.search'
58
+ const cdsSearchKeys = []
59
+ const cdsSearchColumnMap = new Map()
60
+
61
+ for (const key in entity) {
62
+ if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
63
+ }
64
+
65
+ let atLeastOneColumnIsSearchable = false
66
+
67
+ // build a map of columns annotated with the @cds.search annotation
68
+ for (const key of cdsSearchKeys) {
69
+ const columnName = key.split(cdsSearchTerm + '.').pop()
70
+
71
+ // REVISIT: for now, exclude search using path expression, as deep search is not currently
72
+ // supported
73
+ if (columnName.includes('.')) {
74
+ continue
75
+ }
76
+
77
+ const annotationKey = `${cdsSearchTerm}.${columnName}`
78
+ const annotationValue = entity[annotationKey]
79
+ if (annotationValue) atLeastOneColumnIsSearchable = true
80
+ cdsSearchColumnMap.set(columnName, annotationValue)
81
+ }
82
+
83
+ const searchableColumns = columns.filter(column => {
84
+ const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
85
+
86
+ // the element is searchable if it is annotated with the @cds.search, e.g.:
87
+ // `@cds.search { element1: true }` or `@cds.search { element1 }`
88
+ if (annotatedColumnValue) return true
89
+
90
+ // if at least one element is explicitly annotated as searchable, e.g.:
91
+ // `@cds.search { element1: true }` or `@cds.search { element1 }`
92
+ // and it is not the current column name, then it must be excluded from the search
93
+ if (atLeastOneColumnIsSearchable) return false
94
+
95
+ // the element is considered searchable if it is explicitly annotated as such or
96
+ // if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
97
+ return (
98
+ annotatedColumnValue === undefined &&
99
+ column._type === DEFAULT_SEARCHABLE_TYPE &&
100
+ !_isColumnCalculated(entity?.query, column.name)
101
+ )
102
+ })
103
+
104
+ // if the @cds.search annotation is provided -->
105
+ // Early return to ignore the interpretation of the @Search.defaultSearchElement
106
+ // annotation when an entity is annotated with the @cds.search annotation.
107
+ // The @cds.search annotation overrules the @Search.defaultSearchElement annotation.
108
+ if (cdsSearchKeys.length > 0) {
109
+ return searchableColumns.map(column => column.name)
110
+ }
111
+
112
+ return searchableColumns.map(column => column.name)
113
+ }
114
+
115
+ /**
116
+ * @returns {Array<object>} - array of columns
117
+ */
118
+ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, alias) => {
119
+ let toBeSearched = []
120
+
121
+ // aggregations case
122
+ // in the new parser groupBy is moved to sub select.
123
+ if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
124
+ cqn.SELECT.columns &&
125
+ cqn.SELECT.columns.forEach(column => {
126
+ if (column.func) {
127
+ // exclude $count by SELECT of number of Items in a Collection
128
+ if (
129
+ cqn.SELECT.columns.length === 1 &&
130
+ column.func === 'count' &&
131
+ (column.as === '_counted_' || column.as === '$count')
132
+ ) {
133
+ return
134
+ }
135
+
136
+ toBeSearched.push(column)
137
+ return
138
+ }
139
+
140
+ const columnRef = column.ref
141
+ if (columnRef) {
142
+ if (entity.elements[columnRef[columnRef.length - 1]]?._type !== DEFAULT_SEARCHABLE_TYPE) return
143
+ column = { ref: [...column.ref] }
144
+ if (alias) column.ref.unshift(alias)
145
+ toBeSearched.push(column)
146
+ }
147
+ })
148
+ } else {
149
+ toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
150
+ if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
151
+ toBeSearched = toBeSearched.map(c => {
152
+ const col = { ref: [c] }
153
+ if (alias) col.ref.unshift(alias)
154
+ return col
155
+ })
156
+ }
157
+
158
+ return toBeSearched
159
+ }
160
+
161
+ module.exports = {
162
+ getColumns,
163
+ computeColumnsToBeSearched,
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.3.1",
3
+ "version": "1.4.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": {