@cap-js/db-service 1.10.3 → 1.12.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,36 @@
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.12.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.11.0...db-service-v1.12.0) (2024-07-25)
8
+
9
+
10
+ ### Fixed
11
+
12
+ *** add placeholder for string values ([#733](https://github.com/cap-js/cds-dbs/issues/733)) ([8136a45](https://github.com/cap-js/cds-dbs/commit/8136a4526f596b67932908b8ab1336cb052100f3))
13
+ *** for aggregated `expand` always set explicit alias ([#739](https://github.com/cap-js/cds-dbs/issues/739)) ([53a8075](https://github.com/cap-js/cds-dbs/commit/53a8075a609666a896296401a28b6183ff5aa487)), closes [#708](https://github.com/cap-js/cds-dbs/issues/708)
14
+ *** quotations in vals ([#754](https://github.com/cap-js/cds-dbs/issues/754)) ([94d8e97](https://github.com/cap-js/cds-dbs/commit/94d8e977ed00776ff494287ce505d6b7e8017d2e))
15
+
16
+
17
+ ### Changed
18
+
19
+ *** generic-pool as real dep ([#750](https://github.com/cap-js/cds-dbs/issues/750)) ([b50c907](https://github.com/cap-js/cds-dbs/commit/b50c907880455a41a73826a736bc17ca17e5b9ae))
20
+
21
+
22
+ ## [1.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
23
+
24
+
25
+ ### Added
26
+
27
+ * **search:** enable deep search with path expressions ([#590](https://github.com/cap-js/cds-dbs/issues/590)) ([e9e9461](https://github.com/cap-js/cds-dbs/commit/e9e9461362b3a521c06075e32fb1405c8ee4dee6))
28
+
29
+ ### Changed
30
+
31
+ * `search` interprets only first search term instead of raising an error ([#707](https://github.com/cap-js/cds-dbs/issues/707)) ([0b9108c](https://github.com/cap-js/cds-dbs/commit/0b9108c11a61b18704e36f93fbd654e0942bf40a))
32
+
33
+ ### Fixed
34
+
35
+ * optimize foreign key access for expand with aggregations ([#734](https://github.com/cap-js/cds-dbs/issues/734)) ([77b7978](https://github.com/cap-js/cds-dbs/commit/77b79788931c9c45f156d54a4b2ec76ba9ba629a))
36
+
7
37
  ## [1.10.3](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.2...db-service-v1.10.3) (2024-07-05)
8
38
 
9
39
 
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # CDS base database service
2
2
 
3
- Welcome to the new base database service for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js, based on new, streamlined database architecture.
3
+ Welcome to the base database service for [SAP Cloud Application Programming Model](https://cap.cloud.sap) Node.js. This service forms the core of all supported databases and is the base of our streamlined database architecture.
4
4
 
5
- Find documentation at https://cap.cloud.sap/docs/guides/databases
5
+ Find documentation at <https://cap.cloud.sap/docs/guides/databases>
6
6
 
7
7
  ## Support
8
8
 
@@ -23,4 +23,4 @@ We as members, contributors, and leaders pledge to make participation in our com
23
23
 
24
24
  ## Licensing
25
25
 
26
- Copyright 2023 SAP SE or an SAP affiliate company and cds-dbs contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/cds-dbs).
26
+ Copyright 2024 SAP SE or an SAP affiliate company and cds-dbs contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/cds-dbs).
package/lib/SQLService.js CHANGED
@@ -116,6 +116,8 @@ class SQLService extends DatabaseService {
116
116
  * @type {Handler}
117
117
  */
118
118
  async onSELECT({ query, data }) {
119
+ // REVISIT: for custom joins, infer is called twice, which is bad
120
+ // --> make cds.infer properly work with custom joins and remove this
119
121
  if (!query.target) {
120
122
  try { this.infer(query) } catch { /**/ }
121
123
  }
@@ -369,10 +371,8 @@ class SQLService extends DatabaseService {
369
371
  !q.SELECT?.from?.join &&
370
372
  !q.SELECT?.from?.SELECT &&
371
373
  !this.model?.definitions[_target_name4(q)]
372
- ) {
373
- return _unquirked(q)
374
- }
375
- return cqn4sql(q, this.model)
374
+ ) return q
375
+ else return cqn4sql(q, this.model)
376
376
  }
377
377
 
378
378
  /**
@@ -462,18 +462,6 @@ const _target_name4 = q => {
462
462
  return first.id || first
463
463
  }
464
464
 
465
- const _unquirked = !cds.env.ql.quirks_mode ? q => q : q => {
466
- if (!q) return q
467
- else if (typeof q.SELECT?.from === 'string') q.SELECT.from = { ref: [q.SELECT.from] }
468
- else if (typeof q.INSERT?.into === 'string') q.INSERT.into = { ref: [q.INSERT.into] }
469
- else if (typeof q.UPSERT?.into === 'string') q.UPSERT.into = { ref: [q.UPSERT.into] }
470
- else if (typeof q.UPDATE?.entity === 'string') q.UPDATE.entity = { ref: [q.UPDATE.entity] }
471
- else if (typeof q.DELETE?.from === 'string') q.DELETE.from = { ref: [q.DELETE.from] }
472
- else if (typeof q.CREATE?.entity === 'string') q.CREATE.entity = { ref: [q.CREATE.entity] }
473
- else if (typeof q.DROP?.entity === 'string') q.DROP.entity = { ref: [q.DROP.entity] }
474
- return q
475
- }
476
-
477
465
  const sqls = new (class extends SQLService {
478
466
  get factory() {
479
467
  return null
@@ -1,4 +1,4 @@
1
- const { createPool } = require('@sap/cds-foss').pool
1
+ const { createPool } = require('generic-pool')
2
2
 
3
3
  class ConnectionPool {
4
4
  constructor(factory, tenant) {
@@ -22,6 +22,9 @@ const StandardFunctions = {
22
22
  */
23
23
  search: function (ref, arg) {
24
24
  if (!('val' in arg)) throw new Error(`Only single value arguments are allowed for $search`)
25
+ // only apply first search term, rest is ignored
26
+ const sub= /("")|("(?:[^"]|\\")*(?:[^\\]|\\\\)")|(\S*)/.exec(arg.val)
27
+ arg.val = arg.__proto__.val = (sub[2] ? JSON.parse(sub[2]) : sub[3]) || ''
25
28
  const refs = ref.list || [ref],
26
29
  { toString } = ref
27
30
  return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
package/lib/cqn2sql.js CHANGED
@@ -85,7 +85,7 @@ class CQN2SQLRenderer {
85
85
  const sanitize_values = process.env.NODE_ENV === 'production' && cds.env.log.sanitize_values !== false
86
86
  DEBUG?.(
87
87
  this.sql,
88
- sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values,
88
+ ...(sanitize_values && (this.entries || this.values?.length > 0) ? ['***'] : this.entries || this.values || []),
89
89
  )
90
90
  return this
91
91
  }
@@ -311,8 +311,8 @@ class CQN2SQLRenderer {
311
311
  */
312
312
  column_expr(x, q) {
313
313
  if (x === '*') return '*'
314
-
315
- let sql = this.expr({ param: false, __proto__: x })
314
+
315
+ let sql = x.param !== true && typeof x.val === 'number' ? this.expr({ param: false, __proto__: x }): this.expr(x)
316
316
  let alias = this.column_alias4(x, q)
317
317
  if (alias) sql += ' as ' + this.quote(alias)
318
318
  return sql
@@ -508,6 +508,7 @@ class CQN2SQLRenderer {
508
508
  } else {
509
509
  const stream = Readable.from(this.INSERT_entries_stream(INSERT.entries), { objectMode: false })
510
510
  stream.type = 'json'
511
+ stream._raw = INSERT.entries
511
512
  this.entries = [[...this.values, stream]]
512
513
  }
513
514
 
@@ -652,6 +653,7 @@ class CQN2SQLRenderer {
652
653
  } else {
653
654
  const stream = Readable.from(this.INSERT_rows_stream(INSERT.rows), { objectMode: false })
654
655
  stream.type = 'json'
656
+ stream._raw = INSERT.rows
655
657
  this.entries = [[...this.values, stream]]
656
658
  }
657
659
 
@@ -1080,6 +1082,8 @@ Buffer.prototype.toJSON = function () {
1080
1082
  return this.toString('base64')
1081
1083
  }
1082
1084
 
1085
+ Readable.prototype[require('node:util').inspect.custom] = Readable.prototype.toJSON = function () { return this._raw || `[object ${this.constructor.name}]` }
1086
+
1083
1087
  const ObjectKeys = o => (o && [...ObjectKeys(o.__proto__), ...Object.keys(o)]) || []
1084
1088
  const _managed = {
1085
1089
  '$user.id': '$user.id',
package/lib/cqn4sql.js CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict'
2
2
 
3
3
  const cds = require('@sap/cds')
4
- const { computeColumnsToBeSearched } = require('./search')
5
4
 
6
5
  const infer = require('./infer')
6
+ const { computeColumnsToBeSearched } = require('./search')
7
7
 
8
8
  /**
9
9
  * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
@@ -44,8 +44,26 @@ const { pseudos } = require('./infer/pseudos')
44
44
  * @returns {object} transformedQuery the transformed query
45
45
  */
46
46
  function cqn4sql(originalQuery, model) {
47
- const inferred = infer(originalQuery, model)
48
- if (originalQuery.SELECT?.from.args && !originalQuery.joinTree) return inferred
47
+ let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
48
+ const hasCustomJoins =
49
+ originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)
50
+
51
+ if (!hasCustomJoins && inferred.SELECT?.search) {
52
+ // we need an instance of query because the elements of the query are needed for the calculation of the search columns
53
+ if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, Object.getPrototypeOf(SELECT()))
54
+ const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
55
+ if (searchTerm) {
56
+ // Search target can be a navigation, in that case use _target to get the correct entity
57
+ const { where, having } = transformSearch(searchTerm)
58
+ if (where) inferred.SELECT.where = where
59
+ else if (having) inferred.SELECT.having = having
60
+ }
61
+ }
62
+ inferred = infer(inferred, model)
63
+ // if the query has custom joins we don't want to transform it
64
+ // TODO: move all the way to the top of this function once cds.infer supports joins as well
65
+ // we need to infer the query even if no transformation will happen because cds.infer can't calculate the target
66
+ if (hasCustomJoins) return originalQuery
49
67
 
50
68
  let transformedQuery = cds.ql.clone(inferred)
51
69
  const kind = inferred.kind || Object.keys(inferred)[0]
@@ -111,7 +129,7 @@ function cqn4sql(originalQuery, model) {
111
129
 
112
130
  // calculate the primary keys of the target entity, there is always exactly
113
131
  // one query source for UPDATE / DELETE
114
- const queryTarget = Object.values(originalQuery.sources)[0].definition
132
+ const queryTarget = Object.values(inferred.sources)[0].definition
115
133
  const keys = Object.values(queryTarget.elements).filter(e => e.key === true)
116
134
  const primaryKey = { list: [] }
117
135
  keys.forEach(k => {
@@ -155,7 +173,7 @@ function cqn4sql(originalQuery, model) {
155
173
  if (columns) {
156
174
  transformedQuery.SELECT.columns = getTransformedColumns(columns)
157
175
  } else {
158
- transformedQuery.SELECT.columns = getColumnsForWildcard(originalQuery.SELECT?.excluding)
176
+ transformedQuery.SELECT.columns = getColumnsForWildcard(inferred.SELECT?.excluding)
159
177
  }
160
178
 
161
179
  // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
@@ -178,13 +196,6 @@ function cqn4sql(originalQuery, model) {
178
196
  transformedQuery.SELECT.orderBy = transformedOrderBy
179
197
  }
180
198
  }
181
-
182
- if (inferred.SELECT.search) {
183
- // Search target can be a navigation, in that case use _target to get the correct entity
184
- const { where, having } = transformSearch(inferred.SELECT.search, transformedFrom) || {}
185
- if (where) transformedQuery.SELECT.where = where
186
- else if (having) transformedQuery.SELECT.having = having
187
- }
188
199
  return transformedQuery
189
200
  }
190
201
 
@@ -206,46 +217,29 @@ function cqn4sql(originalQuery, model) {
206
217
  * Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
207
218
  * The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
208
219
  *
209
- * @param {object} search - The search expression to be applied to the searchable columns within the query source.
220
+ * @param {object} searchTerm - The search expression to be applied to the searchable columns within the query source.
210
221
  * @param {object} from - The FROM clause of the CQN statement.
211
222
  *
212
- * @returns {(Object|Array|null)} - The function returns an object representing the WHERE or HAVING clause of the query:
213
- * - If the target of the query contains searchable elements, an array representing the WHERE or HAVING clause is returned.
214
- * This includes appending to an existing clause with an AND condition or creating a new clause solely with the 'contains' clause.
215
- * - If the SELECT query does not initially contain a WHERE or HAVING clause, the returned object solely consists of the 'contains' clause.
216
- * - If the target entity of the query does not contain searchable elements, the function returns null.
223
+ * @returns {Object} - The function returns an object representing the WHERE or HAVING clause of the query.
217
224
  *
218
225
  * Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
219
226
  * The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
220
227
  */
221
- function transformSearch(search, from) {
222
- const entity = getDefinition(from.$refLinks[0].definition.target) || from.$refLinks[0].definition
223
- // pass transformedQuery because we may need to search in the columns directly
224
- // in case of aggregation
225
- const searchIn = computeColumnsToBeSearched(transformedQuery, entity, from.as)
226
- if (searchIn.length > 0) {
227
- const xpr = search
228
- const contains = {
229
- func: 'search',
230
- args: [
231
- searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
232
- xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
233
- ],
234
- }
235
-
236
- // if the query is grouped and the queries columns contain an aggregate function,
237
- // we must put the search term into the `having` clause, as the search expression
238
- // is defined on the aggregated result, not on the individual rows
239
- let prop = 'where'
240
-
241
- if (inferred.SELECT.groupBy && searchIn.some(c => c.func || c.xpr)) prop = 'having'
242
- if (transformedQuery.SELECT[prop]) {
243
- return { [prop]: [asXpr(transformedQuery.SELECT.where), 'and', contains] }
244
- } else {
245
- return { [prop]: [contains] }
246
- }
228
+ function transformSearch(searchTerm) {
229
+ let prop = 'where'
230
+
231
+ // if the query is grouped and the queries columns contain an aggregate function,
232
+ // we must put the search term into the `having` clause, as the search expression
233
+ // is defined on the aggregated result, not on the individual rows
234
+ const usesAggregation =
235
+ inferred.SELECT.groupBy &&
236
+ (searchTerm.args[0].func || searchTerm.args[0].xpr || searchTerm.args[0].list?.some(c => c.func || c.xpr))
237
+
238
+ if (usesAggregation) prop = 'having'
239
+ if (inferred.SELECT[prop]) {
240
+ return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
247
241
  } else {
248
- return null
242
+ return { [prop]: [searchTerm] }
249
243
  }
250
244
  }
251
245
 
@@ -438,7 +432,7 @@ function cqn4sql(originalQuery, model) {
438
432
  return
439
433
  }
440
434
 
441
- const tableAlias = getQuerySourceName(col)
435
+ const tableAlias = getTableAlias(col)
442
436
  // re-adjust usage of implicit alias in subquery
443
437
  if (col.$refLinks[0].definition.kind === 'entity' && col.ref[0] !== tableAlias) {
444
438
  col.ref[0] = tableAlias
@@ -484,7 +478,7 @@ function cqn4sql(originalQuery, model) {
484
478
  if (replaceWith === -1) transformedColumns.push(transformedColumn)
485
479
  else transformedColumns.splice(replaceWith, 1, transformedColumn)
486
480
 
487
- setElementOnColumns(transformedColumn, originalQuery.elements[col.as])
481
+ setElementOnColumns(transformedColumn, inferred.elements[col.as])
488
482
  }
489
483
 
490
484
  function getTransformedColumn(col) {
@@ -731,7 +725,7 @@ function cqn4sql(originalQuery, model) {
731
725
  res.push(...getColumnsForWildcard(exclude, replace, col.as))
732
726
  } else
733
727
  res.push(
734
- ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getQuerySourceName(col) }, [], {
728
+ ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
735
729
  exclude,
736
730
  replace,
737
731
  }),
@@ -812,7 +806,8 @@ function cqn4sql(originalQuery, model) {
812
806
 
813
807
  // we need to respect the aliases of the outer query, so the columnAlias might not be suitable
814
808
  // as table alias for the correlated subquery
815
- const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, originalQuery.outerQueries)
809
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)
810
+
816
811
  // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
817
812
  const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
818
813
  const subqueryBase = Object.fromEntries(
@@ -830,7 +825,10 @@ function cqn4sql(originalQuery, model) {
830
825
  }
831
826
  const expanded = transformSubquery(subquery)
832
827
  const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
833
- Object.defineProperty(correlated, 'elements', { value: subquery.elements, writable: true })
828
+ Object.defineProperty(correlated, 'elements', {
829
+ value: expanded.elements,
830
+ writable: true,
831
+ })
834
832
  return correlated
835
833
 
836
834
  function _correlate(subq, outer) {
@@ -881,7 +879,7 @@ function cqn4sql(originalQuery, model) {
881
879
 
882
880
  // to be attached to dummy query
883
881
  const elements = {}
884
- const expandedColumns = column.expand.map(expand => {
882
+ const expandedColumns = column.expand.flatMap(expand => {
885
883
  const fullRef = [...baseRef, ...expand.ref]
886
884
 
887
885
  if (expand.expand) {
@@ -897,17 +895,16 @@ function cqn4sql(originalQuery, model) {
897
895
  )
898
896
  }
899
897
 
900
- const columnCopy = Object.create(groupByRef)
901
- if (expand.as) {
902
- columnCopy.as = expand.as
903
- }
904
- if (columnCopy.isJoinRelevant) {
905
- const tableAlias = getQuerySourceName(columnCopy)
906
- const name = calculateElementName(columnCopy)
907
- columnCopy.ref = [tableAlias, name]
908
- }
909
- elements[expand.as || expand.ref.map(idOnly).join('_')] = columnCopy.$refLinks.at(-1).definition
910
- return columnCopy
898
+ const copy = Object.create(groupByRef)
899
+ // always alias for this special case, so that they nested element names match the expected result structure
900
+ // otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
901
+ copy.as = expand.as || expand.ref.at(-1)
902
+ const tableAlias = getTableAlias(copy)
903
+ const res = getFlatColumnsFor(copy, { tableAlias })
904
+ res.forEach(c => {
905
+ elements[c.as || c.ref.at(-1)] = c.element
906
+ })
907
+ return res
911
908
  })
912
909
 
913
910
  const SELECT = {
@@ -938,15 +935,6 @@ function cqn4sql(originalQuery, model) {
938
935
  if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
939
936
  const calcElement = resolveCalculatedElement(col, true)
940
937
  res.push(calcElement)
941
- } else if (col.isJoinRelevant) {
942
- const tableAlias = getQuerySourceName(col)
943
- const name = calculateElementName(col)
944
- const transformedColumn = {
945
- ref: [tableAlias, name],
946
- }
947
- if (col.sort) transformedColumn.sort = col.sort
948
- if (col.nulls) transformedColumn.nulls = col.nulls
949
- res.push(transformedColumn)
950
938
  } else if (pseudos.elements[col.ref?.[0]]) {
951
939
  res.push({ ...col })
952
940
  } else if (col.ref) {
@@ -974,7 +962,7 @@ function cqn4sql(originalQuery, model) {
974
962
  referredCol.nulls = col.nulls
975
963
  col = referredCol
976
964
  if (definition.kind === 'element') {
977
- tableAlias = getQuerySourceName(col)
965
+ tableAlias = getTableAlias(col)
978
966
  } else {
979
967
  // we must replace the reference with the underlying expression
980
968
  const { val, func, args, xpr } = col
@@ -986,7 +974,7 @@ function cqn4sql(originalQuery, model) {
986
974
  }
987
975
  }
988
976
  } else {
989
- tableAlias = getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
977
+ tableAlias = getTableAlias(col) // do not prepend TA if orderBy column addresses element of query
990
978
  }
991
979
  const leaf = col.$refLinks[col.$refLinks.length - 1].definition
992
980
  if (leaf.virtual === true) continue // already in getFlatColumnForElement
@@ -1004,8 +992,11 @@ function cqn4sql(originalQuery, model) {
1004
992
  */
1005
993
  if (inOrderBy && flatColumns.length > 1)
1006
994
  throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
1007
- if (col.nulls) flatColumns[0].nulls = col.nulls
1008
- if (col.sort) flatColumns[0].sort = col.sort
995
+ flatColumns.forEach(fc => {
996
+ if (col.nulls) fc.nulls = col.nulls
997
+ if (col.sort) fc.sort = col.sort
998
+ if (fc.as) delete fc.as
999
+ })
1009
1000
  res.push(...flatColumns)
1010
1001
  } else {
1011
1002
  let transformedColumn
@@ -1051,7 +1042,7 @@ function cqn4sql(originalQuery, model) {
1051
1042
  const last = q.SELECT.from.ref.at(-1)
1052
1043
  const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
1053
1044
  getLastStringSegment(last.id || last),
1054
- originalQuery.outerQueries,
1045
+ inferred.outerQueries,
1055
1046
  )
1056
1047
  Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
1057
1048
  }
@@ -1163,7 +1154,7 @@ function cqn4sql(originalQuery, model) {
1163
1154
  if (column.val || column.func || column.SELECT) return [column]
1164
1155
 
1165
1156
  const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1166
- let { baseName, columnAlias, tableAlias } = names
1157
+ let { baseName, columnAlias = column.as, tableAlias } = names
1167
1158
  const { exclude, replace } = excludeAndReplace || {}
1168
1159
  const { $refLinks, flatName, isJoinRelevant } = column
1169
1160
  let leafAssoc
@@ -1206,7 +1197,7 @@ function cqn4sql(originalQuery, model) {
1206
1197
  baseName = getFullName(replacedBy.$refLinks?.[replacedBy.$refLinks.length - 2].definition)
1207
1198
  if (replacedBy.isJoinRelevant)
1208
1199
  // we need to provide the correct table alias
1209
- tableAlias = getQuerySourceName(replacedBy)
1200
+ tableAlias = getTableAlias(replacedBy)
1210
1201
 
1211
1202
  if (replacedBy.expand) return [{ as: baseName }]
1212
1203
 
@@ -1495,7 +1486,7 @@ function cqn4sql(originalQuery, model) {
1495
1486
  // hence we need to ignore the alias of the `$baseLink`
1496
1487
  const lastAssoc =
1497
1488
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1498
- const tableAlias = getQuerySourceName(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1489
+ const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1499
1490
  if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1500
1491
  let name = calculateElementName(token, getFullName)
1501
1492
  result.ref = [tableAlias, name]
@@ -1592,7 +1583,7 @@ function cqn4sql(originalQuery, model) {
1592
1583
  if (!def.$refLinks) return def
1593
1584
  const leaf = def.$refLinks[def.$refLinks.length - 1]
1594
1585
  const first = def.$refLinks[0]
1595
- const tableAlias = getQuerySourceName(
1586
+ const tableAlias = getTableAlias(
1596
1587
  def,
1597
1588
  def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink,
1598
1589
  )
@@ -1659,7 +1650,7 @@ function cqn4sql(originalQuery, model) {
1659
1650
  transformedFrom.as = from.as
1660
1651
  } else {
1661
1652
  // select from anonymous query, use artificial alias
1662
- transformedFrom.as = Object.keys(originalQuery.sources)[0]
1653
+ transformedFrom.as = Object.keys(inferred.sources)[0]
1663
1654
  }
1664
1655
  return { transformedFrom, transformedWhere: existingWhere }
1665
1656
  } else {
@@ -1702,7 +1693,7 @@ function cqn4sql(originalQuery, model) {
1702
1693
  * --> This is an artificial query, which will later be correlated
1703
1694
  * with the main query alias. see @function expandColumn()
1704
1695
  */
1705
- if (!(originalQuery.SELECT?.expand === true)) {
1696
+ if (!(inferred.SELECT?.expand === true)) {
1706
1697
  as = getNextAvailableTableAlias(as)
1707
1698
  }
1708
1699
  nextStepLink.alias = as
@@ -2162,6 +2153,35 @@ function cqn4sql(originalQuery, model) {
2162
2153
  return getDefinition(assoc.target) || null
2163
2154
  }
2164
2155
 
2156
+ /**
2157
+ * For a given search expression return a function "search" which holds the search expression
2158
+ * as well as the searchable columns as arguments.
2159
+ *
2160
+ * @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
2161
+ * @param {object} query - The FROM clause of the CQN statement.
2162
+ *
2163
+ * @returns {(Object|null)} returns either:
2164
+ * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2165
+ * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2166
+ */
2167
+ function getSearchTerm(search, query) {
2168
+ const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
2169
+ const searchIn = computeColumnsToBeSearched(inferred, entity)
2170
+ if (searchIn.length > 0) {
2171
+ const xpr = search
2172
+ const searchFunc = {
2173
+ func: 'search',
2174
+ args: [
2175
+ searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
2176
+ xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
2177
+ ],
2178
+ }
2179
+ return searchFunc
2180
+ } else {
2181
+ return null
2182
+ }
2183
+ }
2184
+
2165
2185
  /**
2166
2186
  * Calculates the name of the source which can be used to address the given node.
2167
2187
  *
@@ -2172,7 +2192,7 @@ function cqn4sql(originalQuery, model) {
2172
2192
  * the combined elements of the query
2173
2193
  * @returns the source name which can be used to address the node
2174
2194
  */
2175
- function getQuerySourceName(node, $baseLink = null) {
2195
+ function getTableAlias(node, $baseLink = null) {
2176
2196
  if (!node || !node.$refLinks || !node.ref) {
2177
2197
  throw new Error('Invalid node')
2178
2198
  }
@@ -2207,11 +2227,11 @@ function cqn4sql(originalQuery, model) {
2207
2227
  * - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
2208
2228
  */
2209
2229
  if (
2210
- originalQuery.SELECT?.from.uniqueSubqueryAlias &&
2211
- !originalQuery.SELECT?.from.as &&
2230
+ inferred.SELECT?.from.uniqueSubqueryAlias &&
2231
+ !inferred.SELECT?.from.as &&
2212
2232
  firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
2213
2233
  ) {
2214
- return originalQuery.SELECT?.from.uniqueSubqueryAlias
2234
+ return inferred.SELECT?.from.uniqueSubqueryAlias
2215
2235
  }
2216
2236
  return node.ref[0]
2217
2237
  }
@@ -24,7 +24,7 @@ for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
24
24
  */
25
25
  function infer(originalQuery, model) {
26
26
  if (!model) throw new Error('Please specify a model')
27
- const inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
27
+ const inferred = originalQuery
28
28
 
29
29
  // REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
30
30
  // e.g. there's a lot of overhead for infer( SELECT.from(Books) )
package/lib/search.js CHANGED
@@ -39,7 +39,6 @@ const getColumns = (
39
39
 
40
40
  for (const each in elements) {
41
41
  const element = elements[each]
42
- if (element.isAssociation) continue
43
42
  if (filterVirtual && element.virtual) continue
44
43
  if (removeIgnore && element['@cds.api.ignore']) continue
45
44
  if (filterDraft && each in DRAFT_COLUMNS_UNION) continue
@@ -58,7 +57,7 @@ const _isColumnCalculated = (query, columnName) => {
58
57
 
59
58
  const _getSearchableColumns = entity => {
60
59
  const columnsOptions = { removeIgnore: true, filterVirtual: true }
61
- const columns = getColumns(entity, columnsOptions)
60
+ const columns = entity.SELECT?.columns || getColumns(entity, columnsOptions)
62
61
  const cdsSearchTerm = '@cds.search'
63
62
  const cdsSearchKeys = []
64
63
  const cdsSearchColumnMap = new Map()
@@ -68,30 +67,35 @@ const _getSearchableColumns = entity => {
68
67
  }
69
68
 
70
69
  let atLeastOneColumnIsSearchable = false
70
+ const deepSearchCandidates = []
71
71
 
72
72
  // build a map of columns annotated with the @cds.search annotation
73
73
  for (const key of cdsSearchKeys) {
74
74
  const columnName = key.split(cdsSearchTerm + '.').pop()
75
-
76
- // REVISIT: for now, exclude search using path expression, as deep search is not currently
77
- // supported
78
- if (columnName.includes('.')) {
79
- continue
80
- }
81
-
82
75
  const annotationKey = `${cdsSearchTerm}.${columnName}`
83
76
  const annotationValue = entity[annotationKey]
84
77
  if (annotationValue) atLeastOneColumnIsSearchable = true
78
+
79
+ const column = entity.elements[columnName]
80
+ if (column?.isAssociation || columnName.includes('.')) {
81
+ deepSearchCandidates.push({ ref: columnName.split('.') })
82
+ continue;
83
+ }
85
84
  cdsSearchColumnMap.set(columnName, annotationValue)
86
85
  }
87
86
 
88
87
  const searchableColumns = columns.filter(column => {
89
88
  const annotatedColumnValue = cdsSearchColumnMap.get(column.name)
89
+ const elementName = column.as || column.ref?.at(-1) || column.name
90
+ const element = entity.elements[elementName]
90
91
 
91
92
  // the element is searchable if it is annotated with the @cds.search, e.g.:
92
93
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
93
94
  if (annotatedColumnValue) return true
94
95
 
96
+ // calculated elements are only searchable if requested through `@cds.search`
97
+ if(column.value) return false
98
+
95
99
  // if at least one element is explicitly annotated as searchable, e.g.:
96
100
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
97
101
  // and it is not the current column name, then it must be excluded from the search
@@ -101,32 +105,48 @@ const _getSearchableColumns = entity => {
101
105
  // if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
102
106
  return (
103
107
  annotatedColumnValue === undefined &&
104
- column._type === DEFAULT_SEARCHABLE_TYPE &&
108
+ element?.type === DEFAULT_SEARCHABLE_TYPE &&
105
109
  !_isColumnCalculated(entity?.query, column.name)
106
110
  )
107
111
  })
108
112
 
109
- // if the @cds.search annotation is provided -->
110
- // Early return to ignore the interpretation of the @Search.defaultSearchElement
111
- // annotation when an entity is annotated with the @cds.search annotation.
112
- // The @cds.search annotation overrules the @Search.defaultSearchElement annotation.
113
- if (cdsSearchKeys.length > 0) {
114
- return searchableColumns.map(column => column.name)
113
+ if (deepSearchCandidates.length) {
114
+ deepSearchCandidates.forEach(c => {
115
+ const element = c.ref.reduce((resolveIn, curr, i) => {
116
+ const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
117
+ if (next.isAssociation && !c.ref[i + 1]) {
118
+ const searchInTarget = _getSearchableColumns(next._target)
119
+ searchInTarget.forEach(elementRefInTarget => {
120
+ searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
121
+ })
122
+ }
123
+ return next
124
+ }, entity)
125
+ if (element?.type === DEFAULT_SEARCHABLE_TYPE) {
126
+ searchableColumns.push({ ref: c.ref })
127
+ }
128
+ })
115
129
  }
116
130
 
117
- return searchableColumns.map(column => column.name)
131
+ return searchableColumns.map(column => {
132
+ if(column.ref)
133
+ return column
134
+ return { ref: [ column.name ] }
135
+ })
118
136
  }
119
137
 
120
138
  /**
121
139
  * @returns {Array<object>} - array of columns
122
140
  */
123
- const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, alias) => {
141
+ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }) => {
124
142
  let toBeSearched = []
125
143
 
126
144
  // aggregations case
127
145
  // in the new parser groupBy is moved to sub select.
128
146
  if (cqn._aggregated || /* new parser */ cqn.SELECT.groupBy || cqn.SELECT?.from?.SELECT?.groupBy) {
129
147
  cqn.SELECT.columns?.forEach(column => {
148
+ const elementName = column.as || column.ref?.at(-1) || column.name
149
+ const element = cqn.elements[elementName]
130
150
  if (column.func || column.xpr) {
131
151
  // exclude $count by SELECT of number of Items in a Collection
132
152
  if (
@@ -138,7 +158,7 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
138
158
  }
139
159
 
140
160
  // only strings can be searched
141
- if (column.element.type !== DEFAULT_SEARCHABLE_TYPE) {
161
+ if (element?.type !== DEFAULT_SEARCHABLE_TYPE) {
142
162
  if (column.xpr) return
143
163
  if (column.func && !(column.func in aggregateFunctions)) return
144
164
  }
@@ -156,18 +176,22 @@ const computeColumnsToBeSearched = (cqn, entity = { __searchableColumns: [] }, a
156
176
 
157
177
  // no need to set ref[0] to alias, because columns were already properly transformed
158
178
  if (column.ref) {
159
- if (column.element.type !== DEFAULT_SEARCHABLE_TYPE) return
179
+ if (element?.type !== DEFAULT_SEARCHABLE_TYPE) return
160
180
  column = { ref: [...column.ref] }
161
181
  toBeSearched.push(column)
162
182
  return
163
183
  }
164
184
  })
165
185
  } else {
166
- toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
167
- if (cqn.SELECT.groupBy) toBeSearched = toBeSearched.filter(tbs => cqn.SELECT.groupBy.some(gb => gb.ref[0] === tbs))
186
+ if(entity.kind === 'entity') {
187
+ // first check cache
188
+ toBeSearched = entity.own('__searchableColumns') || entity.set('__searchableColumns', _getSearchableColumns(entity))
189
+ } else {
190
+ // if we search on a subquery, we don't have a cache
191
+ toBeSearched = _getSearchableColumns(entity)
192
+ }
168
193
  toBeSearched = toBeSearched.map(c => {
169
- const column = { ref: [c] }
170
- if (alias) column.ref.unshift(alias)
194
+ const column = {ref: [...c.ref]}
171
195
  return column
172
196
  })
173
197
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.10.3",
3
+ "version": "1.12.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": {
@@ -24,6 +24,9 @@
24
24
  "scripts": {
25
25
  "test": "jest --silent"
26
26
  },
27
+ "dependencies": {
28
+ "generic-pool": "^3.9.0"
29
+ },
27
30
  "peerDependencies": {
28
31
  "@sap/cds": ">=7.9"
29
32
  },