@cap-js/db-service 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@
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
+ ## [2.5.1](https://github.com/cap-js/cds-dbs/compare/db-service-v2.5.0...db-service-v2.5.1) (2025-09-30)
8
+
9
+
10
+ ### Fixed
11
+
12
+ * revert own resolve ([#1366](https://github.com/cap-js/cds-dbs/issues/1366)) ([9037570](https://github.com/cap-js/cds-dbs/commit/9037570c5dda08eb8bc168c0a68045ef9fc85a9f))
13
+
14
+ ## [2.5.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.4.0...db-service-v2.5.0) (2025-09-30)
15
+
16
+
17
+ ### Added
18
+
19
+ * make hana server version accessible to sub classes ([#1263](https://github.com/cap-js/cds-dbs/issues/1263)) ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
20
+ * sets default to hana cloud if server version cannot be detected ([a3ccc3e](https://github.com/cap-js/cds-dbs/commit/a3ccc3ed2fd6a65f1fd5924756a4a7b965adf9a3))
21
+
22
+
23
+ ### Fixed
24
+
25
+ * **`@cds.search`:** no duplicates for search along `to-many` paths ([#1341](https://github.com/cap-js/cds-dbs/issues/1341)) ([5c5f4fb](https://github.com/cap-js/cds-dbs/commit/5c5f4fbf790f718c3cf1bcb6f3bf7be421be598f))
26
+ * associations in `[@cds](https://github.com/cds).search` are additive ([#1355](https://github.com/cap-js/cds-dbs/issues/1355)) ([ea931cb](https://github.com/cap-js/cds-dbs/commit/ea931cb120c2857aa18a4eb68b893926c0999a9f))
27
+ * set proper element link for path into fk ([#1344](https://github.com/cap-js/cds-dbs/issues/1344)) ([9f365d3](https://github.com/cap-js/cds-dbs/commit/9f365d35ac614969d8fd2c2a9a1a2e0cd643969d))
28
+
7
29
  ## [2.4.0](https://github.com/cap-js/cds-dbs/compare/db-service-v2.3.0...db-service-v2.4.0) (2025-08-27)
8
30
 
9
31
 
@@ -21,7 +21,7 @@ const StandardFunctions = {
21
21
  val = sub[2] || sub[3] || ''
22
22
  }
23
23
  arg.val = val
24
- const refs = ref.list
24
+ const refs = ref.list || [ref]
25
25
  return `(${refs.map(ref => this.expr({
26
26
  func: 'contains',
27
27
  args: [
package/lib/cqn2sql.js CHANGED
@@ -17,6 +17,7 @@ class CQN2SQLRenderer {
17
17
  * @param {import('@sap/cds/apis/services').ContextProperties} context the cds.context of the request
18
18
  */
19
19
  constructor(srv) {
20
+ this.srv = srv
20
21
  this.context = srv?.context || cds.context // Using srv.context is required due to stakeholders doing unmanaged txs without cds.context being set
21
22
  this.class = new.target // for IntelliSense
22
23
  this.class._init() // is a noop for subsequent calls
package/lib/cqn4sql.js CHANGED
@@ -61,7 +61,7 @@ function cqn4sql(originalQuery, model) {
61
61
  if (!hasCustomJoins && inferred.SELECT?.search) {
62
62
  // we need an instance of query because the elements of the query are needed for the calculation of the search columns
63
63
  if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
64
- const searchTerm = getSearchTerm(inferred.SELECT.search, inferred)
64
+ const searchTerm = getSearch(inferred.SELECT.search, inferred)
65
65
  if (searchTerm) {
66
66
  // Search target can be a navigation, in that case use _target to get the correct entity
67
67
  const { where, having } = transformSearch(searchTerm)
@@ -123,14 +123,9 @@ function cqn4sql(originalQuery, model) {
123
123
  // calculate the primary keys of the target entity, there is always exactly
124
124
  // one query source for UPDATE / DELETE
125
125
  const queryTarget = Object.values(inferred.sources)[0].definition
126
- const primaryKey = { list: [] }
127
- for (const k of Object.keys(queryTarget.elements)) {
128
- const e = queryTarget.elements[k]
129
- if (e.key === true && !e.virtual && e.isAssociation !== true) {
130
- subquery.SELECT.columns.push({ ref: [e.name] })
131
- primaryKey.list.push({ ref: [transformedFrom.as, e.name] })
132
- }
133
- }
126
+ const primaryKey = { list: getPrimaryKey(queryTarget, uniqueSubqueryAlias) }
127
+ // match primary keys of the target entity with the subquery
128
+ primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
134
129
 
135
130
  const transformedSubquery = cqn4sql(subquery, model)
136
131
 
@@ -258,7 +253,7 @@ function cqn4sql(originalQuery, model) {
258
253
  if (inferred.SELECT[prop]) {
259
254
  return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
260
255
  } else {
261
- return { [prop]: [searchTerm] }
256
+ return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
262
257
  }
263
258
  }
264
259
 
@@ -1211,7 +1206,7 @@ function cqn4sql(originalQuery, model) {
1211
1206
  if(column.element && !isAssocOrStruct(column.element)) {
1212
1207
  columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1213
1208
  const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1214
- setElementOnColumns(res, element)
1209
+ setElementOnColumns(res, column.element)
1215
1210
  return [res]
1216
1211
  }
1217
1212
 
@@ -1789,7 +1784,7 @@ function cqn4sql(originalQuery, model) {
1789
1784
  filterConditions.forEach(f => {
1790
1785
  transformedWhere.push('and')
1791
1786
  if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
1792
- else if (f.length > 3 || f.includes('or') || f.includes('and')) transformedWhere.push(asXpr(f))
1787
+ else if (f.length > 3 || f.includes('or') || f.includes('and') || f.includes('in')) transformedWhere.push(asXpr(f))
1793
1788
  else transformedWhere.push(...f)
1794
1789
  })
1795
1790
  } else {
@@ -2221,30 +2216,41 @@ function cqn4sql(originalQuery, model) {
2221
2216
  return SELECT
2222
2217
  }
2223
2218
 
2224
- /**
2225
- * For a given search expression return a function "search" which holds the search expression
2226
- * as well as the searchable columns as arguments.
2219
+ /**
2220
+ * For a given search term calculate a search expression which can be used in a where clause.
2221
+ * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2222
+ * the search results of the subquery.
2227
2223
  *
2228
- * @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
2224
+ * @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source.
2229
2225
  * @param {object} query - The FROM clause of the CQN statement.
2230
2226
  *
2231
2227
  * @returns {(Object|null)} returns either:
2228
+ * - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
2232
2229
  * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2233
2230
  * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2234
2231
  */
2235
- function getSearchTerm(search, query) {
2232
+ function getSearch(searchTerm, query) {
2236
2233
  const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
2237
2234
  const searchIn = computeColumnsToBeSearched(inferred, entity)
2238
- if (searchIn.length > 0) {
2239
- const xpr = search
2240
- const searchFunc = {
2241
- func: 'search',
2242
- args: [{ list: searchIn }, xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr }],
2243
- }
2244
- return searchFunc
2245
- } else {
2246
- return null
2235
+ if (searchIn.length === 0) return null
2236
+
2237
+ const searchFunc = {
2238
+ func: 'search',
2239
+ args: [
2240
+ searchIn.length === 1 ? searchIn[0] : { list: searchIn },
2241
+ searchTerm.length === 1 && 'val' in searchTerm[0] ? searchTerm[0] : { xpr: searchTerm },
2242
+ ],
2247
2243
  }
2244
+ // for aggregated queries / search on subqueries we do not do a subquery search
2245
+ if (inferred.SELECT.groupBy || entity.SELECT)
2246
+ return searchFunc
2247
+
2248
+ const matchColumns = getPrimaryKey(entity)
2249
+ if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2250
+ return searchFunc
2251
+
2252
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2253
+ return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
2248
2254
  }
2249
2255
 
2250
2256
  /**
@@ -2410,6 +2416,17 @@ function setElementOnColumns(col, element) {
2410
2416
  defineProperty(col, 'element', element)
2411
2417
  }
2412
2418
 
2419
+ function getPrimaryKey(entity, tableAlias = null) {
2420
+ const primaryKey = []
2421
+ for (const k of Object.keys(entity.elements)) {
2422
+ const e = entity.elements[k]
2423
+ if (e.key === true && !e.virtual && e.isAssociation !== true) {
2424
+ primaryKey.push({ ref: tableAlias ? [tableAlias, e.name] : [e.name] })
2425
+ }
2426
+ }
2427
+ return primaryKey
2428
+ }
2429
+
2413
2430
  const getName = col => col.as || col.ref?.at(-1)
2414
2431
  const idOnly = ref => ref.id || ref
2415
2432
  const refWithConditions = step => {
package/lib/search.js CHANGED
@@ -66,7 +66,7 @@ const _getSearchableColumns = entity => {
66
66
  if (key.startsWith(cdsSearchTerm)) cdsSearchKeys.push(key)
67
67
  }
68
68
 
69
- let atLeastOneColumnIsSearchable = false
69
+ let skipDefaultSearchableElements = false
70
70
  const deepSearchCandidates = []
71
71
 
72
72
  // build a map of columns annotated with the @cds.search annotation
@@ -74,13 +74,18 @@ const _getSearchableColumns = entity => {
74
74
  const columnName = key.split(cdsSearchTerm + '.').pop()
75
75
  const annotationKey = `${cdsSearchTerm}.${columnName}`
76
76
  const annotationValue = entity[annotationKey]
77
- if (annotationValue) atLeastOneColumnIsSearchable = true
78
77
 
79
78
  const column = entity.elements[columnName]
79
+ // always ignore virtual elements from search
80
+ if(column?.virtual) continue
80
81
  if (column?.isAssociation || columnName.includes('.')) {
81
- deepSearchCandidates.push({ ref: columnName.split('.') })
82
+ const ref = columnName.split('.')
83
+ if(ref.length > 1) skipDefaultSearchableElements = true
84
+ deepSearchCandidates.push({ ref })
82
85
  continue
83
86
  }
87
+
88
+ if(annotationValue) skipDefaultSearchableElements = true
84
89
  cdsSearchColumnMap.set(columnName, annotationValue)
85
90
  }
86
91
 
@@ -99,7 +104,7 @@ const _getSearchableColumns = entity => {
99
104
  // if at least one element is explicitly annotated as searchable, e.g.:
100
105
  // `@cds.search { element1: true }` or `@cds.search { element1 }`
101
106
  // and it is not the current column name, then it must be excluded from the search
102
- if (atLeastOneColumnIsSearchable) return false
107
+ if (skipDefaultSearchableElements) return false
103
108
 
104
109
  // the element is considered searchable if it is explicitly annotated as such or
105
110
  // if it is not annotated and the column is typed as a string (excluding elements/elements expressions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
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": {