@cap-js/db-service 2.8.1 → 2.9.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/lib/cqn4sql.js CHANGED
@@ -13,6 +13,7 @@ const {
13
13
  defineProperty,
14
14
  getModelUtils,
15
15
  hasOwnSkip,
16
+ isRuntimeView,
16
17
  } = require('./utils')
17
18
 
18
19
  /**
@@ -173,8 +174,114 @@ function cqn4sql(originalQuery, model) {
173
174
  }
174
175
  }
175
176
 
177
+ if (cds.env.features.runtime_views) processRuntimeViews(transformedQuery, model)
178
+
176
179
  return transformedQuery
177
180
 
181
+ /**
182
+ * If the target entity is annotated with persistence skip and has an underlying db entity,
183
+ * we treat it as a runtime view and transform it into a CTE.
184
+ *
185
+ * @param {object} transformedQuery - The query object to be transformed.
186
+ * @param {string} model - The data model used for inference and transformation.
187
+ */
188
+ function processRuntimeViews(transformedQuery, model) {
189
+ const currentDef = transformedQuery._target
190
+
191
+ if (hasOwnSkip(currentDef)) {
192
+ if (!isRuntimeView(currentDef)) throw new Error(`${currentDef.name} is not a runtime view`)
193
+
194
+ addWith(currentDef, transformedQuery, model)
195
+ updateRefsWithRTVAlias(transformedQuery._with, transformedQuery)
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Recursively call cqn4sql for all nested runtime views to calculate cte and
201
+ * add it as a with clause to the transformed query.
202
+ * Alias the runtime view with a unique alias and update all references to the runtime view to point to the alias.
203
+ *
204
+ * @param {object} rootDefinition - The root definition of the query. This is used to recursively process nested runtime views.
205
+ * @param {object} transformedQuery - The query object to be transformed.
206
+ * @param {string} model - The data model used for infer and cqn4sql.
207
+ */
208
+ function addWith(rootDefinition, transformedQuery, model) {
209
+ if (!rootDefinition?.query) return
210
+
211
+ // early exit if already processed
212
+ if (transformedQuery._with?.some(w => w._source === rootDefinition)) return
213
+
214
+ const q = cds.ql.clone(rootDefinition.query)
215
+ if (q.SELECT) {
216
+ if (!q.SELECT.columns) q.SELECT.columns = ['*']
217
+ if (q.SELECT.columns.includes('*')) {
218
+ // cache element names for faster lookup
219
+ const existingColumns = new Set(
220
+ q.SELECT.columns
221
+ .map(col => col.as || col.ref?.at(-1))
222
+ .filter(Boolean)
223
+ )
224
+
225
+ for (let el of rootDefinition.elements) {
226
+ if (el.type === 'cds.LargeBinary' && !existingColumns.has(el.name)) {
227
+ q.SELECT.columns.push({ ref: [el.name] })
228
+ }
229
+ }
230
+ }
231
+ }
232
+ const inferredDQ = infer(q, model)
233
+ inferredDQ._with = transformedQuery._with
234
+ const transformedDQ = cqn4sql(inferredDQ, model)
235
+
236
+ if (q.SELECT?.from?.args) {
237
+ for (const arg of q.SELECT.from.args) {
238
+ addWith(arg.$refLinks.at(-1).definition, inferredDQ, model)
239
+ arg.as ??= arg.ref.at(-1).split('.').at(-1) // apply @sap/cds-compiler default alias
240
+ updateRefsWithRTVAlias(inferredDQ._with, transformedDQ, arg.ref)
241
+ }
242
+ }
243
+
244
+ const newWiths = transformedDQ._with || []
245
+ const rootDefinitionName = rootDefinition.name
246
+
247
+ defineProperty(transformedDQ, '_source', rootDefinition)
248
+ const alias = `RTV_${getImplicitAlias(rootDefinitionName)}`
249
+ transformedDQ.as = transformedDQ.joinTree.addNextAvailableTableAlias(alias, newWiths, rootDefinitionName)
250
+
251
+ // update SELECT.from with runtime view alias
252
+ if (hasOwnSkip(transformedDQ._target)) updateRefsWithRTVAlias(transformedDQ._with, transformedDQ)
253
+
254
+ if (transformedDQ._with) delete transformedDQ._with
255
+ newWiths.push(transformedDQ)
256
+
257
+ // propagate with clauses
258
+ if (!transformedQuery._with) transformedQuery._with = newWiths
259
+ }
260
+
261
+ function updateRefsWithRTVAlias(_with, query, ref) {
262
+ if (!_with?.length) return
263
+
264
+ const _updateRef = (ref) => {
265
+ const refAlias = ref[0]
266
+ if (/RTV_$/.test(refAlias)) return
267
+ for (const w of _with) {
268
+ const aliasValue = w.joinTree._queryAliases.get(refAlias)
269
+ if (aliasValue) {
270
+ ref[0] = aliasValue
271
+ if (query.joinTree?._queryAliases) {
272
+ query.joinTree._queryAliases.set(refAlias, aliasValue)
273
+ }
274
+ break
275
+ }
276
+ }
277
+ }
278
+
279
+ if (ref) return _updateRef(ref)
280
+
281
+ if (query.SELECT.from.args) for (const arg of query.SELECT.from.args) _updateRef(arg.ref)
282
+ else if (query.SELECT.from.ref) _updateRef(query.SELECT.from.ref)
283
+ }
284
+
178
285
  function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
179
286
  const { columns, having, groupBy, orderBy, limit } = queryProp
180
287
 
@@ -314,7 +421,9 @@ function cqn4sql(originalQuery, model) {
314
421
  ),
315
422
  )
316
423
 
317
- const id = getDefinition(nextAssoc.$refLink.definition.target).name
424
+ const def = getDefinition(nextAssoc.$refLink.definition.target)
425
+ const id = def.name
426
+ if (hasOwnSkip(def) && isRuntimeView(def)) addWith(model.definitions[id], transformedQuery, model)
318
427
  const { args } = nextAssoc
319
428
  const arg = {
320
429
  ref: [args ? { id, args } : id],
@@ -352,7 +461,7 @@ function cqn4sql(originalQuery, model) {
352
461
  for (let i = 0; i < columns.length; i++) {
353
462
  const col = columns[i]
354
463
 
355
- if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
464
+ if (isCalculatedOnRead(col.$refLinks?.at(-1).definition) && !col.$refLinks?.at(-1).target?.SELECT) {
356
465
  const name = getName(col)
357
466
  if (!transformedColumns.some(inserted => getName(inserted) === name)) {
358
467
  const calcElement = resolveCalculatedElement(col)
@@ -473,7 +582,13 @@ function cqn4sql(originalQuery, model) {
473
582
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
474
583
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
475
584
 
476
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
585
+ if (
586
+ col.$refLinks.some(link => {
587
+ const def = getDefinition(link.definition.target)
588
+ return hasOwnSkip(def) && !isRuntimeView(def)
589
+ })
590
+ )
591
+ return
477
592
 
478
593
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
479
594
  flatColumns.forEach(flatColumn => {
@@ -560,6 +675,7 @@ function cqn4sql(originalQuery, model) {
560
675
  res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
561
676
  }
562
677
  if (!omitAlias) res.as = column.as || column.name || column.flatName
678
+ setElementOnColumns(res, column.element || column)
563
679
  return res
564
680
  }
565
681
 
@@ -643,8 +759,7 @@ function cqn4sql(originalQuery, model) {
643
759
  nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
644
760
  const name = nameParts.join('_')
645
761
  if (nestedProjection.ref) {
646
- const augmentedInlineCol = { ...nestedProjection }
647
- augmentedInlineCol.ref = col.ref ? [...col.ref, ...nestedProjection.ref] : nestedProjection.ref
762
+ const augmentedInlineCol = augmentInlineRefWithParent(nestedProjection, col)
648
763
  if (
649
764
  col.as ||
650
765
  nestedProjection.as ||
@@ -653,19 +768,6 @@ function cqn4sql(originalQuery, model) {
653
768
  ) {
654
769
  augmentedInlineCol.as = nameParts.join('_')
655
770
  }
656
- Object.defineProperties(augmentedInlineCol, {
657
- $refLinks: { value: [...nestedProjection.$refLinks], writable: true },
658
- isJoinRelevant: {
659
- value: nestedProjection.isJoinRelevant,
660
- writable: true,
661
- },
662
- })
663
- // if the expand is not anonymous, we must prepend the expand columns path
664
- // to make sure the full path is resolvable
665
- if (col.ref) {
666
- augmentedInlineCol.$refLinks.unshift(...col.$refLinks)
667
- augmentedInlineCol.isJoinRelevant = augmentedInlineCol.isJoinRelevant || col.isJoinRelevant
668
- }
669
771
  const flatColumns = getTransformedColumns([augmentedInlineCol])
670
772
  flatColumns.forEach(flatColumn => {
671
773
  const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
@@ -682,6 +784,10 @@ function cqn4sql(originalQuery, model) {
682
784
  if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
683
785
  const rewrittenColumn = { ...nestedProjection }
684
786
  rewrittenColumn.as = name
787
+ // For xpr, we need to transform refs inside to include the struct prefix
788
+ if (nestedProjection.xpr && col.ref) {
789
+ rewrittenColumn.xpr = augmentInlineXprRefs(nestedProjection.xpr, col)
790
+ }
685
791
  rewrittenColumns.push(rewrittenColumn)
686
792
  }
687
793
  }
@@ -690,6 +796,53 @@ function cqn4sql(originalQuery, model) {
690
796
  })
691
797
 
692
798
  return res
799
+
800
+ /**
801
+ * Augment a ref column with the parent column's path and $refLinks.
802
+ */
803
+ function augmentInlineRefWithParent(refCol, parentCol) {
804
+ const augmented = { ...refCol }
805
+ augmented.ref = parentCol.ref ? [...parentCol.ref, ...refCol.ref] : refCol.ref
806
+ Object.defineProperties(augmented, {
807
+ $refLinks: { value: [...refCol.$refLinks], writable: true },
808
+ isJoinRelevant: { value: refCol.isJoinRelevant, writable: true },
809
+ })
810
+ if (parentCol.ref) {
811
+ augmented.$refLinks.unshift(...parentCol.$refLinks)
812
+ augmented.isJoinRelevant = augmented.isJoinRelevant || parentCol.isJoinRelevant
813
+ }
814
+ return augmented
815
+ }
816
+
817
+ /**
818
+ * Augment refs inside an xpr with the parent column's path and $refLinks,
819
+ * then transform them to flat refs.
820
+ */
821
+ function augmentInlineXprRefs(xpr, parentCol) {
822
+ return xpr.map(token => {
823
+ if (typeof token === 'string' || token.val !== undefined) {
824
+ return token
825
+ }
826
+ if (token.ref && token.$refLinks) {
827
+ const augmented = augmentInlineRefWithParent(token, parentCol)
828
+ // Transform this single ref column to get the flat version
829
+ const transformed = getTransformedColumns([augmented])
830
+ if (transformed.length === 1) {
831
+ return transformed[0]
832
+ }
833
+ return augmented
834
+ }
835
+ if (token.xpr) {
836
+ return { ...token, xpr: augmentInlineXprRefs(token.xpr, parentCol) }
837
+ }
838
+ if (token.func && token.args) {
839
+ return { ...token, args: token.args.map(arg =>
840
+ arg.ref ? augmentInlineXprRefs([arg], parentCol)[0] : arg
841
+ )}
842
+ }
843
+ return token
844
+ })
845
+ }
693
846
  }
694
847
 
695
848
  /**
@@ -747,6 +900,9 @@ function cqn4sql(originalQuery, model) {
747
900
 
748
901
  if (baseRefLinks.at(-1).definition.kind === 'entity') {
749
902
  res.push(...getColumnsForWildcard(exclude, replace, col.as))
903
+ } else if (baseRefLinks.at(-1).definition.target) {
904
+ // Wildcard on association - need to include FK columns and join-relevant target columns
905
+ res.push(...expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace))
750
906
  } else
751
907
  res.push(
752
908
  ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
@@ -757,6 +913,117 @@ function cqn4sql(originalQuery, model) {
757
913
  return res
758
914
  }
759
915
 
916
+ /**
917
+ * Expands a wildcard on an association into:
918
+ * 1. FK columns from the source table
919
+ * 2. Non-FK columns from the target via join
920
+ */
921
+ function expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace) {
922
+ const res = []
923
+ const assocDef = baseRefLinks.at(-1).definition
924
+ const targetDef = getDefinition(assocDef.target)
925
+ const columnAlias = col.as || baseRef.map(idOnly).join('_')
926
+ const sourceTableAlias = getTableAlias(col)
927
+
928
+ // Get the join alias for this association (set during join tree merge)
929
+ const joinAlias = baseRefLinks.at(-1).alias
930
+
931
+ // Collect FK element names
932
+ const fkNames = new Set()
933
+ if (assocDef.keys) {
934
+ for (const k of assocDef.keys) {
935
+ fkNames.add(k.ref[0])
936
+ }
937
+ }
938
+
939
+ // First, add FK columns from source table
940
+ // These are accessed via the source table alias, not the join
941
+ const fkColumns = getFlatColumnsFor(col, { tableAlias: sourceTableAlias }, [], {
942
+ exclude,
943
+ replace,
944
+ })
945
+ res.push(...fkColumns.filter(fk => !col.excluding?.some(e => targetDef.elements[e] === fk.element)))
946
+
947
+ // Then, add non-FK columns from target via join
948
+ if (targetDef?.elements) {
949
+ for (const [elemName, elemDef] of Object.entries(targetDef.elements)) {
950
+ // Skip FK elements (already included above), virtual, blobs, and unmanaged assocs
951
+ if (fkNames.has(elemName)) continue
952
+ if (elemDef.virtual) continue
953
+ if (elemDef.type === 'cds.LargeBinary') continue
954
+ if (elemDef.on && !elemDef.keys) continue // unmanaged association
955
+
956
+ // Check exclusions
957
+ const fullName = `${columnAlias}_${elemName}`
958
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === elemName)) continue
959
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fullName)) continue
960
+
961
+ // Check for replacement
962
+ const replacement = replace.find(r => (r.ref?.at(-1) || r.as) === elemName)
963
+ if (replacement) {
964
+ // Handle replacement - create augmented column
965
+ const augmented = { ...replacement }
966
+ augmented.as = fullName
967
+ res.push(...getTransformedColumns([augmented]))
968
+ continue
969
+ }
970
+
971
+ // Create column referencing the join alias
972
+ if (elemDef.elements) {
973
+ // Structured element - need to flatten it
974
+ const structCols = getFlatColumnsFor(
975
+ elemDef,
976
+ { baseName: elemName, columnAlias: fullName, tableAlias: joinAlias },
977
+ [],
978
+ { exclude, replace },
979
+ true,
980
+ )
981
+ res.push(...structCols)
982
+ } else if (elemDef.keys) {
983
+ // Association element - flatten its foreign keys
984
+ // The FK column name is: assocName_keyName (e.g., 'head_id')
985
+ for (const k of elemDef.keys) {
986
+ const keyName = k.as || k.ref.join('_')
987
+ const fkName = `${elemName}_${keyName}` // e.g., 'head_id'
988
+ const fkFullName = `${columnAlias}_${fkName}` // e.g., 'department_head_id'
989
+
990
+ // Check if this FK is excluded
991
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkName)) continue
992
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkFullName)) continue
993
+
994
+ const flatColumn = {
995
+ ref: [joinAlias, fkName],
996
+ as: fkFullName,
997
+ }
998
+ const fkElement = getElementForRef(k.ref, getDefinition(elemDef.target))
999
+ setElementOnColumns(flatColumn, fkElement)
1000
+ res.push(flatColumn)
1001
+ }
1002
+ } else if (elemDef.value) {
1003
+ // Calculated element - resolve it
1004
+ const calcElement = resolveCalculatedElement({ $refLinks: [{ definition: elemDef }] }, true)
1005
+ if (calcElement.as) {
1006
+ calcElement.as = fullName
1007
+ } else {
1008
+ calcElement.as = fullName
1009
+ }
1010
+ res.push(calcElement)
1011
+ }
1012
+ else {
1013
+ // Scalar element
1014
+ const flatColumn = {
1015
+ ref: [joinAlias, elemName],
1016
+ as: fullName,
1017
+ }
1018
+ setElementOnColumns(flatColumn, elemDef)
1019
+ res.push(flatColumn)
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ return res
1025
+ }
1026
+
760
1027
  /**
761
1028
  * This function converts a column with an `expand` property into a subquery.
762
1029
  *
@@ -972,7 +1239,12 @@ function cqn4sql(originalQuery, model) {
972
1239
  } else if (pseudos.elements[col.ref?.[0]]) {
973
1240
  res.push({ ...col })
974
1241
  } else if (col.ref) {
975
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
1242
+ if (
1243
+ col.$refLinks.some(link => {
1244
+ const def = getDefinition(link.definition.target)
1245
+ return hasOwnSkip(def) && !isRuntimeView(def)
1246
+ })
1247
+ )
976
1248
  continue
977
1249
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
978
1250
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1070,10 +1342,18 @@ function cqn4sql(originalQuery, model) {
1070
1342
  outerQueries.push(inferred)
1071
1343
  defineProperty(q, 'outerQueries', outerQueries)
1072
1344
  }
1345
+
1073
1346
  const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
1074
1347
  if (isLocalized(target)) q.SELECT.localized = true
1075
1348
  if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
1076
- return cqn4sql(q, model)
1349
+ if (cds.env.features.runtime_views) q._with = transformedQuery._with
1350
+ const _q = cqn4sql(q, model)
1351
+ if (cds.env.features.runtime_views && _q._with) {
1352
+ if (!transformedQuery._with) transformedQuery._with = _q._with
1353
+ delete _q._with
1354
+ }
1355
+ return _q
1356
+
1077
1357
 
1078
1358
  function assignUniqueSubqueryAlias() {
1079
1359
  if (q.SELECT.from.uniqueSubqueryAlias) return
@@ -1213,7 +1493,7 @@ function cqn4sql(originalQuery, model) {
1213
1493
  columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1214
1494
  } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
1215
1495
 
1216
- if(column.element && !isAssocOrStruct(column.element)) {
1496
+ if (column.element && !isAssocOrStruct(column.element)) {
1217
1497
  columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1218
1498
  const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1219
1499
  setElementOnColumns(res, column.element)
@@ -1503,7 +1783,7 @@ function cqn4sql(originalQuery, model) {
1503
1783
  throw new Error(`The operator "${next}" can only be used with scalar operands`)
1504
1784
 
1505
1785
  const newTokens = expandComparison(token, ops, rhs, $baseLink)
1506
- if(newTokens.length === 0)
1786
+ if (newTokens.length === 0)
1507
1787
  throw new Error(`Can't compare two empty structures`)
1508
1788
 
1509
1789
  const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
@@ -1538,9 +1818,9 @@ function cqn4sql(originalQuery, model) {
1538
1818
  const lastAssoc =
1539
1819
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1540
1820
  const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1541
- if(isAssocOrStruct(definition)) {
1542
- const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1543
- if(flat.length === 0)
1821
+ if (isAssocOrStruct(definition)) {
1822
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1823
+ if (flat.length === 0)
1544
1824
  throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1545
1825
  else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1546
1826
  throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
@@ -1673,7 +1953,7 @@ function cqn4sql(originalQuery, model) {
1673
1953
 
1674
1954
  function assertNoStructInXpr(token, context) {
1675
1955
  const definition = token.$refLinks?.at(-1).definition
1676
- if(!definition) return
1956
+ if (!definition) return
1677
1957
  const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1678
1958
  // unmanaged is always forbidden
1679
1959
  // expanding a ref in a `where`/`having` context
@@ -1785,7 +2065,7 @@ function cqn4sql(originalQuery, model) {
1785
2065
 
1786
2066
  // OData variant w/o mentioning key
1787
2067
  if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1788
- filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
2068
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where, { $baseLink: $refLinksReverse[0] }))
1789
2069
  }
1790
2070
 
1791
2071
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
@@ -2231,7 +2511,7 @@ function cqn4sql(originalQuery, model) {
2231
2511
  return SELECT
2232
2512
  }
2233
2513
 
2234
- /**
2514
+ /**
2235
2515
  * For a given search term calculate a search expression which can be used in a where clause.
2236
2516
  * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2237
2517
  * the search results of the subquery.
@@ -2259,13 +2539,13 @@ function cqn4sql(originalQuery, model) {
2259
2539
  // for aggregated queries / search on subqueries we do not do a subquery search
2260
2540
  if (inferred.SELECT.groupBy || entity.SELECT)
2261
2541
  return searchFunc
2262
-
2542
+
2263
2543
  const matchColumns = getPrimaryKey(entity)
2264
2544
  if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2265
2545
  return searchFunc
2266
-
2267
- const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2268
- return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] }
2546
+
2547
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2548
+ return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] }
2269
2549
  }
2270
2550
 
2271
2551
  /**
@@ -2282,7 +2562,7 @@ function cqn4sql(originalQuery, model) {
2282
2562
  if (!node || !node.$refLinks || !node.ref) {
2283
2563
  throw new Error('Invalid node')
2284
2564
  }
2285
- if(node.$refLinks[0].$main) {
2565
+ if (node.$refLinks[0].$main) {
2286
2566
  if (node.isJoinRelevant) {
2287
2567
  return getJoinRelevantAlias(node)
2288
2568
  }
@@ -2425,7 +2705,7 @@ function assignQueryModifiers(SELECT, modifiers) {
2425
2705
  else SELECT.having.push('and', ...val)
2426
2706
  } else if (key === 'where') {
2427
2707
  // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2428
- if(!val || val.length === 1 && val[0].val) continue
2708
+ if (!val || val.length === 1 && val[0].val) continue
2429
2709
  if (!SELECT.where) SELECT.where = val
2430
2710
  // infix filter comes first in resulting where
2431
2711
  else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]