@cap-js/db-service 1.10.2 → 1.11.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,29 @@
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.11.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.3...db-service-v1.11.0) (2024-07-08)
8
+
9
+
10
+ ### Added
11
+
12
+ * **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))
13
+
14
+ ### Changed
15
+
16
+ * `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))
17
+
18
+ ### Fixed
19
+
20
+ * 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))
21
+
22
+ ## [1.10.3](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.2...db-service-v1.10.3) (2024-07-05)
23
+
24
+
25
+ ### Fixed
26
+
27
+ * rewrite assoc chains if intermediate assoc is not fk ([#715](https://github.com/cap-js/cds-dbs/issues/715)) ([3873f9a](https://github.com/cap-js/cds-dbs/commit/3873f9adce3ff26cafb2b18b9d2115758b0f0830))
28
+ * Support expand with group by clause ([#721](https://github.com/cap-js/cds-dbs/issues/721)) ([90c9e6a](https://github.com/cap-js/cds-dbs/commit/90c9e6a4da9d4a3451ec0ed60dd0815c04600134))
29
+
7
30
  ## [1.10.2](https://github.com/cap-js/cds-dbs/compare/db-service-v1.10.1...db-service-v1.10.2) (2024-06-25)
8
31
 
9
32
 
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
  }
@@ -22,6 +22,8 @@ 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
+ arg.val = arg.__proto__.val = arg.val.split(' ')[0].replace(/"/g, '')
25
27
  const refs = ref.list || [ref],
26
28
  { toString } = ref
27
29
  return '(' + refs.map(ref2 => this.contains(this.tolower(toString(ref2)), this.tolower(arg))).join(' or ') + ')'
package/lib/cqn2sql.js CHANGED
@@ -222,6 +222,7 @@ class CQN2SQLRenderer {
222
222
  if (distinct) sql += ` DISTINCT`
223
223
  if (!_empty(columns)) sql += ` ${columns}`
224
224
  if (!_empty(from)) sql += ` FROM ${this.from(from)}`
225
+ else sql += this.from_dummy()
225
226
  if (!_empty(where)) sql += ` WHERE ${this.where(where)}`
226
227
  if (!_empty(groupBy)) sql += ` GROUP BY ${this.groupBy(groupBy)}`
227
228
  if (!_empty(having)) sql += ` HAVING ${this.having(having)}`
@@ -310,12 +311,7 @@ class CQN2SQLRenderer {
310
311
  */
311
312
  column_expr(x, q) {
312
313
  if (x === '*') return '*'
313
- ///////////////////////////////////////////////////////////////////////////////////////
314
- // REVISIT: that should move out of here!
315
- if (x?.element?.['@cds.extension']) {
316
- return `extensions__->${this.string('$."' + x.element.name + '"')} as ${x.as || x.element.name}`
317
- }
318
- ///////////////////////////////////////////////////////////////////////////////////////
314
+
319
315
  let sql = this.expr({ param: false, __proto__: x })
320
316
  let alias = this.column_alias4(x, q)
321
317
  if (alias) sql += ' as ' + this.quote(alias)
@@ -351,6 +347,14 @@ class CQN2SQLRenderer {
351
347
  return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
352
348
  }
353
349
 
350
+ /**
351
+ * Renders a FROM clause for when the query does not have a target
352
+ * @returns {string} SQL
353
+ */
354
+ from_dummy() {
355
+ return ''
356
+ }
357
+
354
358
  /**
355
359
  * Renders a FROM clause into generic SQL
356
360
  * @param {import('./infer/cqn').ref['ref'][0]['args']} args
@@ -480,7 +484,7 @@ class CQN2SQLRenderer {
480
484
  : ObjectKeys(INSERT.entries[0])
481
485
 
482
486
  /** @type {string[]} */
483
- this.columns = columns.filter(elements ? c => !elements[c]?.['@cds.extension'] : () => true)
487
+ this.columns = columns
484
488
 
485
489
  if (!elements) {
486
490
  this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
@@ -493,24 +497,7 @@ class CQN2SQLRenderer {
493
497
  elements,
494
498
  !!q.UPSERT,
495
499
  )
496
- const extraction = extractions
497
- .map(c => {
498
- const element = elements?.[c.name]
499
- if (element?.['@cds.extension']) {
500
- return false
501
- }
502
- if (c.name === 'extensions__') {
503
- const merges = extractions.filter(c => elements?.[c.name]?.['@cds.extension'])
504
- if (merges.length) {
505
- c.sql = `json_set(ifnull(${c.sql},'{}'),${merges.map(
506
- c => this.string('$."' + c.name + '"') + ',' + c.sql,
507
- )})`
508
- }
509
- }
510
- return c
511
- })
512
- .filter(a => a)
513
- .map(c => c.sql)
500
+ const extraction = extractions.map(c => c.sql)
514
501
 
515
502
  // Include this.values for placeholders
516
503
  /** @type {unknown[][]} */
@@ -783,14 +770,6 @@ class CQN2SQLRenderer {
783
770
  }
784
771
  }
785
772
 
786
- columns = columns.map(c => {
787
- if (q.elements?.[c.name]?.['@cds.extension']) return {
788
- name: 'extensions__',
789
- sql: `jsonb_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
790
- }
791
- return c
792
- })
793
-
794
773
  const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
795
774
 
796
775
  sql += ` SET ${extraction}`
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
 
@@ -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) {
@@ -799,9 +793,20 @@ function cqn4sql(originalQuery, model) {
799
793
  ? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
800
794
  : column.ref.map(idOnly).join('_'))
801
795
 
796
+ // if there is a group by on the main query, all
797
+ // columns of the expand must be in the groupBy
798
+ if (transformedQuery.SELECT.groupBy) {
799
+ const baseRef =
800
+ column.$refLinks[0].definition.SELECT || column.$refLinks[0].definition.kind === 'entity'
801
+ ? column.ref.slice(1)
802
+ : column.ref
803
+
804
+ return _subqueryForGroupBy(column, baseRef, columnAlias)
805
+ }
806
+
802
807
  // we need to respect the aliases of the outer query, so the columnAlias might not be suitable
803
808
  // as table alias for the correlated subquery
804
- const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, originalQuery.outerQueries)
809
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, inferred.outerQueries)
805
810
 
806
811
  // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
807
812
  const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
@@ -815,12 +820,15 @@ function cqn4sql(originalQuery, model) {
815
820
  from,
816
821
  columns: JSON.parse(JSON.stringify(column.expand)),
817
822
  expand: true,
818
- one: column.$refLinks[column.$refLinks.length - 1].definition.is2one,
823
+ one: column.$refLinks.at(-1).definition.is2one,
819
824
  },
820
825
  }
821
826
  const expanded = transformSubquery(subquery)
822
827
  const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
823
- Object.defineProperty(correlated, 'elements', { value: subquery.elements, writable: true })
828
+ Object.defineProperty(correlated, 'elements', {
829
+ value: expanded.elements,
830
+ writable: true,
831
+ })
824
832
  return correlated
825
833
 
826
834
  function _correlate(subq, outer) {
@@ -851,6 +859,72 @@ function cqn4sql(originalQuery, model) {
851
859
  }
852
860
  return subq
853
861
  }
862
+
863
+ /**
864
+ * Generates a special subquery for the `expand` of the `column`.
865
+ * All columns in the `expand` must be part of the GROUP BY clause of the main query.
866
+ * If this is the case, the subqueries columns match the corresponding references of the group by.
867
+ * Nested expands are also supported.
868
+ *
869
+ * @param {Object} column - To expand.
870
+ * @param {Array} baseRef - The base reference for the expanded column.
871
+ * @param {string} subqueryAlias - The alias of the `expand` subquery column.
872
+ * @returns {Object} - The subquery object.
873
+ * @throws {Error} - If one of the `ref`s in the `column.expand` is not part of the GROUP BY clause.
874
+ */
875
+ function _subqueryForGroupBy(column, baseRef, subqueryAlias) {
876
+ const groupByLookup = new Map(
877
+ transformedQuery.SELECT.groupBy.map(c => [c.ref && c.ref.map(refWithConditions).join('.'), c]),
878
+ )
879
+
880
+ // to be attached to dummy query
881
+ const elements = {}
882
+ const expandedColumns = column.expand.flatMap(expand => {
883
+ const fullRef = [...baseRef, ...expand.ref]
884
+
885
+ if (expand.expand) {
886
+ const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
887
+ elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
888
+ return nested
889
+ }
890
+
891
+ const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
892
+ if (!groupByRef) {
893
+ throw new Error(
894
+ `The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
895
+ )
896
+ }
897
+
898
+ const columnCopy = Object.create(groupByRef)
899
+ if (expand.as) {
900
+ columnCopy.as = expand.as
901
+ }
902
+ const res = getFlatColumnsFor(columnCopy, { tableAlias: getQuerySourceName(columnCopy) })
903
+ res.forEach(c => {
904
+ elements[c.as || c.ref.at(-1)] = c.element
905
+ })
906
+ return res
907
+ })
908
+
909
+ const SELECT = {
910
+ from: null,
911
+ columns: expandedColumns,
912
+ }
913
+ return Object.defineProperties(
914
+ {},
915
+ {
916
+ SELECT: {
917
+ value: Object.defineProperties(SELECT, {
918
+ expand: { value: true, writable: true }, // non-enumerable
919
+ one: { value: column.$refLinks.at(-1).definition.is2one, writable: true }, // non-enumerable
920
+ }),
921
+ enumerable: true,
922
+ },
923
+ as: { value: subqueryAlias, enumerable: true, writable: true },
924
+ elements: { value: elements }, // non-enumerable
925
+ },
926
+ )
927
+ }
854
928
  }
855
929
 
856
930
  function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
@@ -860,15 +934,6 @@ function cqn4sql(originalQuery, model) {
860
934
  if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
861
935
  const calcElement = resolveCalculatedElement(col, true)
862
936
  res.push(calcElement)
863
- } else if (col.isJoinRelevant) {
864
- const tableAlias = getQuerySourceName(col)
865
- const name = calculateElementName(col)
866
- const transformedColumn = {
867
- ref: [tableAlias, name],
868
- }
869
- if (col.sort) transformedColumn.sort = col.sort
870
- if (col.nulls) transformedColumn.nulls = col.nulls
871
- res.push(transformedColumn)
872
937
  } else if (pseudos.elements[col.ref?.[0]]) {
873
938
  res.push({ ...col })
874
939
  } else if (col.ref) {
@@ -926,8 +991,14 @@ function cqn4sql(originalQuery, model) {
926
991
  */
927
992
  if (inOrderBy && flatColumns.length > 1)
928
993
  throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
929
- if (col.nulls) flatColumns[0].nulls = col.nulls
930
- if (col.sort) flatColumns[0].sort = col.sort
994
+ flatColumns.forEach(fc => {
995
+ if (col.nulls)
996
+ fc.nulls = col.nulls
997
+ if (col.sort)
998
+ fc.sort = col.sort
999
+ if (fc.as)
1000
+ delete fc.as
1001
+ })
931
1002
  res.push(...flatColumns)
932
1003
  } else {
933
1004
  let transformedColumn
@@ -973,7 +1044,7 @@ function cqn4sql(originalQuery, model) {
973
1044
  const last = q.SELECT.from.ref.at(-1)
974
1045
  const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
975
1046
  getLastStringSegment(last.id || last),
976
- originalQuery.outerQueries,
1047
+ inferred.outerQueries,
977
1048
  )
978
1049
  Object.defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
979
1050
  }
@@ -1573,7 +1644,7 @@ function cqn4sql(originalQuery, model) {
1573
1644
  transformedFrom.args.push(f)
1574
1645
  }
1575
1646
  })
1576
- return { transformedFrom }
1647
+ return { transformedFrom, transformedWhere: existingWhere }
1577
1648
  } else if (from.SELECT) {
1578
1649
  transformedFrom = transformSubquery(from)
1579
1650
  if (from.as) {
@@ -1581,9 +1652,9 @@ function cqn4sql(originalQuery, model) {
1581
1652
  transformedFrom.as = from.as
1582
1653
  } else {
1583
1654
  // select from anonymous query, use artificial alias
1584
- transformedFrom.as = Object.keys(originalQuery.sources)[0]
1655
+ transformedFrom.as = Object.keys(inferred.sources)[0]
1585
1656
  }
1586
- return { transformedFrom }
1657
+ return { transformedFrom, transformedWhere: existingWhere }
1587
1658
  } else {
1588
1659
  return _transformFrom()
1589
1660
  }
@@ -1624,7 +1695,7 @@ function cqn4sql(originalQuery, model) {
1624
1695
  * --> This is an artificial query, which will later be correlated
1625
1696
  * with the main query alias. see @function expandColumn()
1626
1697
  */
1627
- if (!(originalQuery.SELECT?.expand === true)) {
1698
+ if (!(inferred.SELECT?.expand === true)) {
1628
1699
  as = getNextAvailableTableAlias(as)
1629
1700
  }
1630
1701
  nextStepLink.alias = as
@@ -2084,6 +2155,35 @@ function cqn4sql(originalQuery, model) {
2084
2155
  return getDefinition(assoc.target) || null
2085
2156
  }
2086
2157
 
2158
+ /**
2159
+ * For a given search expression return a function "search" which holds the search expression
2160
+ * as well as the searchable columns as arguments.
2161
+ *
2162
+ * @param {object} search - The search expression which shall be applied to the searchable columns on the query source.
2163
+ * @param {object} query - The FROM clause of the CQN statement.
2164
+ *
2165
+ * @returns {(Object|null)} returns either:
2166
+ * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2167
+ * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2168
+ */
2169
+ function getSearchTerm(search, query) {
2170
+ const entity = query.SELECT.from.SELECT ? query.SELECT.from : query.target
2171
+ const searchIn = computeColumnsToBeSearched(inferred, entity)
2172
+ if (searchIn.length > 0) {
2173
+ const xpr = search
2174
+ const searchFunc = {
2175
+ func: 'search',
2176
+ args: [
2177
+ searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
2178
+ xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
2179
+ ],
2180
+ }
2181
+ return searchFunc
2182
+ } else {
2183
+ return null
2184
+ }
2185
+ }
2186
+
2087
2187
  /**
2088
2188
  * Calculates the name of the source which can be used to address the given node.
2089
2189
  *
@@ -2129,11 +2229,11 @@ function cqn4sql(originalQuery, model) {
2129
2229
  * - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
2130
2230
  */
2131
2231
  if (
2132
- originalQuery.SELECT?.from.uniqueSubqueryAlias &&
2133
- !originalQuery.SELECT?.from.as &&
2232
+ inferred.SELECT?.from.uniqueSubqueryAlias &&
2233
+ !inferred.SELECT?.from.as &&
2134
2234
  firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
2135
2235
  ) {
2136
- return originalQuery.SELECT?.from.uniqueSubqueryAlias
2236
+ return inferred.SELECT?.from.uniqueSubqueryAlias
2137
2237
  }
2138
2238
  return node.ref[0]
2139
2239
  }
@@ -2251,4 +2351,12 @@ function setElementOnColumns(col, element) {
2251
2351
 
2252
2352
  const getName = col => col.as || col.ref?.at(-1)
2253
2353
  const idOnly = ref => ref.id || ref
2354
+ const refWithConditions = step => {
2355
+ let appendix
2356
+ const { args, where } = step
2357
+ if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
2358
+ else if (where) appendix = JSON.stringify(where)
2359
+ else if (args) appendix = JSON.stringify(args)
2360
+ return appendix ? step.id + appendix : step
2361
+ }
2254
2362
  const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
@@ -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) )
@@ -1013,9 +1013,11 @@ function infer(originalQuery, model) {
1013
1013
  }
1014
1014
  return true
1015
1015
  }
1016
- if (assoc && assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) {
1016
+ if (assoc) {
1017
1017
  // foreign key access without filters never join relevant
1018
- return false
1018
+ if (assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) return false
1019
+ // <assoc>.<anotherAssoc>.<…> is join relevant as <anotherAssoc> is not fk of <assoc>
1020
+ return true
1019
1021
  }
1020
1022
  if (link.definition.target && link.definition.keys) {
1021
1023
  if (column.ref[i + 1] || assoc) fkAccess = false
@@ -1198,6 +1200,8 @@ function infer(originalQuery, model) {
1198
1200
  firstStepIsEntity = true
1199
1201
  if (arg.$refLinks.length === 1) return `${cur.definition.name}`
1200
1202
  return `${cur.definition.name}`
1203
+ } else if (cur.definition.SELECT) {
1204
+ return `${cur.definition.as}`
1201
1205
  }
1202
1206
  const dot = i === 1 && firstStepIsEntity ? ':' : '.' // divide with colon if first step is entity
1203
1207
  return res !== '' ? res + dot + cur.definition.name : cur.definition.name
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.2",
3
+ "version": "1.11.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": {