@cap-js/db-service 1.14.1 → 1.15.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,27 @@
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.15.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.15.0...db-service-v1.15.1) (2024-11-18)
8
+
9
+
10
+ ### Fixed
11
+
12
+ * cross joins without on-condition ([#899](https://github.com/cap-js/cds-dbs/issues/899)) ([c61a04a](https://github.com/cap-js/cds-dbs/commit/c61a04aa4394511100f97cfebd362a2298221d96))
13
+ * pseudo paths in expands ([#896](https://github.com/cap-js/cds-dbs/issues/896)) ([014c50c](https://github.com/cap-js/cds-dbs/commit/014c50cec9c2de1ee3dfdf1861940ae0e2520c16))
14
+
15
+ ## [1.15.0](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.1...db-service-v1.15.0) (2024-11-14)
16
+
17
+
18
+ ### Added
19
+
20
+ * enable path expressions in infix filter after `exists` predicate ([#875](https://github.com/cap-js/cds-dbs/issues/875)) ([7e50359](https://github.com/cap-js/cds-dbs/commit/7e5035932ac3bf39f052aa67e1565567e9d6b1ad))
21
+
22
+
23
+ ### Fixed
24
+
25
+ * **`search`:** ignore invalid path expressions inside `@cds.search` ([#849](https://github.com/cap-js/cds-dbs/issues/849)) ([250edd5](https://github.com/cap-js/cds-dbs/commit/250edd5ec9f7ba1d8e40e1330e4b4f9ad9e599b0))
26
+ * nested exists wrapped in xpr ([7e50359](https://github.com/cap-js/cds-dbs/commit/7e5035932ac3bf39f052aa67e1565567e9d6b1ad))
27
+
7
28
  ## [1.14.1](https://github.com/cap-js/cds-dbs/compare/db-service-v1.14.0...db-service-v1.14.1) (2024-10-28)
8
29
 
9
30
 
package/lib/cqn2sql.js CHANGED
@@ -363,8 +363,7 @@ class CQN2SQLRenderer {
363
363
  return _aliased(this.quote(this.name(z)))
364
364
  }
365
365
  if (from.SELECT) return _aliased(`(${this.SELECT(from)})`)
366
- if (from.join)
367
- return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])} ON ${this.where(from.on)}`
366
+ if (from.join) return `${this.from(from.args[0])} ${from.join} JOIN ${this.from(from.args[1])}${from.on ? ` ON ${this.where(from.on)}` : ''}`
368
367
  }
369
368
 
370
369
  /**
package/lib/cqn4sql.js CHANGED
@@ -266,10 +266,10 @@ function cqn4sql(originalQuery, model) {
266
266
  const id = localized(r.queryArtifact)
267
267
  args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
268
268
  }
269
- from = { join: 'left', args, on: [] }
269
+ from = { join: r.join || 'left', args, on: [] }
270
270
  r.children.forEach(c => {
271
271
  from = joinForBranch(from, c)
272
- from = { join: 'left', args: [from], on: [] }
272
+ from = { join: c.join || 'left', args: [from], on: [] }
273
273
  })
274
274
  })
275
275
  return from.args.length > 1 ? from : from.args[0]
@@ -309,7 +309,7 @@ function cqn4sql(originalQuery, model) {
309
309
  }
310
310
  if (node.children) {
311
311
  node.children.forEach(c => {
312
- lhs = { join: 'left', args: [lhs], on: [] }
312
+ lhs = { join: c.join || 'left', args: [lhs], on: [] }
313
313
  lhs = joinForBranch(lhs, c)
314
314
  })
315
315
  }
@@ -1868,13 +1868,18 @@ function cqn4sql(originalQuery, model) {
1868
1868
  value: [],
1869
1869
  writable: true,
1870
1870
  })
1871
+ let pseudoPath = false
1871
1872
  ref.reduce((prev, res, i) => {
1872
1873
  if (res === '$self')
1873
1874
  // next is resolvable in entity
1874
1875
  return prev
1875
1876
  if (res in pseudos.elements) {
1877
+ pseudoPath = true
1876
1878
  thing.$refLinks.push({ definition: pseudos.elements[res], target: pseudos })
1877
1879
  return pseudos.elements[res]
1880
+ } else if (pseudoPath) {
1881
+ thing.$refLinks.push({ definition: {}, target: pseudos })
1882
+ return prev?.elements[res]
1878
1883
  }
1879
1884
  const definition =
1880
1885
  prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
@@ -2093,11 +2098,6 @@ function cqn4sql(originalQuery, model) {
2093
2098
  const unmanagedOn = onCondFor(inWhere ? next : current, inWhere ? current : next, inWhere)
2094
2099
  on.push(...(customWhere && hasLogicalOr(unmanagedOn) ? [asXpr(unmanagedOn)] : unmanagedOn))
2095
2100
  }
2096
- // infix filter conditions are wrapped in `xpr` when added to the on-condition
2097
- if (customWhere) {
2098
- const filter = getTransformedTokenStream(customWhere, next)
2099
- on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2100
- }
2101
2101
 
2102
2102
  const subquerySource = assocTarget(nextDefinition) || nextDefinition
2103
2103
  const id = localized(subquerySource)
@@ -2115,6 +2115,26 @@ function cqn4sql(originalQuery, model) {
2115
2115
  ],
2116
2116
  where: on,
2117
2117
  }
2118
+ if (next.pathExpressionInsideFilter) {
2119
+ SELECT.where = customWhere
2120
+ const transformedExists = transformSubquery({ SELECT })
2121
+ // infix filter conditions are wrapped in `xpr` when added to the on-condition
2122
+ if (transformedExists.SELECT.where) {
2123
+ on.push(
2124
+ ...[
2125
+ 'and',
2126
+ ...(hasLogicalOr(transformedExists.SELECT.where)
2127
+ ? [asXpr(transformedExists.SELECT.where)]
2128
+ : transformedExists.SELECT.where),
2129
+ ],
2130
+ )
2131
+ }
2132
+ transformedExists.SELECT.where = on
2133
+ return transformedExists.SELECT
2134
+ } else if (customWhere) {
2135
+ const filter = getTransformedTokenStream(customWhere, next)
2136
+ on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
2137
+ }
2118
2138
  return SELECT
2119
2139
  }
2120
2140
 
@@ -172,28 +172,30 @@ function infer(originalQuery, model) {
172
172
  if (!ref) return
173
173
  init$refLinks(arg)
174
174
  let i = 0
175
+ let pseudoPath = false
175
176
  for (const step of ref) {
176
177
  const id = step.id || step
177
178
  if (i === 0) {
178
- // infix filter never have table alias
179
- // we need to search for first step in ´model.definitions[infixAlias]`
180
- if ($baseLink) {
179
+ if (id in pseudos.elements) {
180
+ // pseudo path
181
+ arg.$refLinks.push({ definition: pseudos.elements[id], target: pseudos })
182
+ pseudoPath = true // only first path step must be well defined
183
+ } else if ($baseLink) {
184
+ // infix filter never have table alias
185
+ // we need to search for first step in ´model.definitions[infixAlias]`
181
186
  const { definition } = $baseLink
182
187
  const elements = getDefinition(definition.target)?.elements || definition.elements
183
188
  const e = elements?.[id] || cds.error`"${id}" not found in the elements of "${definition.name}"`
184
189
  if (e.target) {
185
190
  // only fk access in infix filter
186
191
  const nextStep = ref[1]?.id || ref[1]
187
- // no unmanaged assoc in infix filter path
188
- if (!expandOrExists && e.on) {
189
- const err = `Unexpected unmanaged association “${e.name}” in filter expression of “${$baseLink.definition.name}”`
190
- throw new Error(err)
192
+ if (isNonForeignKeyNavigation(e, nextStep)) {
193
+ if (expandOrExists) {
194
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
195
+ } else {
196
+ rejectNonFkNavigation(e, e.on ? $baseLink.definition.name : nextStep)
197
+ }
191
198
  }
192
- // no non-fk traversal in infix filter
193
- if (!expandOrExists && nextStep && !isForeignKeyOf(nextStep, e))
194
- throw new Error(
195
- `Only foreign keys of “${e.name}” can be accessed in infix filter, but found “${nextStep}”`,
196
- )
197
199
  }
198
200
  arg.$refLinks.push({ definition: e, target: definition })
199
201
  // filter paths are flattened
@@ -204,11 +206,15 @@ function infer(originalQuery, model) {
204
206
  const definition = getDefinition(id) || cds.error`"${id}" not found in the definitions of your model`
205
207
  arg.$refLinks[0] = { definition, target: definition }
206
208
  }
209
+ } else if (arg.ref[0] === '$user' && pseudoPath) {
210
+ // `$user.some.unknown.element` -> no error
211
+ arg.$refLinks.push({ definition: {}, target: pseudos })
207
212
  } else {
208
213
  const recent = arg.$refLinks[i - 1]
209
214
  const { elements } = getDefinition(recent.definition.target) || recent.definition
210
215
  const e = elements[id]
211
- if (!e) throw new Error(`"${id}" not found in the elements of "${arg.$refLinks[i - 1].definition.name}"`)
216
+ const notFoundIn = pseudoPath ? arg.ref[i - 1] : getFullPathForLinkedArg(arg)
217
+ if (!e) throw new Error(`"${id}" not found in the elements of "${notFoundIn}"`)
212
218
  arg.$refLinks.push({ definition: e, target: getDefinition(e.target) || e })
213
219
  }
214
220
  arg.$refLinks[i].alias = !ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
@@ -226,7 +232,7 @@ function infer(originalQuery, model) {
226
232
  // don't miss an exists within an expression
227
233
  token.xpr.forEach(walkTokenStream)
228
234
  } else {
229
- attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate)
235
+ attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate || expandOrExists)
230
236
  existsPredicate = false
231
237
  }
232
238
  }
@@ -235,6 +241,7 @@ function infer(originalQuery, model) {
235
241
  }
236
242
  i += 1
237
243
  }
244
+ if ($baseLink?.pathExpressionInsideFilter) Object.defineProperty(arg, 'join', { value: 'inner' })
238
245
  const { definition, target } = arg.$refLinks[arg.$refLinks.length - 1]
239
246
  if (definition.value) {
240
247
  // nested calculated element
@@ -542,9 +549,19 @@ function infer(originalQuery, model) {
542
549
  const elements = getDefinition(definition.target)?.elements || definition.elements
543
550
  if (elements && id in elements) {
544
551
  const element = elements[id]
545
- rejectNonFkAccess(element)
552
+ if (inInfixFilter) {
553
+ const nextStep = column.ref[1]?.id || column.ref[1]
554
+ if (isNonForeignKeyNavigation(element, nextStep)) {
555
+ if (inExists) {
556
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
557
+ } else {
558
+ rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
559
+ }
560
+ }
561
+ }
546
562
  const resolvableIn = getDefinition(definition.target) || target
547
- column.$refLinks.push({ definition: elements[id], target: resolvableIn })
563
+ const $refLink = { definition: elements[id], target: resolvableIn }
564
+ column.$refLinks.push($refLink)
548
565
  } else {
549
566
  stepNotFoundInPredecessor(id, definition.name)
550
567
  }
@@ -593,7 +610,16 @@ function infer(originalQuery, model) {
593
610
 
594
611
  const target = getDefinition(definition.target) || column.$refLinks[i - 1].target
595
612
  if (element) {
596
- if ($baseLink) rejectNonFkAccess(element)
613
+ if ($baseLink && inInfixFilter) {
614
+ const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
615
+ if (isNonForeignKeyNavigation(element, nextStep)) {
616
+ if (inExists) {
617
+ Object.defineProperty($baseLink, 'pathExpressionInsideFilter', { value: true })
618
+ } else {
619
+ rejectNonFkNavigation(element, element.on ? $baseLink.definition.name : nextStep)
620
+ }
621
+ }
622
+ }
597
623
  const $refLink = { definition: elements[id], target }
598
624
  column.$refLinks.push($refLink)
599
625
  } else if (firstStepIsSelf) {
@@ -637,7 +663,7 @@ function infer(originalQuery, model) {
637
663
  skipJoinsForFilter = true
638
664
  } else if (token.ref || token.xpr) {
639
665
  inferQueryElement(token, false, column.$refLinks[i], {
640
- inExists: skipJoinsForFilter,
666
+ inExists: skipJoinsForFilter || inExists,
641
667
  inExpr: !!token.xpr,
642
668
  inInfixFilter: true,
643
669
  })
@@ -646,7 +672,7 @@ function infer(originalQuery, model) {
646
672
  applyToFunctionArgs(token.args, inferQueryElement, [
647
673
  false,
648
674
  column.$refLinks[i],
649
- { inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: true },
675
+ { inExists: skipJoinsForFilter || inExists, inExpr: true, inInfixFilter: true },
650
676
  ])
651
677
  }
652
678
  }
@@ -700,31 +726,11 @@ function infer(originalQuery, model) {
700
726
  }
701
727
  }
702
728
  }
703
-
704
- /**
705
- * Check if the next step in the ref is foreign key of `assoc`
706
- * if not, an error is thrown.
707
- *
708
- * @param {CSN.Element} assoc if this is an association, the next step must be a foreign key of the element.
709
- */
710
- function rejectNonFkAccess(assoc) {
711
- if (inInfixFilter && assoc.target) {
712
- // only fk access in infix filter
713
- const nextStep = column.ref[i + 1]?.id || column.ref[i + 1]
714
- // no unmanaged assoc in infix filter path
715
- if (!inExists && assoc.on) {
716
- const err = `Unexpected unmanaged association “${assoc.name}” in filter expression of “${$baseLink.definition.name}”`
717
- throw new Error(err)
718
- }
719
- // no non-fk traversal in infix filter in non-exists path
720
- if (nextStep && !assoc.on && !isForeignKeyOf(nextStep, assoc))
721
- throw new Error(
722
- `Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${nextStep}”`,
723
- )
724
- }
725
- }
726
729
  })
727
730
 
731
+ // we need inner joins for the path expressions inside filter expressions after exists predicate
732
+ if ($baseLink?.pathExpressionInsideFilter) Object.defineProperty(column, 'join', { value: 'inner' })
733
+
728
734
  // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
729
735
  if (column.expand) {
730
736
  const { $refLinks } = column
@@ -1214,6 +1220,26 @@ function infer(originalQuery, model) {
1214
1220
  }
1215
1221
  }
1216
1222
 
1223
+ /**
1224
+ * Determines if a given association is a non-foreign key navigation.
1225
+ *
1226
+ * @param {Object} assoc - The association.
1227
+ * @param {Object} nextStep - The next step in the navigation path.
1228
+ * @returns {boolean} - Returns true if the next step is a non-foreign key navigation, otherwise false.
1229
+ */
1230
+ function isNonForeignKeyNavigation(assoc, nextStep) {
1231
+ if (!nextStep || !assoc.target) return false
1232
+
1233
+ return assoc.on || !isForeignKeyOf(nextStep, assoc)
1234
+ }
1235
+
1236
+ function rejectNonFkNavigation(assoc, additionalInfo) {
1237
+ if (assoc.on) {
1238
+ throw new Error(`Unexpected unmanaged association “${assoc.name}” in filter expression of “${additionalInfo}”`)
1239
+ }
1240
+ throw new Error(`Only foreign keys of “${assoc.name}” can be accessed in infix filter, but found “${additionalInfo}”`)
1241
+ }
1242
+
1217
1243
  /**
1218
1244
  * Returns true if e is a foreign key of assoc.
1219
1245
  * this function is also compatible with unfolded csn (UCSN),
@@ -181,6 +181,7 @@ class JoinTree {
181
181
  // if no root node was found, the column is selected from a subquery
182
182
  if (!node) return
183
183
  while (i < col.ref.length) {
184
+ if(col.join === 'inner') node.join = 'inner'
184
185
  const step = col.ref[i]
185
186
  const { where, args } = step
186
187
  const id = joinId(step, args, where)
package/lib/search.js CHANGED
@@ -114,7 +114,7 @@ const _getSearchableColumns = entity => {
114
114
  deepSearchCandidates.forEach(c => {
115
115
  const element = c.ref.reduce((resolveIn, curr, i) => {
116
116
  const next = resolveIn.elements?.[curr] || resolveIn._target.elements[curr]
117
- if (next.isAssociation && !c.ref[i + 1]) {
117
+ if (next?.isAssociation && !c.ref[i + 1]) {
118
118
  const searchInTarget = _getSearchableColumns(next._target)
119
119
  searchInTarget.forEach(elementRefInTarget => {
120
120
  searchableColumns.push({ ref: c.ref.concat(...elementRefInTarget.ref) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/db-service",
3
- "version": "1.14.1",
3
+ "version": "1.15.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": {