@cap-js/db-service 2.8.2 → 2.10.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
@@ -9,10 +9,11 @@ const {
9
9
  prettyPrintRef,
10
10
  isCalculatedOnRead,
11
11
  isCalculatedElement,
12
- getImplicitAlias,
12
+ getImplicitAlias: _getImplicitAlias,
13
13
  defineProperty,
14
14
  getModelUtils,
15
15
  hasOwnSkip,
16
+ isRuntimeView,
16
17
  } = require('./utils')
17
18
 
18
19
  /**
@@ -53,7 +54,8 @@ const { pseudos } = require('./infer/pseudos')
53
54
  * @param {object} model
54
55
  * @returns {object} transformedQuery the transformed query
55
56
  */
56
- function cqn4sql(originalQuery, model) {
57
+ function cqn4sql(originalQuery, model, useTechnicalAlias = true) {
58
+ const getImplicitAlias = str => _getImplicitAlias(str, useTechnicalAlias)
57
59
  let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
58
60
  const hasCustomJoins =
59
61
  originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)
@@ -80,7 +82,7 @@ function cqn4sql(originalQuery, model) {
80
82
  if (inferred.UPDATE?.entity.ref?.at(-1).id) {
81
83
  assignQueryModifiers(inferred.UPDATE, inferred.UPDATE.entity.ref.at(-1))
82
84
  }
83
- inferred = infer(inferred, model)
85
+ inferred = infer(inferred, model, useTechnicalAlias)
84
86
  const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
85
87
  // if the query has custom joins we don't want to transform it
86
88
  // TODO: move all the way to the top of this function once cds.infer supports joins as well
@@ -133,7 +135,7 @@ function cqn4sql(originalQuery, model) {
133
135
  // match primary keys of the target entity with the subquery
134
136
  primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
135
137
 
136
- const transformedSubquery = cqn4sql(subquery, model)
138
+ const transformedSubquery = cqn4sql(subquery, model, useTechnicalAlias)
137
139
 
138
140
  // replace where condition of original query with the transformed subquery
139
141
  // correlate UPDATE / DELETE query with subquery by primary key matches
@@ -173,8 +175,114 @@ function cqn4sql(originalQuery, model) {
173
175
  }
174
176
  }
175
177
 
178
+ if (cds.env.features.runtime_views) processRuntimeViews(transformedQuery, model)
179
+
176
180
  return transformedQuery
177
181
 
182
+ /**
183
+ * If the target entity is annotated with persistence skip and has an underlying db entity,
184
+ * we treat it as a runtime view and transform it into a CTE.
185
+ *
186
+ * @param {object} transformedQuery - The query object to be transformed.
187
+ * @param {string} model - The data model used for inference and transformation.
188
+ */
189
+ function processRuntimeViews(transformedQuery, model) {
190
+ const currentDef = transformedQuery._target
191
+
192
+ if (hasOwnSkip(currentDef)) {
193
+ if (!isRuntimeView(currentDef)) throw new Error(`${currentDef.name} is not a runtime view`)
194
+
195
+ addWith(currentDef, transformedQuery, model)
196
+ updateRefsWithRTVAlias(transformedQuery._with, transformedQuery)
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Recursively call cqn4sql for all nested runtime views to calculate cte and
202
+ * add it as a with clause to the transformed query.
203
+ * Alias the runtime view with a unique alias and update all references to the runtime view to point to the alias.
204
+ *
205
+ * @param {object} rootDefinition - The root definition of the query. This is used to recursively process nested runtime views.
206
+ * @param {object} transformedQuery - The query object to be transformed.
207
+ * @param {string} model - The data model used for infer and cqn4sql.
208
+ */
209
+ function addWith(rootDefinition, transformedQuery, model) {
210
+ if (!rootDefinition?.query) return
211
+
212
+ // early exit if already processed
213
+ if (transformedQuery._with?.some(w => w._source === rootDefinition)) return
214
+
215
+ const q = cds.ql.clone(rootDefinition.query)
216
+ if (q.SELECT) {
217
+ if (!q.SELECT.columns) q.SELECT.columns = ['*']
218
+ if (q.SELECT.columns.includes('*')) {
219
+ // cache element names for faster lookup
220
+ const existingColumns = new Set(
221
+ q.SELECT.columns
222
+ .map(col => col.as || col.ref?.at(-1))
223
+ .filter(Boolean)
224
+ )
225
+
226
+ for (let el of rootDefinition.elements) {
227
+ if (el.type === 'cds.LargeBinary' && !existingColumns.has(el.name)) {
228
+ q.SELECT.columns.push({ ref: [el.name] })
229
+ }
230
+ }
231
+ }
232
+ }
233
+ const inferredDQ = infer(q, model, useTechnicalAlias)
234
+ inferredDQ._with = transformedQuery._with
235
+ const transformedDQ = cqn4sql(inferredDQ, model, useTechnicalAlias)
236
+
237
+ if (q.SELECT?.from?.args) {
238
+ for (const arg of q.SELECT.from.args) {
239
+ addWith(arg.$refLinks.at(-1).definition, inferredDQ, model)
240
+ arg.as ??= arg.ref.at(-1).split('.').at(-1) // apply @sap/cds-compiler default alias
241
+ updateRefsWithRTVAlias(inferredDQ._with, transformedDQ, arg.ref)
242
+ }
243
+ }
244
+
245
+ const newWiths = transformedDQ._with || []
246
+ const rootDefinitionName = rootDefinition.name
247
+
248
+ defineProperty(transformedDQ, '_source', rootDefinition)
249
+ const alias = `RTV_${getImplicitAlias(rootDefinitionName)}`
250
+ transformedDQ.as = transformedDQ.joinTree.addNextAvailableTableAlias(alias, newWiths, rootDefinitionName)
251
+
252
+ // update SELECT.from with runtime view alias
253
+ if (hasOwnSkip(transformedDQ._target)) updateRefsWithRTVAlias(transformedDQ._with, transformedDQ)
254
+
255
+ if (transformedDQ._with) delete transformedDQ._with
256
+ newWiths.push(transformedDQ)
257
+
258
+ // propagate with clauses
259
+ if (!transformedQuery._with) transformedQuery._with = newWiths
260
+ }
261
+
262
+ function updateRefsWithRTVAlias(_with, query, ref) {
263
+ if (!_with?.length) return
264
+
265
+ const _updateRef = (ref) => {
266
+ const refAlias = ref[0]
267
+ if (/RTV_$/.test(refAlias)) return
268
+ for (const w of _with) {
269
+ const aliasValue = w.joinTree._queryAliases.get(refAlias)
270
+ if (aliasValue) {
271
+ ref[0] = aliasValue
272
+ if (query.joinTree?._queryAliases) {
273
+ query.joinTree._queryAliases.set(refAlias, aliasValue)
274
+ }
275
+ break
276
+ }
277
+ }
278
+ }
279
+
280
+ if (ref) return _updateRef(ref)
281
+
282
+ if (query.SELECT.from.args) for (const arg of query.SELECT.from.args) _updateRef(arg.ref)
283
+ else if (query.SELECT.from.ref) _updateRef(query.SELECT.from.ref)
284
+ }
285
+
178
286
  function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
179
287
  const { columns, having, groupBy, orderBy, limit } = queryProp
180
288
 
@@ -314,7 +422,9 @@ function cqn4sql(originalQuery, model) {
314
422
  ),
315
423
  )
316
424
 
317
- const id = getDefinition(nextAssoc.$refLink.definition.target).name
425
+ const def = getDefinition(nextAssoc.$refLink.definition.target)
426
+ const id = def.name
427
+ if (hasOwnSkip(def) && isRuntimeView(def)) addWith(model.definitions[id], transformedQuery, model)
318
428
  const { args } = nextAssoc
319
429
  const arg = {
320
430
  ref: [args ? { id, args } : id],
@@ -352,7 +462,7 @@ function cqn4sql(originalQuery, model) {
352
462
  for (let i = 0; i < columns.length; i++) {
353
463
  const col = columns[i]
354
464
 
355
- if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
465
+ if (isCalculatedOnRead(col.$refLinks?.at(-1).definition) && !col.$refLinks?.at(-1).target?.SELECT) {
356
466
  const name = getName(col)
357
467
  if (!transformedColumns.some(inserted => getName(inserted) === name)) {
358
468
  const calcElement = resolveCalculatedElement(col)
@@ -473,7 +583,13 @@ function cqn4sql(originalQuery, model) {
473
583
  const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
474
584
  if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
475
585
 
476
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target)))) return
586
+ if (
587
+ col.$refLinks.some(link => {
588
+ const def = getDefinition(link.definition.target)
589
+ return hasOwnSkip(def) && !isRuntimeView(def)
590
+ })
591
+ )
592
+ return
477
593
 
478
594
  const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
479
595
  flatColumns.forEach(flatColumn => {
@@ -507,6 +623,11 @@ function cqn4sql(originalQuery, model) {
507
623
 
508
624
  function getTransformedColumn(col) {
509
625
  let ret
626
+ if (col !== null && typeof col === 'object' && '#' in col) {
627
+ ret = resolveEnumToken(col, [], -1)
628
+ // cast is already resolved inside resolveEnumToken; do not overwrite it here
629
+ return ret
630
+ }
510
631
  if (col.func) {
511
632
  ret = {
512
633
  func: col.func,
@@ -519,7 +640,7 @@ function cqn4sql(originalQuery, model) {
519
640
  ret.xpr = getTransformedTokenStream(col.xpr)
520
641
  }
521
642
  if (ret) {
522
- if (col.cast) ret.cast = col.cast
643
+ if (col.cast) ret.cast = resolveEnumCastType(col.cast)
523
644
  return ret
524
645
  }
525
646
  return copy(col)
@@ -560,6 +681,7 @@ function cqn4sql(originalQuery, model) {
560
681
  res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
561
682
  }
562
683
  if (!omitAlias) res.as = column.as || column.name || column.flatName
684
+ setElementOnColumns(res, column.element || column)
563
685
  return res
564
686
  }
565
687
 
@@ -610,10 +732,11 @@ function cqn4sql(originalQuery, model) {
610
732
  },
611
733
  })
612
734
  } else {
613
- // target column is `val` or `xpr`, destructure and throw away the ref with the $self
735
+ // target column is `val`, `xpr`, or `func` destructure and throw away the ref with the $self
614
736
  // eslint-disable-next-line no-unused-vars
615
- const { xpr, val, ref, as: _as, ...rest } = referencedColumn
737
+ const { xpr, val, func, args, ref, as: _as, ...rest } = referencedColumn
616
738
  if (xpr) rest.xpr = xpr
739
+ else if (func) { rest.func = func; rest.args = args }
617
740
  else rest.val = val
618
741
  dollarSelfColumn = { ...rest } // reassign dummyColumn without 'ref'
619
742
  if (!omitAlias) dollarSelfColumn.as = as
@@ -643,8 +766,7 @@ function cqn4sql(originalQuery, model) {
643
766
  nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
644
767
  const name = nameParts.join('_')
645
768
  if (nestedProjection.ref) {
646
- const augmentedInlineCol = { ...nestedProjection }
647
- augmentedInlineCol.ref = col.ref ? [...col.ref, ...nestedProjection.ref] : nestedProjection.ref
769
+ const augmentedInlineCol = augmentInlineRefWithParent(nestedProjection, col)
648
770
  if (
649
771
  col.as ||
650
772
  nestedProjection.as ||
@@ -653,19 +775,6 @@ function cqn4sql(originalQuery, model) {
653
775
  ) {
654
776
  augmentedInlineCol.as = nameParts.join('_')
655
777
  }
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
778
  const flatColumns = getTransformedColumns([augmentedInlineCol])
670
779
  flatColumns.forEach(flatColumn => {
671
780
  const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
@@ -682,6 +791,10 @@ function cqn4sql(originalQuery, model) {
682
791
  if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
683
792
  const rewrittenColumn = { ...nestedProjection }
684
793
  rewrittenColumn.as = name
794
+ // For xpr, we need to transform refs inside to include the struct prefix
795
+ if (nestedProjection.xpr && col.ref) {
796
+ rewrittenColumn.xpr = augmentInlineXprRefs(nestedProjection.xpr, col)
797
+ }
685
798
  rewrittenColumns.push(rewrittenColumn)
686
799
  }
687
800
  }
@@ -690,6 +803,55 @@ function cqn4sql(originalQuery, model) {
690
803
  })
691
804
 
692
805
  return res
806
+
807
+ /**
808
+ * Augment a ref column with the parent column's path and $refLinks.
809
+ */
810
+ function augmentInlineRefWithParent(refCol, parentCol) {
811
+ const augmented = { ...refCol }
812
+ augmented.ref = parentCol.ref ? [...parentCol.ref, ...refCol.ref] : refCol.ref
813
+ Object.defineProperties(augmented, {
814
+ $refLinks: { value: [...refCol.$refLinks], writable: true },
815
+ isJoinRelevant: { value: refCol.isJoinRelevant, writable: true },
816
+ })
817
+ if (parentCol.ref) {
818
+ augmented.$refLinks.unshift(...parentCol.$refLinks)
819
+ augmented.isJoinRelevant = augmented.isJoinRelevant || parentCol.isJoinRelevant
820
+ }
821
+ return augmented
822
+ }
823
+
824
+ /**
825
+ * Augment refs inside an xpr with the parent column's path and $refLinks,
826
+ * then transform them to flat refs.
827
+ */
828
+ function augmentInlineXprRefs(xpr, parentCol) {
829
+ return xpr.map(token => {
830
+ if (typeof token === 'string' || token.val !== undefined) {
831
+ return token
832
+ }
833
+ if (token.ref && token.$refLinks) {
834
+ const augmented = augmentInlineRefWithParent(token, parentCol)
835
+ // Transform this single ref column to get the flat version
836
+ const transformed = getTransformedColumns([augmented])
837
+ if (transformed.length === 1) {
838
+ return transformed[0]
839
+ }
840
+ return augmented
841
+ }
842
+ if (token.xpr) {
843
+ return { ...token, xpr: augmentInlineXprRefs(token.xpr, parentCol) }
844
+ }
845
+ if (token.func && token.args) {
846
+ return {
847
+ ...token, args: token.args.map(arg =>
848
+ arg.ref ? augmentInlineXprRefs([arg], parentCol)[0] : arg
849
+ )
850
+ }
851
+ }
852
+ return token
853
+ })
854
+ }
693
855
  }
694
856
 
695
857
  /**
@@ -747,6 +909,9 @@ function cqn4sql(originalQuery, model) {
747
909
 
748
910
  if (baseRefLinks.at(-1).definition.kind === 'entity') {
749
911
  res.push(...getColumnsForWildcard(exclude, replace, col.as))
912
+ } else if (baseRefLinks.at(-1).definition.target) {
913
+ // Wildcard on association - need to include FK columns and join-relevant target columns
914
+ res.push(...expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace))
750
915
  } else
751
916
  res.push(
752
917
  ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
@@ -757,6 +922,117 @@ function cqn4sql(originalQuery, model) {
757
922
  return res
758
923
  }
759
924
 
925
+ /**
926
+ * Expands a wildcard on an association into:
927
+ * 1. FK columns from the source table
928
+ * 2. Non-FK columns from the target via join
929
+ */
930
+ function expandAssociationWildcard(col, baseRef, baseRefLinks, exclude, replace) {
931
+ const res = []
932
+ const assocDef = baseRefLinks.at(-1).definition
933
+ const targetDef = getDefinition(assocDef.target)
934
+ const columnAlias = col.as || baseRef.map(idOnly).join('_')
935
+ const sourceTableAlias = getTableAlias(col)
936
+
937
+ // Get the join alias for this association (set during join tree merge)
938
+ const joinAlias = baseRefLinks.at(-1).alias
939
+
940
+ // Collect FK element names
941
+ const fkNames = new Set()
942
+ if (assocDef.keys) {
943
+ for (const k of assocDef.keys) {
944
+ fkNames.add(k.ref[0])
945
+ }
946
+ }
947
+
948
+ // First, add FK columns from source table
949
+ // These are accessed via the source table alias, not the join
950
+ const fkColumns = getFlatColumnsFor(col, { tableAlias: sourceTableAlias }, [], {
951
+ exclude,
952
+ replace,
953
+ })
954
+ res.push(...fkColumns.filter(fk => !col.excluding?.some(e => targetDef.elements[e] === fk.element)))
955
+
956
+ // Then, add non-FK columns from target via join
957
+ if (targetDef?.elements) {
958
+ for (const [elemName, elemDef] of Object.entries(targetDef.elements)) {
959
+ // Skip FK elements (already included above), virtual, blobs, and unmanaged assocs
960
+ if (fkNames.has(elemName)) continue
961
+ if (elemDef.virtual) continue
962
+ if (elemDef.type === 'cds.LargeBinary') continue
963
+ if (elemDef.on && !elemDef.keys) continue // unmanaged association
964
+
965
+ // Check exclusions
966
+ const fullName = `${columnAlias}_${elemName}`
967
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === elemName)) continue
968
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fullName)) continue
969
+
970
+ // Check for replacement
971
+ const replacement = replace.find(r => (r.ref?.at(-1) || r.as) === elemName)
972
+ if (replacement) {
973
+ // Handle replacement - create augmented column
974
+ const augmented = { ...replacement }
975
+ augmented.as = fullName
976
+ res.push(...getTransformedColumns([augmented]))
977
+ continue
978
+ }
979
+
980
+ // Create column referencing the join alias
981
+ if (elemDef.elements) {
982
+ // Structured element - need to flatten it
983
+ const structCols = getFlatColumnsFor(
984
+ elemDef,
985
+ { baseName: elemName, columnAlias: fullName, tableAlias: joinAlias },
986
+ [],
987
+ { exclude, replace },
988
+ true,
989
+ )
990
+ res.push(...structCols)
991
+ } else if (elemDef.keys) {
992
+ // Association element - flatten its foreign keys
993
+ // The FK column name is: assocName_keyName (e.g., 'head_id')
994
+ for (const k of elemDef.keys) {
995
+ const keyName = k.as || k.ref.join('_')
996
+ const fkName = `${elemName}_${keyName}` // e.g., 'head_id'
997
+ const fkFullName = `${columnAlias}_${fkName}` // e.g., 'department_head_id'
998
+
999
+ // Check if this FK is excluded
1000
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkName)) continue
1001
+ if (exclude.some(e => (e.ref?.at(-1) || e.as || e) === fkFullName)) continue
1002
+
1003
+ const flatColumn = {
1004
+ ref: [joinAlias, fkName],
1005
+ as: fkFullName,
1006
+ }
1007
+ const fkElement = getElementForRef(k.ref, getDefinition(elemDef.target))
1008
+ setElementOnColumns(flatColumn, fkElement)
1009
+ res.push(flatColumn)
1010
+ }
1011
+ } else if (elemDef.value) {
1012
+ // Calculated element - resolve it
1013
+ const calcElement = resolveCalculatedElement({ $refLinks: [{ definition: elemDef }] }, true)
1014
+ if (calcElement.as) {
1015
+ calcElement.as = fullName
1016
+ } else {
1017
+ calcElement.as = fullName
1018
+ }
1019
+ res.push(calcElement)
1020
+ }
1021
+ else {
1022
+ // Scalar element
1023
+ const flatColumn = {
1024
+ ref: [joinAlias, elemName],
1025
+ as: fullName,
1026
+ }
1027
+ setElementOnColumns(flatColumn, elemDef)
1028
+ res.push(flatColumn)
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ return res
1034
+ }
1035
+
760
1036
  /**
761
1037
  * This function converts a column with an `expand` property into a subquery.
762
1038
  *
@@ -972,7 +1248,12 @@ function cqn4sql(originalQuery, model) {
972
1248
  } else if (pseudos.elements[col.ref?.[0]]) {
973
1249
  res.push({ ...col })
974
1250
  } else if (col.ref) {
975
- if (col.$refLinks.some(link => hasOwnSkip(getDefinition(link.definition.target))))
1251
+ if (
1252
+ col.$refLinks.some(link => {
1253
+ const def = getDefinition(link.definition.target)
1254
+ return hasOwnSkip(def) && !isRuntimeView(def)
1255
+ })
1256
+ )
976
1257
  continue
977
1258
  if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
978
1259
  const dollarSelfReplacement = calculateDollarSelfColumn(col)
@@ -1070,10 +1351,18 @@ function cqn4sql(originalQuery, model) {
1070
1351
  outerQueries.push(inferred)
1071
1352
  defineProperty(q, 'outerQueries', outerQueries)
1072
1353
  }
1354
+
1073
1355
  const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
1074
1356
  if (isLocalized(target)) q.SELECT.localized = true
1075
1357
  if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
1076
- return cqn4sql(q, model)
1358
+ if (cds.env.features.runtime_views) q._with = transformedQuery._with
1359
+ const _q = cqn4sql(q, model, useTechnicalAlias)
1360
+ if (cds.env.features.runtime_views && _q._with) {
1361
+ if (!transformedQuery._with) transformedQuery._with = _q._with
1362
+ delete _q._with
1363
+ }
1364
+ return _q
1365
+
1077
1366
 
1078
1367
  function assignUniqueSubqueryAlias() {
1079
1368
  if (q.SELECT.from.uniqueSubqueryAlias) return
@@ -1108,8 +1397,8 @@ function cqn4sql(originalQuery, model) {
1108
1397
  if (!exclude.includes(k)) {
1109
1398
  const { index, tableAlias } = inferred.$combinedElements[k][0]
1110
1399
  const element = tableAlias.elements[k]
1111
- // ignore FK for odata csn / ignore blobs from wildcard expansion
1112
- if (isManagedAssocInFlatMode(element) || element.type === 'cds.LargeBinary') continue
1400
+ // ignore FK for odata csn (but not for subquery sources where FK is not a separate element) / ignore blobs from wildcard expansion
1401
+ if ((!tableAlias.SELECT && isManagedAssocInFlatMode(element)) || element.type === 'cds.LargeBinary') continue
1113
1402
  // for wildcard on subquery in from, just reference the elements
1114
1403
  if (tableAlias.SELECT && !element.elements && !element.target) {
1115
1404
  wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
@@ -1157,35 +1446,23 @@ function cqn4sql(originalQuery, model) {
1157
1446
 
1158
1447
  /**
1159
1448
  * Recursively expands a structured element into flat columns, representing all leaf paths.
1160
- * This function transforms complex structured elements into simple column representations.
1161
- *
1162
- * For each element, the function checks if it's a structure, an association or a scalar,
1163
- * and proceeds accordingly. If the element is a structure, it recursively fetches flat columns for all sub-elements.
1164
- * If it's an association, it fetches flat columns for it's foreign keys.
1165
- * If it's a scalar, it creates a flat column for it.
1166
1449
  *
1167
- * Columns excluded in a wildcard expansion or replaced by other columns are also handled accordingly.
1450
+ * Structures flat sub-element columns. Associations flat foreign key columns.
1451
+ * Scalars → single column reference.
1168
1452
  *
1169
- * @param {object} column - The structured element which needs to be expanded.
1453
+ * @param {object} column - The element to expand (may be a ref with $refLinks, or a raw element definition).
1170
1454
  * @param {{
1171
- * columnAlias: string
1172
- * tableAlias: string
1173
- * baseName: string
1174
- * }} names - configuration object for naming parameters:
1175
- * columnAlias - The explicit alias which the user has defined for the column.
1176
- * For instance `{ struct.foo as bar}` will be transformed into
1177
- * `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
1178
- * tableAlias - The table alias to prepend to the column name. Optional.
1179
- * baseName - The prefixes of the column reference (joined with '_'). Optional.
1180
- * @param {string} columnAlias - The explicit alias which the user has defined for the column.
1181
- * For instance `{ struct.foo as bar}` will be transformed into
1182
- * `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
1183
- * @param {string} tableAlias - The table alias to prepend to the column name. Optional.
1184
- * @param {Array} csnPath - An array containing CSN paths. Optional.
1185
- * @param {Array} exclude - An array of columns to be excluded from the flat structure. Optional.
1186
- * @param {Array} replace - An array of columns to be replaced in the flat structure. Optional.
1187
- *
1188
- * @returns {object[]} Returns an array of flat column(s) for the given element.
1455
+ * baseName?: string,
1456
+ * columnAlias?: string,
1457
+ * tableAlias?: string
1458
+ * }} [names] - Naming context:
1459
+ * - `baseName` accumulated underscore-joined prefix for the flat column ref (e.g. `'address'` → `'address_street'`).
1460
+ * - `columnAlias` — explicit alias for the output column. Defaults to `column.as` when omitted.
1461
+ * - `tableAlias` table alias prepended to the column ref.
1462
+ * @param {string[]} [csnPath=[]] - Accumulated CSN element path (used for `_csnPath` metadata on leaf columns).
1463
+ * @param {{ exclude?: Array, replace?: Array }} [excludeAndReplace] - Columns to exclude or replace during wildcard expansion.
1464
+ * @param {boolean} [isWildcard=false] - Whether this expansion originates from a wildcard; filters out LargeBinary.
1465
+ * @returns {object[]} Flat column(s) for the given element.
1189
1466
  */
1190
1467
  function getFlatColumnsFor(column, names, csnPath = [], excludeAndReplace, isWildcard = false) {
1191
1468
  if (!column) return column
@@ -1198,28 +1475,13 @@ function cqn4sql(originalQuery, model) {
1198
1475
  let firstNonJoinRelevantAssoc, stepAfterAssoc
1199
1476
  let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
1200
1477
  if (isWildcard && element.type === 'cds.LargeBinary') return []
1201
- if (element.on && !element.keys)
1202
- return [] // unmanaged doesn't make it into columns
1203
- else if (element.virtual === true) return []
1204
- else if (!isJoinRelevant && flatName) baseName = flatName
1205
- else if (isJoinRelevant) {
1206
- const leafAssocIndex = column.$refLinks.findIndex(link => link.definition.isAssociation && link.onlyForeignKeyAccess)
1207
- firstNonJoinRelevantAssoc = column.$refLinks[leafAssocIndex] || [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1208
- stepAfterAssoc = column.$refLinks.at(leafAssocIndex + 1) || column.$refLinks.at(-1)
1209
- let elements = firstNonJoinRelevantAssoc.definition.elements || firstNonJoinRelevantAssoc.definition.foreignKeys
1210
- if (elements && stepAfterAssoc.definition.name in elements) {
1211
- element = firstNonJoinRelevantAssoc.definition
1212
- baseName = getFullName(firstNonJoinRelevantAssoc.definition)
1213
- columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1214
- } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
1215
-
1216
- if(column.element && !isAssocOrStruct(column.element)) {
1217
- columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_')
1218
- const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1219
- setElementOnColumns(res, column.element)
1220
- return [res]
1221
- }
1478
+ if (element.on && !element.keys) return [] // unmanaged doesn't make it into columns
1479
+ if (element.virtual === true) return []
1222
1480
 
1481
+ if (!isJoinRelevant && flatName) baseName = flatName
1482
+ else if (isJoinRelevant) {
1483
+ const earlyResult = resolveJoinRelevantNames()
1484
+ if (earlyResult) return earlyResult
1223
1485
  } else if (!baseName && structsAreUnfoldedAlready) {
1224
1486
  baseName = element.name // name is already fully constructed
1225
1487
  } else {
@@ -1250,108 +1512,41 @@ function cqn4sql(originalQuery, model) {
1250
1512
  return getFlatColumnsFor(replacedBy, { baseName, columnAlias: replacedBy.as, tableAlias }, csnPath)
1251
1513
  }
1252
1514
 
1253
- csnPath.push(element.name)
1515
+ csnPath = [...csnPath, element.name]
1254
1516
 
1255
- if (element.keys) {
1256
- const flatColumns = []
1257
- for (const k of element.keys) {
1258
- // if only one part of a foreign key is requested, only flatten the partial key
1259
- const keyElement = getElementForRef(k.ref, getDefinition(element.target))
1260
- const flattenThisForeignKey =
1261
- !$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
1262
- element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
1263
- keyElement === stepAfterAssoc.definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1264
- if (flattenThisForeignKey) {
1265
- const fkElement = getElementForRef(k.ref, getDefinition(element.target))
1266
- let fkBaseName
1267
- if (!firstNonJoinRelevantAssoc || firstNonJoinRelevantAssoc.onlyForeignKeyAccess) fkBaseName = `${baseName}_${k.as || k.ref.at(-1)}`
1268
- // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
1269
- else fkBaseName = k.ref.at(-1)
1270
- const fkPath = [...csnPath, k.ref.at(-1)]
1271
- if (fkElement.elements) {
1272
- // structured key
1273
- for (const e of Object.values(fkElement.elements)) {
1274
- let alias
1275
- if (columnAlias) {
1276
- const fkName = k.as
1277
- ? `${k.as}_${e.name}` // foreign key might also be re-named: `assoc { id as foo }`
1278
- : `${k.ref.join('_')}_${e.name}`
1279
- alias = `${columnAlias}_${fkName}`
1280
- }
1281
- flatColumns.push(
1282
- ...getFlatColumnsFor(
1283
- e,
1284
- { baseName: fkBaseName, columnAlias: alias, tableAlias },
1285
- [...fkPath],
1286
- excludeAndReplace,
1287
- isWildcard,
1288
- ),
1289
- )
1290
- }
1291
- } else if (fkElement.isAssociation) {
1292
- // assoc as key
1293
- flatColumns.push(
1294
- ...getFlatColumnsFor(
1295
- fkElement,
1296
- { baseName, columnAlias, tableAlias },
1297
- csnPath,
1298
- excludeAndReplace,
1299
- isWildcard,
1300
- ),
1301
- )
1302
- } else {
1303
- // leaf reached
1304
- let flatColumn
1305
- if (columnAlias) {
1306
- // if the column has an explicit alias AND the original ref
1307
- // directly resolves to the foreign key, we must not append the fk name to the column alias
1308
- // e.g. `assoc.fk as FOO` => columns.alias = FOO
1309
- // `assoc as FOO` => columns.alias = FOO_fk
1310
- let columnAliasWithFlatFk
1311
- if (!(column.as && fkElement === column.$refLinks?.at(-1).definition))
1312
- columnAliasWithFlatFk = `${columnAlias}_${k.as || k.ref.join('_')}`
1313
- flatColumn = { ref: [fkBaseName], as: columnAliasWithFlatFk || columnAlias }
1314
- } else flatColumn = { ref: [fkBaseName] }
1315
- if (tableAlias) flatColumn.ref.unshift(tableAlias)
1316
-
1317
- // in a flat model, we must assign the foreign key rather than the key in the target
1318
- const flatForeignKey = getDefinition(element.parent.name)?.elements[fkBaseName]
1319
-
1320
- setElementOnColumns(flatColumn, flatForeignKey || fkElement)
1321
- defineProperty(flatColumn, '_csnPath', csnPath)
1322
- flatColumns.push(flatColumn)
1323
- }
1324
- }
1325
- }
1326
- return flatColumns
1327
- } else if (element.elements && element.type !== 'cds.Map') {
1517
+ if (element.keys) return flattenForeignKeys()
1518
+ if (element.elements && element.type !== 'cds.Map') return flattenStructElements()
1519
+ return buildScalarColumn()
1520
+
1521
+ function flattenStructElements() {
1328
1522
  const flatRefs = []
1329
- Object.values(element.elements).forEach(e => {
1523
+ for (const e of Object.values(element.elements)) {
1330
1524
  const alias = columnAlias ? `${columnAlias}_${e.name}` : null
1331
1525
  flatRefs.push(
1332
1526
  ...getFlatColumnsFor(
1333
1527
  e,
1334
1528
  { baseName, columnAlias: alias, tableAlias },
1335
- [...csnPath],
1529
+ csnPath,
1336
1530
  excludeAndReplace,
1337
1531
  isWildcard,
1338
1532
  ),
1339
1533
  )
1340
- })
1534
+ }
1341
1535
  return flatRefs
1342
1536
  }
1343
- const flatRef = tableAlias ? { ref: [tableAlias, baseName] } : { ref: [baseName] }
1344
- if (column.cast) {
1345
- flatRef.cast = column.cast
1346
- if (!columnAlias)
1347
- // provide an explicit alias
1348
- columnAlias = baseName
1537
+
1538
+ function buildScalarColumn() {
1539
+ const flatRef = tableAlias ? { ref: [tableAlias, baseName] } : { ref: [baseName] }
1540
+ if (column.cast) {
1541
+ flatRef.cast = column.cast
1542
+ if (!columnAlias) columnAlias = baseName
1543
+ }
1544
+ if (column.sort) flatRef.sort = column.sort
1545
+ if (columnAlias) flatRef.as = columnAlias
1546
+ setElementOnColumns(flatRef, element)
1547
+ defineProperty(flatRef, '_csnPath', csnPath)
1548
+ return [flatRef]
1349
1549
  }
1350
- if (column.sort) flatRef.sort = column.sort
1351
- if (columnAlias) flatRef.as = columnAlias
1352
- setElementOnColumns(flatRef, element)
1353
- defineProperty(flatRef, '_csnPath', csnPath)
1354
- return [flatRef]
1355
1550
 
1356
1551
  function getReplacement(from) {
1357
1552
  return from?.find(replacement => {
@@ -1359,6 +1554,101 @@ function cqn4sql(originalQuery, model) {
1359
1554
  return nameOfExcludedColumn === element.name
1360
1555
  })
1361
1556
  }
1557
+
1558
+ function resolveJoinRelevantNames() {
1559
+ const leafAssocIndex = column.$refLinks.findIndex(
1560
+ link => link.definition.isAssociation && link.onlyForeignKeyAccess,
1561
+ )
1562
+ firstNonJoinRelevantAssoc =
1563
+ column.$refLinks[leafAssocIndex] || [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
1564
+ stepAfterAssoc = column.$refLinks.at(leafAssocIndex + 1) || column.$refLinks.at(-1)
1565
+ const targetElements = firstNonJoinRelevantAssoc.definition.elements || firstNonJoinRelevantAssoc.definition.foreignKeys
1566
+ if (targetElements && stepAfterAssoc.definition.name in targetElements) {
1567
+ element = firstNonJoinRelevantAssoc.definition
1568
+ baseName = getFullName(firstNonJoinRelevantAssoc.definition)
1569
+ columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_')
1570
+ } else {
1571
+ baseName = getFullName(column.$refLinks.at(-1).definition)
1572
+ }
1573
+
1574
+ if (column.element && !isAssocOrStruct(column.element)) {
1575
+ columnAlias =
1576
+ column.as || (leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_'))
1577
+ const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias }
1578
+ setElementOnColumns(res, column.element)
1579
+ return [res]
1580
+ }
1581
+ return null
1582
+ }
1583
+
1584
+ function flattenForeignKeys() {
1585
+ const flatColumns = []
1586
+ for (const k of element.keys) {
1587
+ const fkElement = getElementForRef(k.ref, getDefinition(element.target))
1588
+ // if only one part of a foreign key is requested, only flatten the partial key
1589
+ const shouldFlatten =
1590
+ !$refLinks || // the association is passed as element, not as ref --> flatten full foreign key
1591
+ element === $refLinks.at(-1).definition || // the association is the leaf of the ref --> flatten full foreign key
1592
+ fkElement === stepAfterAssoc.definition // the foreign key is the leaf of the ref --> only flatten this specific foreign key
1593
+ if (!shouldFlatten) continue
1594
+
1595
+ // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
1596
+ const fkBaseName = !firstNonJoinRelevantAssoc || firstNonJoinRelevantAssoc.onlyForeignKeyAccess
1597
+ ? `${baseName}_${k.as || k.ref.at(-1)}`
1598
+ : k.ref.at(-1)
1599
+ const fkPath = [...csnPath, k.ref.at(-1)]
1600
+
1601
+ if (fkElement.elements) {
1602
+ // structured key
1603
+ for (const e of Object.values(fkElement.elements)) {
1604
+ let alias
1605
+ if (columnAlias) {
1606
+ const fkName = k.as
1607
+ ? `${k.as}_${e.name}` // foreign key might also be re-named: `assoc { id as foo }`
1608
+ : `${k.ref.join('_')}_${e.name}`
1609
+ alias = `${columnAlias}_${fkName}`
1610
+ }
1611
+ flatColumns.push(
1612
+ ...getFlatColumnsFor(
1613
+ e,
1614
+ { baseName: fkBaseName, columnAlias: alias, tableAlias },
1615
+ fkPath,
1616
+ excludeAndReplace,
1617
+ isWildcard,
1618
+ ),
1619
+ )
1620
+ }
1621
+ } else if (fkElement.isAssociation) {
1622
+ // assoc as key
1623
+ flatColumns.push(
1624
+ ...getFlatColumnsFor(fkElement, { baseName, columnAlias, tableAlias }, csnPath, excludeAndReplace, isWildcard),
1625
+ )
1626
+ } else {
1627
+ // leaf reached
1628
+ let flatColumn
1629
+ if (columnAlias) {
1630
+ // if the column has an explicit alias AND the original ref
1631
+ // directly resolves to the foreign key, we must not append the fk name to the column alias
1632
+ // e.g. `assoc.fk as FOO` => columns.alias = FOO
1633
+ // `assoc as FOO` => columns.alias = FOO_fk
1634
+ let fkAlias = columnAlias
1635
+ if (!(column.as && fkElement === column.$refLinks?.at(-1).definition))
1636
+ fkAlias = `${columnAlias}_${k.as || k.ref.join('_')}`
1637
+ flatColumn = { ref: [fkBaseName], as: fkAlias }
1638
+ } else {
1639
+ flatColumn = { ref: [fkBaseName] }
1640
+ }
1641
+ if (tableAlias) flatColumn.ref.unshift(tableAlias)
1642
+
1643
+ // in a flat model, we must assign the foreign key rather than the key in the target
1644
+ const flatForeignKey = getDefinition(element.parent.name)?.elements[fkBaseName]
1645
+ setElementOnColumns(flatColumn, flatForeignKey || fkElement)
1646
+ defineProperty(flatColumn, '_csnPath', csnPath)
1647
+ flatColumns.push(flatColumn)
1648
+ }
1649
+ }
1650
+ return flatColumns
1651
+ }
1362
1652
  }
1363
1653
 
1364
1654
  /**
@@ -1446,6 +1736,9 @@ function cqn4sql(originalQuery, model) {
1446
1736
  transformedTokenStream[i + 1] = whereExists
1447
1737
  // skip newly created subquery from being iterated
1448
1738
  i += 1
1739
+ } else if (token !== null && typeof token === 'object' && '#' in token) {
1740
+ // Enum token: resolve to its value
1741
+ transformedTokenStream.push(resolveEnumToken(token, tokenStream, i))
1449
1742
  } else if (token.list) {
1450
1743
  if (token.list.length === 0) {
1451
1744
  // replace `[not] in <empty list>` to harmonize behavior across dbs
@@ -1463,8 +1756,13 @@ function cqn4sql(originalQuery, model) {
1463
1756
  transformedTokenStream.push({ list: [] })
1464
1757
  }
1465
1758
  } else {
1466
- const { list } = token
1467
- if (list.every(e => e.val))
1759
+ let { list } = token
1760
+ // Resolve enum tokens in list items using context from the parent token stream
1761
+ if (list.some(e => e !== null && typeof e === 'object' && '#' in e)) {
1762
+ const enumDef = findEnumDefinition(tokenStream, i)
1763
+ list = list.map(item => (item !== null && typeof item === 'object' && '#' in item) ? resolveEnumToken(item, tokenStream, i, enumDef) : item)
1764
+ }
1765
+ if (list.every(e => 'val' in e))
1468
1766
  // no need for transformation
1469
1767
  transformedTokenStream.push({ list })
1470
1768
  else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
@@ -1496,14 +1794,13 @@ function cqn4sql(originalQuery, model) {
1496
1794
  ops.push(rhs)
1497
1795
  rhs = tokenStream[i + 3]
1498
1796
  indexRhs += 1
1499
- rhsDef = rhs?.$refLinks?.at(-1)?.definition
1500
1797
  }
1501
1798
 
1502
1799
  if (notSupportedOps.some(([firstOp]) => firstOp === next))
1503
1800
  throw new Error(`The operator "${next}" can only be used with scalar operands`)
1504
1801
 
1505
1802
  const newTokens = expandComparison(token, ops, rhs, $baseLink)
1506
- if(newTokens.length === 0)
1803
+ if (newTokens.length === 0)
1507
1804
  throw new Error(`Can't compare two empty structures`)
1508
1805
 
1509
1806
  const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
@@ -1538,9 +1835,9 @@ function cqn4sql(originalQuery, model) {
1538
1835
  const lastAssoc =
1539
1836
  token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1540
1837
  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)
1838
+ if (isAssocOrStruct(definition)) {
1839
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1840
+ if (flat.length === 0)
1544
1841
  throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1545
1842
  else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1546
1843
  throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
@@ -1566,6 +1863,7 @@ function cqn4sql(originalQuery, model) {
1566
1863
  }
1567
1864
  }
1568
1865
 
1866
+ if (result.cast) result.cast = resolveEnumCastType(result.cast)
1569
1867
  transformedTokenStream.push(result)
1570
1868
  }
1571
1869
  }
@@ -1673,7 +1971,7 @@ function cqn4sql(originalQuery, model) {
1673
1971
 
1674
1972
  function assertNoStructInXpr(token, context) {
1675
1973
  const definition = token.$refLinks?.at(-1).definition
1676
- if(!definition) return
1974
+ if (!definition) return
1677
1975
  const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1678
1976
  // unmanaged is always forbidden
1679
1977
  // expanding a ref in a `where`/`having` context
@@ -1785,7 +2083,7 @@ function cqn4sql(originalQuery, model) {
1785
2083
 
1786
2084
  // OData variant w/o mentioning key
1787
2085
  if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
1788
- filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
2086
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where, { $baseLink: $refLinksReverse[0] }))
1789
2087
  }
1790
2088
 
1791
2089
  if (existingWhere.length > 0) filterConditions.push(existingWhere)
@@ -2231,7 +2529,7 @@ function cqn4sql(originalQuery, model) {
2231
2529
  return SELECT
2232
2530
  }
2233
2531
 
2234
- /**
2532
+ /**
2235
2533
  * For a given search term calculate a search expression which can be used in a where clause.
2236
2534
  * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2237
2535
  * the search results of the subquery.
@@ -2259,13 +2557,13 @@ function cqn4sql(originalQuery, model) {
2259
2557
  // for aggregated queries / search on subqueries we do not do a subquery search
2260
2558
  if (inferred.SELECT.groupBy || entity.SELECT)
2261
2559
  return searchFunc
2262
-
2560
+
2263
2561
  const matchColumns = getPrimaryKey(entity)
2264
2562
  if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2265
2563
  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] }
2564
+
2565
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2566
+ return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] }
2269
2567
  }
2270
2568
 
2271
2569
  /**
@@ -2282,7 +2580,7 @@ function cqn4sql(originalQuery, model) {
2282
2580
  if (!node || !node.$refLinks || !node.ref) {
2283
2581
  throw new Error('Invalid node')
2284
2582
  }
2285
- if(node.$refLinks[0].$main) {
2583
+ if (node.$refLinks[0].$main) {
2286
2584
  if (node.isJoinRelevant) {
2287
2585
  return getJoinRelevantAlias(node)
2288
2586
  }
@@ -2354,6 +2652,116 @@ function cqn4sql(originalQuery, model) {
2354
2652
  }
2355
2653
  return result
2356
2654
  }
2655
+
2656
+ /**
2657
+ * Resolves an enum token to a value literal.
2658
+ *
2659
+ * If the token already has a `val`, it is used directly.
2660
+ * Otherwise, the enum value is resolved by looking up the symbol
2661
+ * in the enum definition found from the surrounding context.
2662
+ *
2663
+ * @param {object} token - The enum token with a `#` property.
2664
+ * @param {object[]} tokenStream - The surrounding token stream for context discovery.
2665
+ * @param {number} index - The index of the enum token in the token stream.
2666
+ * @param {object} [enumDef] - An already-discovered enum definition (optimization for lists).
2667
+ * @returns {object} A value token `{ val: resolvedValue }`.
2668
+ */
2669
+ function resolveEnumToken(token, tokenStream, index, enumDef) {
2670
+ if ('val' in token) {
2671
+ const result = { val: token.val }
2672
+ if (token.cast) result.cast = resolveEnumCastType(token.cast)
2673
+ return result
2674
+ }
2675
+
2676
+ // Check if the token itself has a cast with an enum type
2677
+ if (!enumDef && token.cast?.type) {
2678
+ const typeDef = model.definitions[token.cast.type]
2679
+ if (typeDef?.enum) enumDef = typeDef.enum
2680
+ }
2681
+
2682
+ if (!enumDef) enumDef = findEnumDefinition(tokenStream, index)
2683
+ if (!enumDef) {
2684
+ throw new Error(`Can't resolve enum value "#${token['#']}"`)
2685
+ }
2686
+
2687
+ const entry = enumDef[token['#']]
2688
+ if (!entry) {
2689
+ throw new Error(`Unknown enum symbol "#${token['#']}"`)
2690
+ }
2691
+
2692
+ const result = { val: 'val' in entry ? entry.val : token['#'] }
2693
+ if (token.cast) result.cast = resolveEnumCastType(token.cast)
2694
+ return result
2695
+ }
2696
+
2697
+ /**
2698
+ * If `cast.type` refers to a user-defined enum type, resolves it to the
2699
+ * underlying scalar CDS built-in type so that the SQL builder (`cqn2sql`)
2700
+ * can render a valid SQL type name.
2701
+ *
2702
+ * Example: `{ type: 'enums.Priority' }` → `{ type: 'cds.Integer' }`
2703
+ *
2704
+ * Non-enum types (including CDS built-ins) are returned unchanged.
2705
+ *
2706
+ * @param {object} cast - The cast descriptor with a `type` property.
2707
+ * @returns {object} The cast descriptor with the resolved type.
2708
+ */
2709
+ function resolveEnumCastType(cast) {
2710
+ if (!cast?.type) return cast
2711
+ let def = model.definitions[cast.type]
2712
+ while (def?.enum) {
2713
+ const baseType = def.type
2714
+ if (!baseType) return cast // no base type declared – leave as-is
2715
+ if (cds.builtin.types[baseType]) return { ...cast, type: baseType }
2716
+ def = model.definitions[baseType]
2717
+ }
2718
+ return cast
2719
+ }
2720
+
2721
+ /**
2722
+ * Scans the token stream around the given index to find an element
2723
+ * definition that has an `enum` property, which can be used to resolve
2724
+ * enum symbols to their values.
2725
+ *
2726
+ * @param {object[]} tokenStream - The token stream to scan.
2727
+ * @param {number} index - The index of the enum token.
2728
+ * @returns {object|null} The enum definition object, or null if not found.
2729
+ */
2730
+ function findEnumDefinition(tokenStream, index) {
2731
+ // Scan backward
2732
+ for (let j = index - 1; j >= 0; j--) {
2733
+ const t = tokenStream[j]
2734
+ if (typeof t === 'string') continue // operators, keywords
2735
+ if (t !== null && typeof t === 'object' && '#' in t) continue // other enum tokens
2736
+ if ('val' in t && !t.ref) continue // plain value literals
2737
+
2738
+ const def = t.$refLinks?.at(-1)?.definition
2739
+ if (def?.enum) return def.enum
2740
+ if (t.cast?.type) {
2741
+ const typeDef = model.definitions[t.cast.type]
2742
+ if (typeDef?.enum) return typeDef.enum
2743
+ }
2744
+ if (def) break // found a ref without enum type, stop
2745
+ }
2746
+
2747
+ // Scan forward
2748
+ for (let j = index + 1; j < tokenStream.length; j++) {
2749
+ const t = tokenStream[j]
2750
+ if (typeof t === 'string') continue
2751
+ if (t !== null && typeof t === 'object' && '#' in t) continue
2752
+ if ('val' in t && !t.ref) continue
2753
+
2754
+ const def = t.$refLinks?.at(-1)?.definition
2755
+ if (def?.enum) return def.enum
2756
+ if (t.cast?.type) {
2757
+ const typeDef = model.definitions[t.cast.type]
2758
+ if (typeDef?.enum) return typeDef.enum
2759
+ }
2760
+ if (def) break
2761
+ }
2762
+
2763
+ return null
2764
+ }
2357
2765
  }
2358
2766
 
2359
2767
  function calculateElementName(token) {
@@ -2425,7 +2833,7 @@ function assignQueryModifiers(SELECT, modifiers) {
2425
2833
  else SELECT.having.push('and', ...val)
2426
2834
  } else if (key === 'where') {
2427
2835
  // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2428
- if(!val || val.length === 1 && val[0].val) continue
2836
+ if (!val || val.length === 1 && val[0].val) continue
2429
2837
  if (!SELECT.where) SELECT.where = val
2430
2838
  // infix filter comes first in resulting where
2431
2839
  else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]