@sap/cds-dk 10.0.3 → 10.0.5

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/debug.js +6 -2
  3. package/bin/up.js +1 -1
  4. package/bin/watch.js +1 -0
  5. package/lib/build/plugins/helm/files/templates/_deployment_helpers.tpl +2 -0
  6. package/lib/build/plugins/nodejs/index.js +6 -3
  7. package/lib/build/util.js +1 -1
  8. package/lib/init/template/ams/files/containerize.yaml.hbs +1 -0
  9. package/lib/init/template/approuter/files/values.yaml.hbs +9 -1
  10. package/lib/init/template/data/as-csv.js +4 -2
  11. package/lib/init/template/data/as-json.js +4 -1
  12. package/lib/init/template/github-actions/files/workflows/cf.yaml.hbs +6 -6
  13. package/lib/init/template/ias/files/values.yaml.hbs +4 -4
  14. package/lib/init/template/portal/files/values.yaml.hbs +12 -0
  15. package/lib/init/template/sample/files/app/browse/fiori-service.cds +2 -0
  16. package/lib/init/template/typer/files/xsconfig.json +1 -1
  17. package/lib/init/template/typescript/files/package.json +1 -1
  18. package/lib/lint/eslintHelp.json +1 -1
  19. package/lib/watch/esm-resolve-registration.js +27 -0
  20. package/lib/watch/vite-watch.js +1 -1
  21. package/node_modules/@cap-js/db-service/CHANGELOG.md +768 -0
  22. package/node_modules/@cap-js/db-service/LICENSE +201 -0
  23. package/node_modules/@cap-js/db-service/README.md +26 -0
  24. package/node_modules/@cap-js/db-service/index.js +19 -0
  25. package/node_modules/@cap-js/db-service/lib/InsertResults.js +122 -0
  26. package/node_modules/@cap-js/db-service/lib/SQLService.js +581 -0
  27. package/node_modules/@cap-js/db-service/lib/common/DatabaseService.js +179 -0
  28. package/node_modules/@cap-js/db-service/lib/common/factory.d.ts +5 -0
  29. package/node_modules/@cap-js/db-service/lib/common/generic-pool.js +310 -0
  30. package/node_modules/@cap-js/db-service/lib/common/session-context.js +46 -0
  31. package/node_modules/@cap-js/db-service/lib/converters.d.ts +24 -0
  32. package/node_modules/@cap-js/db-service/lib/cql-functions.js +464 -0
  33. package/node_modules/@cap-js/db-service/lib/cqn2pql.js +116 -0
  34. package/node_modules/@cap-js/db-service/lib/cqn2sql.js +1612 -0
  35. package/node_modules/@cap-js/db-service/lib/cqn4sql.js +2895 -0
  36. package/node_modules/@cap-js/db-service/lib/deep-queries.js +290 -0
  37. package/node_modules/@cap-js/db-service/lib/fill-in-keys.js +75 -0
  38. package/node_modules/@cap-js/db-service/lib/infer/cqn.d.ts +42 -0
  39. package/node_modules/@cap-js/db-service/lib/infer/index.js +1290 -0
  40. package/node_modules/@cap-js/db-service/lib/infer/join-tree.js +278 -0
  41. package/node_modules/@cap-js/db-service/lib/infer/pseudos.js +24 -0
  42. package/node_modules/@cap-js/db-service/lib/search.js +220 -0
  43. package/node_modules/@cap-js/db-service/lib/utils.js +177 -0
  44. package/node_modules/@cap-js/db-service/package.json +36 -0
  45. package/node_modules/@cap-js/sqlite/CHANGELOG.md +404 -0
  46. package/node_modules/@cap-js/sqlite/LICENSE +201 -0
  47. package/node_modules/@cap-js/sqlite/README.md +34 -0
  48. package/node_modules/@cap-js/sqlite/cds-plugin.js +0 -0
  49. package/node_modules/@cap-js/sqlite/index.js +2 -0
  50. package/node_modules/@cap-js/sqlite/lib/SQLiteService.js +324 -0
  51. package/node_modules/@cap-js/sqlite/lib/cql-functions.js +160 -0
  52. package/node_modules/@cap-js/sqlite/lib/node-sqlite.js +36 -0
  53. package/node_modules/@cap-js/sqlite/lib/session.json +3 -0
  54. package/node_modules/@cap-js/sqlite/lib/sql.js.js +99 -0
  55. package/node_modules/@cap-js/sqlite/package.json +68 -0
  56. package/node_modules/@sap/cds-compiler/CHANGELOG.md +14 -0
  57. package/node_modules/@sap/cds-compiler/bin/cdsse.js +1 -1
  58. package/node_modules/@sap/cds-compiler/bin/cdsv2m.js +1 -1
  59. package/node_modules/@sap/cds-compiler/doc/IncompatibleChanges_v7.md +20 -13
  60. package/node_modules/@sap/cds-compiler/lib/base/builtins.js +4 -1
  61. package/node_modules/@sap/cds-compiler/lib/base/message-registry.js +15 -2
  62. package/node_modules/@sap/cds-compiler/lib/checks/defaultValues.js +0 -1
  63. package/node_modules/@sap/cds-compiler/lib/compiler/resolve.js +6 -5
  64. package/node_modules/@sap/cds-compiler/lib/compiler/tweak-assocs.js +27 -6
  65. package/node_modules/@sap/cds-compiler/lib/edm/annotations/edmJson.js +5 -0
  66. package/node_modules/@sap/cds-compiler/lib/json/from-csn.js +3 -3
  67. package/node_modules/@sap/cds-compiler/lib/json/to-csn.js +5 -4
  68. package/node_modules/@sap/cds-compiler/lib/parsers/AstBuildingParser.js +14 -3
  69. package/node_modules/@sap/cds-compiler/lib/tool-lib/enrichCsn.js +1 -1
  70. package/node_modules/@sap/cds-compiler/lib/transform/db/cdsPersistence.js +3 -2
  71. package/node_modules/@sap/cds-compiler/lib/transform/db/views.js +2 -2
  72. package/node_modules/@sap/cds-compiler/lib/transform/transformUtils.js +1 -2
  73. package/node_modules/@sap/cds-compiler/package.json +1 -1
  74. package/node_modules/iconv-lite/encodings/sbcs-data.js +2 -0
  75. package/node_modules/iconv-lite/encodings/utf32.js +10 -3
  76. package/node_modules/iconv-lite/package.json +2 -2
  77. package/node_modules/iconv-lite/types/encodings.d.ts +2 -0
  78. package/node_modules/ws/lib/receiver.js +15 -32
  79. package/node_modules/ws/lib/websocket-server.js +4 -4
  80. package/node_modules/ws/lib/websocket.js +4 -4
  81. package/node_modules/ws/package.json +5 -1
  82. package/package.json +5 -3
@@ -0,0 +1,2895 @@
1
+ 'use strict'
2
+
3
+ const cds = require('@sap/cds')
4
+ cds.infer.target ??= q => q._target || q.target // instanceof cds.entity ? q._target : q.target
5
+
6
+ const infer = require('./infer')
7
+ const { computeColumnsToBeSearched } = require('./search')
8
+ const {
9
+ prettyPrintRef,
10
+ isCalculatedOnRead,
11
+ isCalculatedElement,
12
+ getImplicitAlias: _getImplicitAlias,
13
+ defineProperty,
14
+ getModelUtils,
15
+ hasOwnSkip,
16
+ isRuntimeView,
17
+ } = require('./utils')
18
+
19
+ /**
20
+ * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
21
+ * If there are at least two leaf elements and if there are tokens before or after the recognized pattern, we enclose the resulting condition in parens (...)
22
+ */
23
+ const eqOps = [['is'], ['='] /* ['=='] */]
24
+ /**
25
+ * For operators of <notEqOps>, do the same but use `or` instead of `and`.
26
+ * This ensures that `not struct == <value>` is the same as `struct != <value>`.
27
+ */
28
+ const notEqOps = [['is', 'not'], ['<>'], ['!=']]
29
+ /**
30
+ * not supported in comparison w/ struct because of unclear semantics
31
+ */
32
+ const notSupportedOps = [['>'], ['<'], ['>='], ['<='], ['*'], ['+'], ['-'], ['/']]
33
+
34
+ const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
35
+
36
+ const { pseudos } = require('./infer/pseudos')
37
+ /**
38
+ * Transforms a CDL style query into SQL-Like CQN:
39
+ * - transform association paths in `from` to `WHERE exists` subqueries
40
+ * - transforms columns into their flat representation.
41
+ * 1. Flatten managed associations to their foreign
42
+ * 2. Flatten structures to their leafs
43
+ * 3. Replace join-relevant ref paths (i.e. non-fk accesses in association paths) with the correct join alias
44
+ * - transforms `expand` columns into special, normalized subqueries
45
+ * - transform `where` clause.
46
+ * That is the flattening of all `ref`s and the expansion of `where exists` predicates
47
+ * - rewrites `from` clause:
48
+ * Each join relevant association path traversal is translated to a join condition.
49
+ *
50
+ * `cqn4sql` is applied recursively to all queries found in `from`, `columns` and `where`
51
+ * of a query.
52
+ *
53
+ * @param {object} originalQuery
54
+ * @param {object} model
55
+ * @returns {object} transformedQuery the transformed query
56
+ */
57
+ function cqn4sql(originalQuery, model, useTechnicalAlias = true) {
58
+ const getImplicitAlias = str => _getImplicitAlias(str, useTechnicalAlias)
59
+ let inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
60
+ const hasCustomJoins =
61
+ originalQuery.SELECT?.from.args && (!originalQuery.joinTree || originalQuery.joinTree.isInitial)
62
+
63
+ if (!hasCustomJoins && inferred.SELECT?.search) {
64
+ // we need an instance of query because the elements of the query are needed for the calculation of the search columns
65
+ if (!inferred.SELECT.elements) Object.setPrototypeOf(inferred, SELECT.class.prototype)
66
+ const searchTerm = getSearch(inferred.SELECT.search, inferred)
67
+ if (searchTerm) {
68
+ // Search target can be a navigation, in that case use _target to get the correct entity
69
+ const { where, having } = transformSearch(searchTerm)
70
+ if (where) inferred.SELECT.where = where
71
+ else if (having) inferred.SELECT.having = having
72
+ }
73
+ }
74
+ // query modifiers can also be defined in from ref leaf infix filter
75
+ // > SELECT from bookshop.Books[order by price] {ID}
76
+ if (inferred.SELECT?.from.ref?.at(-1).id) {
77
+ assignQueryModifiers(inferred.SELECT, inferred.SELECT.from.ref.at(-1))
78
+ }
79
+ if (inferred.DELETE?.from.ref?.at(-1).id) {
80
+ assignQueryModifiers(inferred.DELETE, inferred.DELETE.from.ref.at(-1))
81
+ }
82
+ if (inferred.UPDATE?.entity.ref?.at(-1).id) {
83
+ assignQueryModifiers(inferred.UPDATE, inferred.UPDATE.entity.ref.at(-1))
84
+ }
85
+ inferred = infer(inferred, model, useTechnicalAlias)
86
+ const { getLocalizedName, isLocalized, getDefinition } = getModelUtils(model, originalQuery) // TODO: pass model to getModelUtils
87
+ // if the query has custom joins we don't want to transform it
88
+ // TODO: move all the way to the top of this function once cds.infer supports joins as well
89
+ // we need to infer the query even if no transformation will happen because cds.infer can't calculate the target
90
+ if (hasCustomJoins) return originalQuery
91
+
92
+ let transformedQuery = cds.ql.clone(inferred)
93
+ const kind = inferred.kind || Object.keys(inferred)[0]
94
+
95
+ if (inferred.INSERT || inferred.UPSERT) {
96
+ transformedQuery = transformQueryForInsertUpsert(kind)
97
+ } else {
98
+ const queryProp = inferred[kind]
99
+ const { entity, where } = queryProp
100
+ const from = queryProp.from
101
+
102
+ const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
103
+ const queryNeedsJoins = inferred.joinTree && !inferred.joinTree.isInitial
104
+
105
+ // Transform the existing where, prepend table aliases, and so on...
106
+ if (where) {
107
+ transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
108
+ }
109
+
110
+ // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
111
+ // The already transformed `where` clause is then glued together with the resulting subqueries.
112
+ const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
113
+
114
+ // build a subquery for DELETE / UPDATE queries with path expressions and match the primary keys
115
+ if (queryNeedsJoins && (inferred.UPDATE || inferred.DELETE)) {
116
+ const prop = inferred.UPDATE ? 'UPDATE' : 'DELETE'
117
+ const subquery = {
118
+ SELECT: {
119
+ from: { ...(from || entity) },
120
+ columns: [], // primary keys of the query target will be added later
121
+ where: [...where],
122
+ },
123
+ }
124
+ // The alias of the original query is now the alias for the subquery
125
+ // so that potential references in the where clause to the alias match.
126
+ // Hence, replace the alias of the original query with the next
127
+ // available alias, so that each alias is unique.
128
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(transformedFrom.as)
129
+ transformedFrom.as = uniqueSubqueryAlias
130
+
131
+ // calculate the primary keys of the target entity, there is always exactly
132
+ // one query source for UPDATE / DELETE
133
+ const queryTarget = Object.values(inferred.sources)[0].definition
134
+ const primaryKey = { list: getPrimaryKey(queryTarget, uniqueSubqueryAlias) }
135
+ // match primary keys of the target entity with the subquery
136
+ primaryKey.list.forEach(k => subquery.SELECT.columns.push({ ref: k.ref.slice(1) }))
137
+
138
+ const transformedSubquery = cqn4sql(subquery, model, useTechnicalAlias)
139
+
140
+ // replace where condition of original query with the transformed subquery
141
+ // correlate UPDATE / DELETE query with subquery by primary key matches
142
+ transformedQuery[prop].where = [primaryKey, 'in', transformedSubquery]
143
+
144
+ if (prop === 'UPDATE') transformedQuery.UPDATE.entity = transformedFrom
145
+ else transformedQuery.DELETE.from = transformedFrom
146
+
147
+ return transformedQuery
148
+ }
149
+
150
+ if (inferred.SELECT) {
151
+ transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
152
+ } else {
153
+ if (from) {
154
+ transformedProp.from = transformedFrom
155
+ } else if (!queryNeedsJoins) {
156
+ transformedProp.entity = transformedFrom
157
+ }
158
+
159
+ if (transformedWhere?.length > 0) {
160
+ transformedProp.where = transformedWhere
161
+ }
162
+
163
+ transformedQuery[kind] = transformedProp
164
+
165
+ if (inferred.UPDATE?.with) {
166
+ Object.entries(inferred.UPDATE.with).forEach(([key, val]) => {
167
+ const transformed = getTransformedTokenStream([val])
168
+ inferred.UPDATE.with[key] = transformed[0]
169
+ })
170
+ }
171
+ }
172
+
173
+ if (queryNeedsJoins) {
174
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
175
+ }
176
+ }
177
+
178
+ if (cds.env.features.runtime_views) processRuntimeViews(transformedQuery, model)
179
+
180
+ return transformedQuery
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
+
286
+ function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
287
+ const { columns, having, groupBy, orderBy, limit } = queryProp
288
+
289
+ // Trivial replacement -> no transformations needed
290
+ if (limit) {
291
+ transformedQuery.SELECT.limit = limit
292
+ }
293
+
294
+ transformedQuery.SELECT.from = transformedFrom
295
+
296
+ if (transformedWhere?.length > 0) {
297
+ transformedQuery.SELECT.where = transformedWhere
298
+ }
299
+
300
+ if (columns) {
301
+ transformedQuery.SELECT.columns = getTransformedColumns(columns)
302
+ } else {
303
+ transformedQuery.SELECT.columns = getColumnsForWildcard(inferred.SELECT?.excluding)
304
+ }
305
+
306
+ // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
307
+ if (having) {
308
+ transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
309
+ }
310
+
311
+ if (groupBy) {
312
+ const transformedGroupBy = getTransformedOrderByGroupBy(groupBy)
313
+ if (transformedGroupBy.length) {
314
+ transformedQuery.SELECT.groupBy = transformedGroupBy
315
+ }
316
+ }
317
+
318
+ // Since all the expressions in the SELECT part of the query have been computed,
319
+ // one can reference aliases of the queries columns in the orderBy clause.
320
+ if (orderBy) {
321
+ const transformedOrderBy = getTransformedOrderByGroupBy(orderBy, true)
322
+ if (transformedOrderBy.length) {
323
+ transformedQuery.SELECT.orderBy = transformedOrderBy
324
+ }
325
+ }
326
+ return transformedQuery
327
+ }
328
+
329
+ /**
330
+ * Transforms a query object for INSERT or UPSERT operations by modifying the `into` clause.
331
+ *
332
+ * @param {string} kind - The type of operation: "INSERT" or "UPSERT".
333
+ *
334
+ * @returns {object} - The transformed query with updated `into` clause.
335
+ */
336
+ function transformQueryForInsertUpsert(kind) {
337
+ const { as } = transformedQuery[kind].into
338
+ const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
339
+ transformedQuery[kind].into = { ref: [target.name] }
340
+ if (as) transformedQuery[kind].into.as = as
341
+ return transformedQuery
342
+ }
343
+
344
+ /**
345
+ * Transforms a search expression into a WHERE or HAVING clause for a SELECT operation, depending on the context of the query.
346
+ * The function decides whether to use a WHERE or HAVING clause based on the presence of aggregated columns in the search criteria.
347
+ *
348
+ * @param {object} searchTerm - The search expression to be applied to the searchable columns within the query source.
349
+ * @param {object} from - The FROM clause of the CQN statement.
350
+ *
351
+ * @returns {Object} - The function returns an object representing the WHERE or HAVING clause of the query.
352
+ *
353
+ * Note: The WHERE clause is used for filtering individual rows before any aggregation occurs.
354
+ * The HAVING clause is utilized for conditions on aggregated data, applied after grouping operations.
355
+ */
356
+ function transformSearch(searchTerm) {
357
+ let prop = 'where'
358
+
359
+ // if the query is grouped and the queries columns contain an aggregate function,
360
+ // we must put the search term into the `having` clause, as the search expression
361
+ // is defined on the aggregated result, not on the individual rows
362
+ const usesAggregation =
363
+ inferred.SELECT.groupBy &&
364
+ (searchTerm.args[0].func || searchTerm.args[0].xpr || searchTerm.args[0].list?.some(c => c.func || c.xpr))
365
+
366
+ if (usesAggregation) prop = 'having'
367
+ if (inferred.SELECT[prop]) {
368
+ return { [prop]: [asXpr(inferred.SELECT.where), 'and', searchTerm] }
369
+ } else {
370
+ return { [prop]: searchTerm.xpr ? [...searchTerm.xpr] : [searchTerm] }
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Rewrites the from clause based on the `query.joinTree`.
376
+ *
377
+ * For each join relevant node in the join tree, the respective join is generated.
378
+ * Each join relevant node in the join tree has an unique table alias which is the query source for the respective
379
+ * path traversals. Hence, all join relevant `ref`s must be rewritten to point to the generated join aliases. However,
380
+ * this is done in the @function getFlatColumnsFor().
381
+ *
382
+ * @returns {CQN.from}
383
+ */
384
+ function translateAssocsToJoins() {
385
+ let from
386
+ /**
387
+ * remember already seen aliases, do not create a join for them again
388
+ */
389
+ const alreadySeen = new Map()
390
+ inferred.joinTree._roots.forEach(r => {
391
+ const args = []
392
+ if (r.queryArtifact.SELECT) {
393
+ const transformedSubquery = transformSubquery(r.queryArtifact)
394
+ transformedSubquery.as = r.alias
395
+ args.push(transformedSubquery)
396
+ }
397
+ else {
398
+ const id = getLocalizedName(r.queryArtifact)
399
+ args.push({ ref: [r.args ? { id, args: r.args } : id], as: r.alias })
400
+ }
401
+ from = { join: r.join || 'left', args, on: [] }
402
+ r.children.forEach(c => {
403
+ from = joinForBranch(from, c)
404
+ from = { join: c.join || 'left', args: [from], on: [] }
405
+ })
406
+ })
407
+ return from.args.length > 1 ? from : from.args[0]
408
+
409
+ function joinForBranch(lhs, node) {
410
+ const nextAssoc = inferred.joinTree.findNextAssoc(node)
411
+ if (!nextAssoc || alreadySeen.has(nextAssoc.$refLink.alias)) return lhs.args.length > 1 ? lhs : lhs.args[0]
412
+
413
+ lhs.on.push(
414
+ ...onCondFor(
415
+ nextAssoc.$refLink,
416
+ node.parent.$refLink || /** tree roots do not have $refLink */ {
417
+ alias: node.parent.alias,
418
+ definition: node.parent.queryArtifact,
419
+ target: node.parent.queryArtifact,
420
+ },
421
+ /** flip source and target in on condition */ true,
422
+ ),
423
+ )
424
+
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)
428
+ const { args } = nextAssoc
429
+ const arg = {
430
+ ref: [args ? { id, args } : id],
431
+ as: nextAssoc.$refLink.alias,
432
+ }
433
+
434
+ lhs.args.push(arg)
435
+ alreadySeen.set(nextAssoc.$refLink.alias, true)
436
+ if (nextAssoc.where) {
437
+ const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
438
+ lhs.on = [
439
+ ...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
440
+ 'and',
441
+ ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter),
442
+ ]
443
+ }
444
+ if (node.children) {
445
+ node.children.forEach(c => {
446
+ lhs = { join: c.join || 'left', args: [lhs], on: [] }
447
+ lhs = joinForBranch(lhs, c)
448
+ })
449
+ }
450
+ return lhs.args.length > 1 ? lhs : lhs.args[0]
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
456
+ *
457
+ * @param {object[]} columns
458
+ * @returns {object[]} the transformed representation of the input. Expanded and flattened.
459
+ */
460
+ function getTransformedColumns(columns) {
461
+ const transformedColumns = []
462
+ for (let i = 0; i < columns.length; i++) {
463
+ const col = columns[i]
464
+
465
+ if (isCalculatedOnRead(col.$refLinks?.at(-1).definition) && !col.$refLinks?.at(-1).target?.SELECT) {
466
+ const name = getName(col)
467
+ if (!transformedColumns.some(inserted => getName(inserted) === name)) {
468
+ const calcElement = resolveCalculatedElement(col)
469
+ transformedColumns.push(calcElement)
470
+ }
471
+ } else if (col.expand) {
472
+ if (col.ref?.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
473
+ const dollarSelfReplacement = calculateDollarSelfColumn(col)
474
+ transformedColumns.push(...getTransformedColumns([dollarSelfReplacement]))
475
+ continue
476
+ }
477
+ transformedColumns.push(() => {
478
+ const expandResult = handleExpand(col)
479
+ if (expandResult.length > 1) {
480
+ return expandResult
481
+ } else {
482
+ return expandResult[0]
483
+ }
484
+ })
485
+ } else if (col.inline) {
486
+ handleInline(col)
487
+ } else if (col.ref) {
488
+ if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
489
+ const dollarSelfReplacement = calculateDollarSelfColumn(col)
490
+ transformedColumns.push(...getTransformedColumns([dollarSelfReplacement]))
491
+ continue
492
+ }
493
+ handleRef(col)
494
+ } else if (col === '*') {
495
+ handleWildcard(columns)
496
+ } else if (col.SELECT) {
497
+ handleSubquery(col)
498
+ } else {
499
+ handleDefault(col)
500
+ }
501
+ }
502
+ // subqueries are processed in the end
503
+ for (let i = 0; i < transformedColumns.length; i++) {
504
+ const c = transformedColumns[i]
505
+ if (typeof c === 'function') {
506
+ const res = c() || [] // target of expand / subquery could also be skipped -> no result
507
+ if (res.length !== undefined) {
508
+ transformedColumns.splice(i, 1, ...res)
509
+ i += res.length - 1
510
+ } else {
511
+ const replaceWith = res.as
512
+ ? transformedColumns.findIndex(t => (t.as || t.ref?.[t.ref.length - 1]) === res.as)
513
+ : -1
514
+ if (replaceWith === -1) transformedColumns.splice(i, 1, res)
515
+ else {
516
+ transformedColumns.splice(replaceWith, 1, res)
517
+ transformedColumns.splice(i, 1)
518
+ // When removing an element, the next element moves to the current index
519
+ i--
520
+ }
521
+ }
522
+ }
523
+ }
524
+
525
+ if (transformedColumns.length === 0 && columns.length) {
526
+ handleEmptyColumns(columns)
527
+ }
528
+
529
+ return transformedColumns
530
+
531
+ function handleSubquery(col) {
532
+ transformedColumns.push(() => {
533
+ const res = transformSubquery(col)
534
+ if (col.as) res.as = col.as
535
+ return res
536
+ })
537
+ }
538
+
539
+ function handleExpand(col) {
540
+ const { $refLinks } = col
541
+ const res = []
542
+ const last = $refLinks?.[$refLinks.length - 1]
543
+ if (last && !last.skipExpand && last.definition.isAssociation) {
544
+ const expandedSubqueryColumn = expandColumn(col)
545
+ if (!expandedSubqueryColumn) return []
546
+ setElementOnColumns(expandedSubqueryColumn, col.element)
547
+ res.push(expandedSubqueryColumn)
548
+ } else if (!last?.skipExpand) {
549
+ const expandCols = nestedProjectionOnStructure(col, 'expand')
550
+ res.push(...expandCols)
551
+ }
552
+ return res
553
+ }
554
+
555
+ function handleInline(col) {
556
+ const inlineCols = nestedProjectionOnStructure(col)
557
+ transformedColumns.push(...inlineCols)
558
+ }
559
+
560
+ function handleRef(col) {
561
+ if (pseudos.elements[col.ref[0]] || col.param) {
562
+ transformedColumns.push({ ...col })
563
+ return
564
+ }
565
+
566
+ const tableAlias = getTableAlias(col)
567
+ // re-adjust usage of implicit alias in subquery
568
+ if (col.$refLinks[0].definition.kind === 'entity' && col.ref[0] !== tableAlias) {
569
+ col.ref[0] = tableAlias
570
+ }
571
+ const leaf = col.$refLinks[col.$refLinks.length - 1].definition
572
+ if (leaf.virtual === true) return
573
+
574
+ let baseName
575
+ if (col.ref.length >= 2) {
576
+ baseName = col.ref
577
+ .map(idOnly)
578
+ .slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1)
579
+ .join('_')
580
+ }
581
+
582
+ let columnAlias = col.as || (col.isJoinRelevant ? col.flatName : null)
583
+ const refNavigation = col.ref.slice(col.$refLinks[0].definition.kind !== 'element' ? 1 : 0).join('_')
584
+ if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
585
+
586
+ if (
587
+ col.$refLinks.some(link => {
588
+ const def = getDefinition(link.definition.target)
589
+ return hasOwnSkip(def) && !isRuntimeView(def)
590
+ })
591
+ )
592
+ return
593
+
594
+ const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
595
+ flatColumns.forEach(flatColumn => {
596
+ const name = getName(flatColumn)
597
+ if (!transformedColumns.some(inserted => getName(inserted) === name)) transformedColumns.push(flatColumn)
598
+ })
599
+ }
600
+
601
+ function handleWildcard(columns) {
602
+ const wildcardIndex = columns.indexOf('*')
603
+ const ignoreInWildcardExpansion = columns.slice(0, wildcardIndex)
604
+ const { excluding } = inferred.SELECT
605
+ if (excluding) ignoreInWildcardExpansion.push(...excluding)
606
+
607
+ const wildcardColumns = getColumnsForWildcard(ignoreInWildcardExpansion, columns.slice(wildcardIndex + 1))
608
+ transformedColumns.push(...wildcardColumns)
609
+ }
610
+
611
+ function handleDefault(col) {
612
+ let transformedColumn = getTransformedColumn(col)
613
+ if (col.as) transformedColumn.as = col.as
614
+
615
+ const replaceWith = transformedColumns.findIndex(
616
+ t => (t.as || t.ref?.[t.ref.length - 1]) === transformedColumn.as,
617
+ )
618
+ if (replaceWith === -1) transformedColumns.push(transformedColumn)
619
+ else transformedColumns.splice(replaceWith, 1, transformedColumn)
620
+
621
+ setElementOnColumns(transformedColumn, inferred.elements[col.as])
622
+ }
623
+
624
+ function getTransformedColumn(col) {
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
+ }
631
+ if (col.func) {
632
+ ret = {
633
+ func: col.func,
634
+ args: getTransformedFunctionArgs(col.args),
635
+ as: col.func, // may be overwritten by the explicit alias
636
+ }
637
+ }
638
+ if (col.xpr) {
639
+ ret ??= {}
640
+ ret.xpr = getTransformedTokenStream(col.xpr)
641
+ }
642
+ if (ret) {
643
+ if (col.cast) ret.cast = resolveEnumCastType(col.cast)
644
+ return ret
645
+ }
646
+ return copy(col)
647
+ }
648
+
649
+ function handleEmptyColumns(columns) {
650
+ if (columns.some(c => c.$refLinks?.[c.$refLinks.length - 1].definition.type === 'cds.Composition')) return
651
+ throw new Error('Queries must have at least one non-virtual column')
652
+ }
653
+ }
654
+
655
+ function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
656
+ let value
657
+
658
+ if (column.$refLinks) {
659
+ const { $refLinks } = column
660
+ value = $refLinks[$refLinks.length - 1].definition.value
661
+ if (column.$refLinks.length > 1) {
662
+ $baseLink =
663
+ [...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
664
+ // if there is no association in the path, the table alias is the base link
665
+ // TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
666
+ column.$refLinks[0]
667
+ }
668
+ } else {
669
+ value = column.value
670
+ }
671
+ const { ref, val, xpr, func } = value
672
+
673
+ let res
674
+ if (ref) {
675
+ res = getTransformedTokenStream([value], { $baseLink })[0]
676
+ } else if (xpr) {
677
+ res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
678
+ } else if (val !== undefined) {
679
+ res = { val }
680
+ } else if (func) {
681
+ res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
682
+ }
683
+ if (!omitAlias) res.as = column.as || column.name || column.flatName
684
+ setElementOnColumns(res, column.element || column)
685
+ return res
686
+ }
687
+
688
+ /**
689
+ * This function resolves a `ref` starting with a `$self`.
690
+ * Such a path targets another element of the query by it's implicit, or explicit alias.
691
+ *
692
+ * A `$self` reference may also target another `$self` path. In this case, this function
693
+ * recursively resolves the tail of the `$self` references (`$selfPath.ref.slice(2)`) onto it's
694
+ * new base.
695
+ *
696
+ * @param {object} col with a ref like `[ '$self', <target column>, <optional further path navigation> ]`
697
+ * @param {boolean} omitAlias if we replace a $self reference in an aggregation or a token stream, we must not add an "as" to the result
698
+ */
699
+ function calculateDollarSelfColumn(col, omitAlias = false) {
700
+ const dummyColumn = buildDummyColumnForDollarSelf({ ...col }, col.$refLinks)
701
+
702
+ return dummyColumn
703
+
704
+ function buildDummyColumnForDollarSelf(dollarSelfColumn, $refLinks) {
705
+ const { ref, as } = dollarSelfColumn
706
+ const stepToFind = ref[1]
707
+ let referencedColumn = inferred.SELECT.columns.find(
708
+ otherColumn =>
709
+ otherColumn !== dollarSelfColumn &&
710
+ (otherColumn.as
711
+ ? stepToFind === otherColumn.as
712
+ : stepToFind === otherColumn.ref?.[otherColumn.ref.length - 1]),
713
+ )
714
+ if (referencedColumn.ref?.[0] === '$self') {
715
+ referencedColumn = buildDummyColumnForDollarSelf({ ...referencedColumn }, referencedColumn.$refLinks)
716
+ }
717
+
718
+ if (referencedColumn.ref) {
719
+ dollarSelfColumn.ref = [...referencedColumn.ref, ...dollarSelfColumn.ref.slice(2)]
720
+ Object.defineProperties(dollarSelfColumn, {
721
+ flatName: {
722
+ value:
723
+ referencedColumn.$refLinks[0].definition.kind === 'entity'
724
+ ? dollarSelfColumn.ref.slice(1).join('_')
725
+ : dollarSelfColumn.ref.join('_'),
726
+ },
727
+ isJoinRelevant: {
728
+ value: referencedColumn.isJoinRelevant,
729
+ },
730
+ $refLinks: {
731
+ value: [...referencedColumn.$refLinks, ...$refLinks.slice(2)],
732
+ },
733
+ })
734
+ } else {
735
+ // target column is `val`, `xpr`, or `func` — destructure and throw away the ref with the $self
736
+ // eslint-disable-next-line no-unused-vars
737
+ const { xpr, val, func, args, ref, as: _as, ...rest } = referencedColumn
738
+ if (xpr) rest.xpr = xpr
739
+ else if (func) { rest.func = func; rest.args = args }
740
+ else rest.val = val
741
+ dollarSelfColumn = { ...rest } // reassign dummyColumn without 'ref'
742
+ if (!omitAlias) dollarSelfColumn.as = as
743
+ }
744
+ return dollarSelfColumn.ref?.[0] === '$self'
745
+ ? buildDummyColumnForDollarSelf({ ...dollarSelfColumn }, $refLinks)
746
+ : dollarSelfColumn
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Calculates the columns for a nested projection on a structure.
752
+ *
753
+ * @param {object} col
754
+ * @param {'inline'|'expand'} prop the property on which to operate. Default is `inline`.
755
+ * @returns a list of flat columns.
756
+ */
757
+ function nestedProjectionOnStructure(col, prop = 'inline') {
758
+ const res = []
759
+
760
+ col[prop].forEach((nestedProjection, i) => {
761
+ let rewrittenColumns = []
762
+ if (nestedProjection === '*') {
763
+ res.push(...expandNestedProjectionWildcard(col, i, prop))
764
+ } else {
765
+ const nameParts = col.as ? [col.as] : [col.ref.map(idOnly).join('_')]
766
+ nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
767
+ const name = nameParts.join('_')
768
+ if (nestedProjection.ref) {
769
+ const augmentedInlineCol = augmentInlineRefWithParent(nestedProjection, col)
770
+ if (
771
+ col.as ||
772
+ nestedProjection.as ||
773
+ nestedProjection.$refLinks[nestedProjection.$refLinks.length - 1].definition.value ||
774
+ nestedProjection.isJoinRelevant
775
+ ) {
776
+ augmentedInlineCol.as = nameParts.join('_')
777
+ }
778
+ const flatColumns = getTransformedColumns([augmentedInlineCol])
779
+ flatColumns.forEach(flatColumn => {
780
+ const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
781
+ if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === flatColumnName)) {
782
+ const rewrittenColumn = { ...flatColumn }
783
+ if (nestedProjection.as) rewrittenColumn.as = flatColumnName
784
+ rewrittenColumns.push(rewrittenColumn)
785
+ }
786
+ })
787
+ } else {
788
+ // func, xpr, val..
789
+ // we need to check if the column was already added
790
+ // in the wildcard expansion
791
+ if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
792
+ const rewrittenColumn = { ...nestedProjection }
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
+ }
798
+ rewrittenColumns.push(rewrittenColumn)
799
+ }
800
+ }
801
+ }
802
+ res.push(...rewrittenColumns)
803
+ })
804
+
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
+ }
855
+ }
856
+
857
+ /**
858
+ * Expand the wildcard of the given column into all leaf elements.
859
+ * Respect smart wildcard rules and excluding clause.
860
+ *
861
+ * Every column before the wildcardIndex is excluded from the wildcard expansion.
862
+ * Columns after the wildcardIndex overwrite columns within the wildcard expansion in place.
863
+ *
864
+ * @TODO use this also for `expand` wildcards on structures.
865
+ *
866
+ * @param {csn.Column} col
867
+ * @param {integer} wildcardIndex
868
+ * @returns an array of columns which represents the expanded wildcard
869
+ */
870
+ function expandNestedProjectionWildcard(col, wildcardIndex, prop = 'inline') {
871
+ const res = []
872
+ // everything before the wildcard is inserted before the wildcard
873
+ // and ignored from the wildcard expansion
874
+ const exclude = col[prop].slice(0, wildcardIndex)
875
+ // everything after the wildcard, is a potential replacement
876
+ // in the wildcard expansion
877
+ const replace = []
878
+
879
+ const baseRef = col.ref || []
880
+ const baseRefLinks = col.$refLinks || []
881
+
882
+ // column has no ref, then it is an anonymous expand:
883
+ // select from books { { * } as bar }
884
+ // only possible if there is exactly one query source
885
+ if (!baseRef.length) {
886
+ const [tableAlias, { definition }] = Object.entries(inferred.sources)[0]
887
+ baseRef.push(tableAlias)
888
+ baseRefLinks.push({ definition, source: definition })
889
+ }
890
+ // we need to make the refs absolute
891
+ col[prop].slice(wildcardIndex + 1).forEach(c => {
892
+ const fakeColumn = { ...c }
893
+ if (fakeColumn.ref) {
894
+ fakeColumn.ref = [...baseRef, ...fakeColumn.ref]
895
+ fakeColumn.$refLinks = [...baseRefLinks, ...c.$refLinks]
896
+ }
897
+ replace.push(fakeColumn)
898
+ })
899
+ // respect excluding clause
900
+ if (col.excluding) {
901
+ // fake the ref since excluding only has strings
902
+ col.excluding.forEach(c => {
903
+ const fakeColumn = {
904
+ ref: [...baseRef, c],
905
+ }
906
+ exclude.push(fakeColumn)
907
+ })
908
+ }
909
+
910
+ if (baseRefLinks.at(-1).definition.kind === 'entity') {
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))
915
+ } else
916
+ res.push(
917
+ ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getTableAlias(col) }, [], {
918
+ exclude,
919
+ replace,
920
+ }),
921
+ )
922
+ return res
923
+ }
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
+
1036
+ /**
1037
+ * This function converts a column with an `expand` property into a subquery.
1038
+ *
1039
+ * It operates by using the following steps:
1040
+ *
1041
+ * 1. It creates an intermediate SQL query, selecting `from <effective query source>:...<column>.ref { ...<column>.expand }`.
1042
+ * For example, from the query `SELECT from Authors { books { title } }`, it generates:
1043
+ * - `SELECT from Authors:books as books {title}`
1044
+ *
1045
+ * 2. It then adds the properties `expand: true` and `one: <expand assoc>.is2one` to the intermediate SQL query.
1046
+ *
1047
+ * 3. It applies `cqn4sql` to the intermediate query (ensuring the aliases of the outer query are maintained).
1048
+ * For example, `cqn4sql(…)` is used to create the following query:
1049
+ * - `SELECT from Books as books {books.title} where exists ( SELECT 1 from Authors as Authors where Authors.ID = books.author_ID )`
1050
+ *
1051
+ * 4. It then replaces the `exists <subquery>` with the where condition of the `<subquery>` and correlates it with the effective query source.
1052
+ * For example, this query is created:
1053
+ * - `SELECT from Books as books { books.title } where Authors.ID = books.author_ID`
1054
+ *
1055
+ * 5. Lastly, it replaces the `expand` column of the original query with the transformed subquery.
1056
+ * For example, the query becomes:
1057
+ * - `SELECT from Authors { (SELECT from Books as books { books.title } where Authors.ID = books.author_ID) as books }`
1058
+ *
1059
+ * @param {CSN.column} column - The column with the 'expand' property to be transformed into a subquery.
1060
+ *
1061
+ * @returns {object} Returns a subquery correlated with the enclosing query, with added properties `expand:true` and `one:true|false`.
1062
+ */
1063
+ function expandColumn(column) {
1064
+ let outerAlias
1065
+ let subqueryFromRef
1066
+ const ref = column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref
1067
+ const assoc = column.$refLinks.at(-1)
1068
+ ensureValidForeignKeys(assoc.definition, column.ref, 'expand')
1069
+ if (column.isJoinRelevant) {
1070
+ // all n-1 steps of the expand column are already transformed into joins
1071
+ // find the last join relevant association. That is the n-1 assoc in the ref path.
1072
+ // slice the ref array beginning from the n-1 assoc in the ref and take that as the postfix for the subqueries from ref.
1073
+ ;[...column.$refLinks]
1074
+ .reverse()
1075
+ .slice(1)
1076
+ .find((link, i) => {
1077
+ if (link.definition.isAssociation) {
1078
+ subqueryFromRef = [link.definition.target, ...column.ref.slice(-(i + 1), column.ref.length)]
1079
+ // alias of last join relevant association is also the correlation alias for the subquery
1080
+ outerAlias = link.alias
1081
+ return true
1082
+ }
1083
+ })
1084
+ } else {
1085
+ outerAlias = transformedQuery.SELECT.from.as
1086
+ subqueryFromRef = [transformedQuery._target.name, ...ref]
1087
+ }
1088
+
1089
+ // this is the alias of the column which holds the correlated subquery
1090
+ const columnAlias = column.as || ref.map(idOnly).join('_')
1091
+
1092
+ // if there is a group by on the main query, all
1093
+ // columns of the expand must be in the groupBy
1094
+ if (transformedQuery.SELECT.groupBy) {
1095
+ const baseRef = column.$refLinks[0].definition.SELECT || ref
1096
+
1097
+ return _subqueryForGroupBy(column, baseRef, columnAlias)
1098
+ }
1099
+
1100
+ // Alias in expand subquery is derived from but not equal to
1101
+ // the alias of the column because to account for potential ambiguities
1102
+ // the alias cannot be addressed anyways
1103
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(getImplicitAlias(columnAlias), inferred.outerQueries)
1104
+
1105
+ // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
1106
+ const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
1107
+ const subqueryBase = {}
1108
+ const queryModifiers = { ...column }
1109
+ for (const [key, value] of Object.entries(queryModifiers)) {
1110
+ if (key in { limit: 1, orderBy: 1, groupBy: 1, excluding: 1, where: 1, having: 1, count: 1 })
1111
+ subqueryBase[key] = value
1112
+ }
1113
+
1114
+ const subquery = {
1115
+ SELECT: {
1116
+ ...subqueryBase,
1117
+ from,
1118
+ columns: JSON.parse(JSON.stringify(column.expand)),
1119
+ expand: true,
1120
+ one: column.$refLinks.at(-1).definition.is2one,
1121
+ },
1122
+ }
1123
+ const expanded = transformSubquery(subquery)
1124
+ const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
1125
+ defineProperty(correlated, 'elements', expanded.elements)
1126
+ return correlated
1127
+
1128
+ function _correlate(subq, outer) {
1129
+ const subqueryFollowingExists = (a, indexOfExists) => a[indexOfExists + 1]
1130
+ let {
1131
+ SELECT: { where },
1132
+ } = subq
1133
+ let recent = where
1134
+ let i = where.indexOf('exists')
1135
+ while (i !== -1) {
1136
+ where = subqueryFollowingExists((recent = where), i).SELECT.where
1137
+ i = where.indexOf('exists')
1138
+ }
1139
+ const existsIndex = recent.indexOf('exists')
1140
+ recent.splice(
1141
+ existsIndex,
1142
+ 2,
1143
+ ...where.map(x => {
1144
+ return replaceAliasWithSubqueryAlias(x)
1145
+ }),
1146
+ )
1147
+
1148
+ function replaceAliasWithSubqueryAlias(x) {
1149
+ const existsSubqueryAlias = recent[existsIndex + 1].SELECT.from.as
1150
+ if (existsSubqueryAlias === x.ref?.[0]) return { ref: [outer, ...x.ref.slice(1)] }
1151
+ if (x.xpr) x.xpr = x.xpr.map(replaceAliasWithSubqueryAlias)
1152
+ if (x.args) x.args = x.args.map(replaceAliasWithSubqueryAlias)
1153
+ return x
1154
+ }
1155
+ return subq
1156
+ }
1157
+
1158
+ /**
1159
+ * Generates a special subquery for the `expand` of the `column`.
1160
+ * All columns in the `expand` must be part of the GROUP BY clause of the main query.
1161
+ * If this is the case, the subqueries columns match the corresponding references of the group by.
1162
+ * Nested expands are also supported.
1163
+ *
1164
+ * @param {Object} column - To expand.
1165
+ * @param {Array} baseRef - The base reference for the expanded column.
1166
+ * @param {string} subqueryAlias - The alias of the `expand` subquery column.
1167
+ * @returns {Object} - The subquery object or null if the expand has a wildcard.
1168
+ * @throws {Error} - If one of the `ref`s in the `column.expand` is not part of the GROUP BY clause.
1169
+ */
1170
+ function _subqueryForGroupBy(column, baseRef, subqueryAlias) {
1171
+ const groupByLookup = new Map(
1172
+ transformedQuery.SELECT.groupBy.map(c => [c.ref && c.ref.map(refWithConditions).join('.'), c]),
1173
+ )
1174
+
1175
+ // to be attached to dummy query
1176
+ const elements = {}
1177
+ const containsWildcard = column.expand.includes('*')
1178
+ if (containsWildcard) {
1179
+ // expand with wildcard vanishes as expand is part of the group by (OData $apply + $expand)
1180
+ return null
1181
+ }
1182
+ const expandedColumns = column.expand
1183
+ .flatMap(expand => {
1184
+ if (!expand.ref) return expand
1185
+ const fullRef = [...baseRef, ...expand.ref]
1186
+
1187
+ if (expand.expand) {
1188
+ const nested = _subqueryForGroupBy(expand, fullRef, expand.as || expand.ref.map(idOnly).join('_'))
1189
+ if (nested) {
1190
+ setElementOnColumns(nested, expand.element)
1191
+ elements[expand.as || expand.ref.map(idOnly).join('_')] = nested
1192
+ }
1193
+ return nested
1194
+ }
1195
+
1196
+ const groupByRef = groupByLookup.get(fullRef.map(refWithConditions).join('.'))
1197
+ if (!groupByRef) {
1198
+ throw new Error(
1199
+ `The expanded column "${fullRef.map(refWithConditions).join('.')}" must be part of the group by clause`,
1200
+ )
1201
+ }
1202
+
1203
+ const copy = Object.create(groupByRef)
1204
+ // always alias for this special case, so that they nested element names match the expected result structure
1205
+ // otherwise we'd get `author { <outer>.author_ID }`, but we need `author { <outer>.author_ID as ID }`
1206
+ copy.as = expand.as || expand.ref.at(-1)
1207
+ const tableAlias = getTableAlias(copy)
1208
+ const res = getFlatColumnsFor(copy, { tableAlias })
1209
+ res.forEach(c => {
1210
+ elements[c.as || c.ref.at(-1)] = c.element
1211
+ })
1212
+ return res
1213
+ })
1214
+ .filter(c => c)
1215
+
1216
+ if (expandedColumns.length === 0) {
1217
+ return null
1218
+ }
1219
+
1220
+ const SELECT = {
1221
+ from: null,
1222
+ columns: expandedColumns,
1223
+ }
1224
+ return Object.defineProperties(
1225
+ {},
1226
+ {
1227
+ SELECT: {
1228
+ value: Object.defineProperties(SELECT, {
1229
+ expand: { value: true, writable: true }, // non-enumerable
1230
+ one: { value: column.$refLinks.at(-1).definition.is2one, writable: true }, // non-enumerable
1231
+ }),
1232
+ enumerable: true,
1233
+ },
1234
+ as: { value: subqueryAlias, enumerable: true, writable: true },
1235
+ elements: { value: elements }, // non-enumerable
1236
+ },
1237
+ )
1238
+ }
1239
+ }
1240
+
1241
+ function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
1242
+ const res = []
1243
+ for (let i = 0; i < columns.length; i++) {
1244
+ let col = columns[i]
1245
+ if (isCalculatedOnRead(col.$refLinks?.[col.$refLinks.length - 1].definition)) {
1246
+ const calcElement = resolveCalculatedElement(col, true)
1247
+ res.push(calcElement)
1248
+ } else if (pseudos.elements[col.ref?.[0]]) {
1249
+ res.push({ ...col })
1250
+ } else if (col.ref) {
1251
+ if (
1252
+ col.$refLinks.some(link => {
1253
+ const def = getDefinition(link.definition.target)
1254
+ return hasOwnSkip(def) && !isRuntimeView(def)
1255
+ })
1256
+ )
1257
+ continue
1258
+ if (col.ref.length > 1 && col.ref[0] === '$self' && !col.$refLinks[0].definition.kind) {
1259
+ const dollarSelfReplacement = calculateDollarSelfColumn(col)
1260
+ res.push(...getTransformedOrderByGroupBy([dollarSelfReplacement], inOrderBy))
1261
+ continue
1262
+ }
1263
+ const { target, definition } = col.$refLinks[0]
1264
+ let tableAlias = null
1265
+ if (target.SELECT?.columns && inOrderBy) {
1266
+ // usually TA is omitted if order by ref is a column
1267
+ // if a localized sorting is requested, we add `COLLATE`s
1268
+ // later on, which transforms the simple name to an expression
1269
+ // --> in an expression, only source elements can be addressed, hence we must add TA
1270
+ if (target.SELECT.localized && definition.type === 'cds.String') {
1271
+ const referredCol = target.SELECT.columns.find(c => {
1272
+ return c.as === col.ref[0] || c.ref?.at(-1) === col.ref[0]
1273
+ })
1274
+ if (referredCol) {
1275
+ // keep sort and nulls properties
1276
+ referredCol.sort = col.sort
1277
+ referredCol.nulls = col.nulls
1278
+ col = referredCol
1279
+ if (definition.kind === 'element') {
1280
+ tableAlias = getTableAlias(col)
1281
+ } else {
1282
+ // we must replace the reference with the underlying expression
1283
+ const { val, func, args, xpr } = col
1284
+ if (val) res.push({ val })
1285
+ if (func) res.push({ func, args })
1286
+ if (xpr) res.push({ xpr })
1287
+ continue
1288
+ }
1289
+ }
1290
+ }
1291
+ } else {
1292
+ tableAlias = getTableAlias(col) // do not prepend TA if orderBy column addresses element of query
1293
+ }
1294
+ const leaf = col.$refLinks[col.$refLinks.length - 1].definition
1295
+ if (leaf.virtual === true) continue // already in getFlatColumnForElement
1296
+ let baseName
1297
+ if (col.ref.length >= 2) {
1298
+ // leaf might be intermediate structure
1299
+ baseName = col.ref
1300
+ .map(idOnly)
1301
+ .slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1)
1302
+ .join('_')
1303
+ }
1304
+ const flatColumns = getFlatColumnsFor(col, { baseName, tableAlias })
1305
+ /**
1306
+ * We can't guarantee that the element order will NOT change in the future.
1307
+ * We claim that the element order doesn't matter, hence we can't allow elements
1308
+ * in the order by clause which expand to more than one column, as the order impacts
1309
+ * the result.
1310
+ */
1311
+ if (inOrderBy && flatColumns.length > 1)
1312
+ throw new Error(`Structured element “${getFullName(leaf)}” expands to multiple fields and can't be used in order by`)
1313
+ flatColumns.forEach(fc => {
1314
+ if (col.nulls) fc.nulls = col.nulls
1315
+ if (col.sort) fc.sort = col.sort
1316
+ if (fc.as) delete fc.as
1317
+ })
1318
+ res.push(...flatColumns)
1319
+ } else {
1320
+ let transformedColumn
1321
+ if (col.SELECT) transformedColumn = transformSubquery(col)
1322
+ else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
1323
+ else if (col.func) transformedColumn = { args: getTransformedFunctionArgs(col.args), func: col.func }
1324
+ // val
1325
+ else transformedColumn = copy(col)
1326
+ if (col.sort) transformedColumn.sort = col.sort
1327
+ if (col.nulls) transformedColumn.nulls = col.nulls
1328
+ res.push(transformedColumn)
1329
+ }
1330
+ }
1331
+ return res
1332
+ }
1333
+
1334
+ /**
1335
+ * Transforms a subquery.
1336
+ *
1337
+ * If the current query contains outer queries (is itself a subquery),
1338
+ * it appends the current inferred query.
1339
+ * Otherwise, it initializes the `outerQueries` array and adds the inferred query.
1340
+ * The `outerQueries` property makes sure
1341
+ * that the table aliases of the outer queries are accessible within the scope of the subquery.
1342
+ * Lastly, it recursively calls cqn4sql on the subquery.
1343
+ *
1344
+ * @param {object} q - The query to be transformed. This should be a subquery object.
1345
+ * @returns {object} - The cqn4sql transformed subquery.
1346
+ */
1347
+ function transformSubquery(q) {
1348
+ if (q.outerQueries) q.outerQueries.push(inferred)
1349
+ else {
1350
+ const outerQueries = inferred.outerQueries || []
1351
+ outerQueries.push(inferred)
1352
+ defineProperty(q, 'outerQueries', outerQueries)
1353
+ }
1354
+
1355
+ const target = cds.infer.target(inferred) // REVISIT: we should reliably use inferred._target instead
1356
+ if (isLocalized(target)) q.SELECT.localized = true
1357
+ if (q.SELECT.from.ref && !q.SELECT.from.as) assignUniqueSubqueryAlias()
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
+
1366
+
1367
+ function assignUniqueSubqueryAlias() {
1368
+ if (q.SELECT.from.uniqueSubqueryAlias) return
1369
+ const last = q.SELECT.from.ref.at(-1)
1370
+ const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
1371
+ getImplicitAlias(last.id || last),
1372
+ inferred.outerQueries,
1373
+ )
1374
+ defineProperty(q.SELECT.from, 'uniqueSubqueryAlias', uniqueSubqueryAlias)
1375
+ }
1376
+ }
1377
+
1378
+ /**
1379
+ * This function converts a wildcard into explicit columns.
1380
+ *
1381
+ * Based on the query's `$combinedElements` attribute, the function computes the flat column representations
1382
+ * and returns them. Additionally, it prepends the respective table alias to each column. Columns specified
1383
+ * in the `excluding` clause are ignored during this transformation.
1384
+ *
1385
+ * Furthermore, foreign keys (FK) for OData CSN and blobs are excluded from the wildcard expansion.
1386
+ *
1387
+ * @param {array} exclude - An optional list of columns to be excluded during the wildcard expansion.
1388
+ * @param {array} replace - An optional list of columns to replace during the wildcard expansion.
1389
+ * @param {string} baseName - the explicit alias of the column.
1390
+ * Only possible for anonymous expands on implicit table alias: `select from books { { * } as FOO }`
1391
+ *
1392
+ * @returns {Array} Returns an array of explicit columns derived from the wildcard.
1393
+ */
1394
+ function getColumnsForWildcard(exclude = [], replace = [], baseName = null) {
1395
+ const wildcardColumns = []
1396
+ for (const k of Object.keys(inferred.$combinedElements)) {
1397
+ if (!exclude.includes(k)) {
1398
+ const { index, tableAlias } = inferred.$combinedElements[k][0]
1399
+ const element = tableAlias.elements[k]
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
1402
+ // for wildcard on subquery in from, just reference the elements
1403
+ if (tableAlias.SELECT && !element.elements && !element.target) {
1404
+ wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
1405
+ } else if (isCalculatedOnRead(element)) {
1406
+ wildcardColumns.push(resolveCalculatedElement(replace.find(r => r.as === k) || element))
1407
+ } else {
1408
+ const flatColumns = getFlatColumnsFor(
1409
+ element,
1410
+ { tableAlias: index, baseName },
1411
+ [],
1412
+ { exclude, replace },
1413
+ true,
1414
+ )
1415
+ wildcardColumns.push(...flatColumns)
1416
+ }
1417
+ }
1418
+ }
1419
+ return wildcardColumns
1420
+
1421
+ /**
1422
+ * foreign keys are already part of the elements in a flat model
1423
+ * not excluding the associations from the wildcard columns would cause duplicate columns upon foreign key expansion
1424
+ * @param {CSN.element} e
1425
+ * @returns {boolean} true if the element is a managed association and the model is flat
1426
+ */
1427
+ function isManagedAssocInFlatMode(e) {
1428
+ return (
1429
+ e.isAssociation && e.keys && (model.meta.transformation === 'odata' || model.meta.unfolded?.includes('structs'))
1430
+ )
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Resolve `ref` within `def` and return the element
1436
+ *
1437
+ * @param {string[]} ref
1438
+ * @param {CSN.Artifact} def
1439
+ * @returns {CSN.Element}
1440
+ */
1441
+ function getElementForRef(ref, def) {
1442
+ return ref.reduce((prev, res) => {
1443
+ return (prev?.elements || prev?.foreignKeys)?.[res] || getDefinition(prev?.target)?.elements[res] // PLEASE REVIEW: should we add the .foreignKey check here for the non-ucsn case?
1444
+ }, def)
1445
+ }
1446
+
1447
+ /**
1448
+ * Recursively expands a structured element into flat columns, representing all leaf paths.
1449
+ *
1450
+ * Structures → flat sub-element columns. Associations → flat foreign key columns.
1451
+ * Scalars → single column reference.
1452
+ *
1453
+ * @param {object} column - The element to expand (may be a ref with $refLinks, or a raw element definition).
1454
+ * @param {{
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.
1466
+ */
1467
+ function getFlatColumnsFor(column, names, csnPath = [], excludeAndReplace, isWildcard = false) {
1468
+ if (!column) return column
1469
+ if (column.val || column.func || column.SELECT) return [column]
1470
+
1471
+ const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1472
+ let { baseName, columnAlias = column.as, tableAlias } = names || {}
1473
+ const { exclude, replace } = excludeAndReplace || {}
1474
+ const { $refLinks, flatName, isJoinRelevant } = column
1475
+ let firstNonJoinRelevantAssoc, stepAfterAssoc
1476
+ let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
1477
+ if (isWildcard && element.type === 'cds.LargeBinary') return []
1478
+ if (element.on && !element.keys) return [] // unmanaged doesn't make it into columns
1479
+ if (element.virtual === true) return []
1480
+
1481
+ if (!isJoinRelevant && flatName) baseName = flatName
1482
+ else if (isJoinRelevant) {
1483
+ const earlyResult = resolveJoinRelevantNames()
1484
+ if (earlyResult) return earlyResult
1485
+ } else if (!baseName && structsAreUnfoldedAlready) {
1486
+ baseName = element.name // name is already fully constructed
1487
+ } else {
1488
+ baseName = baseName ? `${baseName}_${element.name}` : getFullName(element)
1489
+ }
1490
+
1491
+ // now we have the name of the to be expanded column
1492
+ // it could be a structure, an association or a scalar
1493
+ // check if the column shall be skipped
1494
+ // e.g. for wildcard elements which have been overwritten before
1495
+ if (exclude && getReplacement(exclude)) return []
1496
+ const replacedBy = getReplacement(replace)
1497
+ if (replacedBy) {
1498
+ // the replacement alias is the baseName of the flat structure
1499
+ // e.g. `office.{ *, address.city as floor }`
1500
+ // for the `ref: [ office, floor ]` we find the replacement
1501
+ // `ref: [ office, address, city]` so the `baseName` of the replacement
1502
+ if (replacedBy.as) replacedBy.as = baseName
1503
+ // we might have a new base ref
1504
+ if (replacedBy.ref && replacedBy.ref.length > 1)
1505
+ baseName = getFullName(replacedBy.$refLinks?.[replacedBy.$refLinks.length - 2].definition)
1506
+ if (replacedBy.isJoinRelevant)
1507
+ // we need to provide the correct table alias
1508
+ tableAlias = getTableAlias(replacedBy)
1509
+
1510
+ if (replacedBy.expand) return [{ as: baseName }]
1511
+
1512
+ return getFlatColumnsFor(replacedBy, { baseName, columnAlias: replacedBy.as, tableAlias }, csnPath)
1513
+ }
1514
+
1515
+ csnPath = [...csnPath, element.name]
1516
+
1517
+ if (element.keys) return flattenForeignKeys()
1518
+ if (element.elements && element.type !== 'cds.Map') return flattenStructElements()
1519
+ return buildScalarColumn()
1520
+
1521
+ function flattenStructElements() {
1522
+ const flatRefs = []
1523
+ for (const e of Object.values(element.elements)) {
1524
+ const alias = columnAlias ? `${columnAlias}_${e.name}` : null
1525
+ flatRefs.push(
1526
+ ...getFlatColumnsFor(
1527
+ e,
1528
+ { baseName, columnAlias: alias, tableAlias },
1529
+ csnPath,
1530
+ excludeAndReplace,
1531
+ isWildcard,
1532
+ ),
1533
+ )
1534
+ }
1535
+ return flatRefs
1536
+ }
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]
1549
+ }
1550
+
1551
+ function getReplacement(from) {
1552
+ return from?.find(replacement => {
1553
+ const nameOfExcludedColumn = replacement.as || replacement.ref?.at(-1) || replacement
1554
+ return nameOfExcludedColumn === element.name
1555
+ })
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
+ }
1652
+ }
1653
+
1654
+ /**
1655
+ * Transforms a CQN token stream (e.g. `where`, `xpr` or `having`) into a SQL like expression.
1656
+ *
1657
+ * Expand `exists <assoc>` into `WHERE EXISTS` subqueries, apply flattening to `ref`s.
1658
+ * Recursively apply `cqn4sql` to query expressions found in the token stream.
1659
+ *
1660
+ * @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
1661
+ * object representing a CQN construct such as a column, an operator,
1662
+ * or a subquery.
1663
+ * @param {object} [context] - Optional context object.
1664
+ * @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
1665
+ * It serves as the reference point for resolving associations in
1666
+ * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1667
+ * Here, the $baseLink for `anotherAssoc` would be `assoc`.
1668
+ * @param {string} [context.prop] - The query property which holds the token stream which shall be
1669
+ * transformed by this function, e.g. "where".
1670
+ * @returns {object[]} - The transformed token stream.
1671
+ */
1672
+ function getTransformedTokenStream(tokenStream, context = {}) {
1673
+ const transformedTokenStream = []
1674
+ const { $baseLink, /* prop */ } = context
1675
+ for (let i = 0; i < tokenStream.length; i++) {
1676
+ const token = tokenStream[i]
1677
+ if (token === 'exists') {
1678
+ transformedTokenStream.push(token)
1679
+ const whereExistsSubSelects = []
1680
+ const { ref, $refLinks } = tokenStream[i + 1]
1681
+ if (!ref) continue
1682
+ if (ref[0] in { $self: true, $projection: true })
1683
+ throw new Error(`Unexpected "${ref[0]}" following "exists", remove it or add a table alias instead`)
1684
+ const firstStepIsTableAlias = ref.length > 1 && ref[0] in inferred.sources
1685
+ for (let j = 0; j < ref.length; j += 1) {
1686
+ let current, next
1687
+ const step = ref[j]
1688
+ const id = step.id || step
1689
+ if (j === 0) {
1690
+ if (firstStepIsTableAlias) continue
1691
+ current = $baseLink || {
1692
+ definition: $refLinks[0].target,
1693
+ target: $refLinks[0].target,
1694
+ // if the first step of a where is not a table alias,
1695
+ // the table alias is the query source where the current ref step
1696
+ // originates from. As no table alias is specified, there must be
1697
+ // only one table alias for the given ref step
1698
+ alias: inferred.$combinedElements[id][0].index,
1699
+ }
1700
+ next = $refLinks[0]
1701
+ } else {
1702
+ current = $refLinks[j - 1]
1703
+ next = $refLinks[j]
1704
+ }
1705
+
1706
+ if (isStructured(next.definition)) {
1707
+ // find next association / entity in the ref because this is actually our real nextStep
1708
+ const nextAssocIndex =
1709
+ 2 + $refLinks.slice(j + 2).findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1710
+ next = $refLinks[nextAssocIndex]
1711
+ j = nextAssocIndex
1712
+ }
1713
+
1714
+ const as = getNextAvailableTableAlias(getImplicitAlias(next.alias))
1715
+ next.alias = as
1716
+ if (!next.definition.target) {
1717
+ let type = next.definition.type
1718
+ if (isCalculatedElement(next.definition)) {
1719
+ // try to infer the type at the leaf for better error message
1720
+ const { $refLinks } = next.definition.value
1721
+ type = $refLinks?.at(-1).definition.type || 'expression'
1722
+ }
1723
+ throw new Error(
1724
+ `Expecting path “${tokenStream[i + 1].ref
1725
+ .map(idOnly)
1726
+ .join('.')}” following “EXISTS” predicate to end with association/composition, found “${type}”`,
1727
+ )
1728
+ }
1729
+ const { definition: fkSource } = next
1730
+ ensureValidForeignKeys(fkSource, ref)
1731
+ const { where, ...args } = step
1732
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, true, args))
1733
+ }
1734
+
1735
+ const whereExists = { SELECT: whereExistsSubqueries(whereExistsSubSelects) }
1736
+ transformedTokenStream[i + 1] = whereExists
1737
+ // skip newly created subquery from being iterated
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))
1742
+ } else if (token.list) {
1743
+ if (token.list.length === 0) {
1744
+ // replace `[not] in <empty list>` to harmonize behavior across dbs
1745
+ const precedingTwoTokens = tokenStream.slice(i - 2, i)
1746
+ const firstPrecedingToken =
1747
+ typeof precedingTwoTokens[0] === 'string' ? precedingTwoTokens[0].toLowerCase() : ''
1748
+ const secondPrecedingToken =
1749
+ typeof precedingTwoTokens[1] === 'string' ? precedingTwoTokens[1].toLowerCase() : ''
1750
+
1751
+ if (firstPrecedingToken === 'not') {
1752
+ transformedTokenStream.splice(i - 2, 2, 'is', 'not', 'null')
1753
+ } else if (secondPrecedingToken === 'in') {
1754
+ transformedTokenStream.splice(i - 1, 1, '=', { val: null })
1755
+ } else {
1756
+ transformedTokenStream.push({ list: [] })
1757
+ }
1758
+ } else {
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))
1766
+ // no need for transformation
1767
+ transformedTokenStream.push({ list })
1768
+ else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
1769
+ }
1770
+ } else if (tokenStream.length === 1 && token.val && $baseLink) {
1771
+ // infix filter - OData variant w/o mentioning key
1772
+ const def = getDefinition($baseLink.definition.target) || $baseLink.definition
1773
+ const flatKeys = getPrimaryKey(def, $baseLink.alias)
1774
+ if (flatKeys.length > 1) // TODO: what about keyless?
1775
+ throw new Error(`Shortcut notation “[${token.val}]” not available for composite primary key of “${def.name}”, write “<key> = ${token.val}” explicitly`)
1776
+ transformedTokenStream.push(...[flatKeys[0], '=', token]);
1777
+ } else if (token.ref && token.param) {
1778
+ transformedTokenStream.push({ ...token })
1779
+ } else if (pseudos.elements[token.ref?.[0]]) {
1780
+ transformedTokenStream.push({ ...token })
1781
+ } else {
1782
+ // expand `struct = null | struct2`
1783
+ const next = tokenStream[i + 1]
1784
+ let indexRhs = i + 2
1785
+ let rhs = tokenStream[indexRhs] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1786
+ const lhsDef = token.$refLinks?.at(-1).definition
1787
+ let rhsDef = rhs?.$refLinks?.at(-1)?.definition
1788
+ if (
1789
+ allOps.some(([firstOp]) => firstOp === next) &&
1790
+ (lhsDef?.elements || lhsDef?.keys || rhsDef?.elements || rhsDef?.keys)
1791
+ ) {
1792
+ const ops = [next]
1793
+ if (allOps.some(([, secondOp]) => secondOp === rhs)) {
1794
+ ops.push(rhs)
1795
+ rhs = tokenStream[i + 3]
1796
+ indexRhs += 1
1797
+ }
1798
+
1799
+ if (notSupportedOps.some(([firstOp]) => firstOp === next))
1800
+ throw new Error(`The operator "${next}" can only be used with scalar operands`)
1801
+
1802
+ const newTokens = expandComparison(token, ops, rhs, $baseLink)
1803
+ if (newTokens.length === 0)
1804
+ throw new Error(`Can't compare two empty structures`)
1805
+
1806
+ const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1807
+ transformedTokenStream.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1808
+ i = indexRhs // jump to next relevant index
1809
+ } else {
1810
+ // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1811
+ assertNoStructInXpr(token, context)
1812
+ // reject virtual elements in expressions as they will lead to a sql error down the line
1813
+ if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
1814
+
1815
+ let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive!
1816
+ // REVISIT: required because we copy the token above and lose the not enumerable "param: false"
1817
+ if (typeof token === 'object' && 'val' in token && 'param' in token) Object.defineProperty(result, 'param', { value: token.param })
1818
+ if (token.ref) {
1819
+ const { definition } = token.$refLinks.at(-1)
1820
+ // Add definition to result
1821
+ setElementOnColumns(result, definition)
1822
+ if (isCalculatedOnRead(definition)) {
1823
+ const calculatedElement = resolveCalculatedElement(token, true, $baseLink)
1824
+ transformedTokenStream.push(calculatedElement)
1825
+ continue
1826
+ }
1827
+ if (token.ref.length > 1 && token.ref[0] === '$self' && !token.$refLinks[0].definition.kind) {
1828
+ if (inferred.outerQueries) {
1829
+ const outerQuery = inferred.outerQueries[0]
1830
+ const stepToFind = token.ref[1]?.id || token.ref[1]
1831
+ const outerAlias = outerQuery.$combinedElements?.[stepToFind]?.[0].index
1832
+ if (outerAlias) {
1833
+ let result = copy(token)
1834
+ result.ref = [outerAlias, token.flatName]
1835
+ transformedTokenStream.push(result)
1836
+ continue
1837
+ }
1838
+ }
1839
+ const dollarSelfReplacement = [calculateDollarSelfColumn(token, true)]
1840
+ transformedTokenStream.push(...getTransformedTokenStream(dollarSelfReplacement))
1841
+ continue
1842
+ }
1843
+ // if we have e.g. a calculated element like `books.authorLastName,`
1844
+ // we have effectively a ref ['books', 'author', 'lastName']
1845
+ // in that case, we have a baseLink `books` which we need to resolve the following steps
1846
+ // however, the correct table alias has been assigned to the `author` step
1847
+ // hence we need to ignore the alias of the `$baseLink`
1848
+ const lastAssoc =
1849
+ token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
1850
+ const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1851
+ if (isAssocOrStruct(definition)) {
1852
+ const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1853
+ if (flat.length === 0)
1854
+ throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1855
+ else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1856
+ throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
1857
+ transformedTokenStream.push(...flat)
1858
+ continue
1859
+ } else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1860
+ let name = calculateElementName(token)
1861
+ result.ref = [tableAlias, name]
1862
+ } else if (tableAlias) {
1863
+ result.ref = [tableAlias, token.flatName]
1864
+ } else {
1865
+ // if there is no table alias, we might select from an anonymous subquery
1866
+ result.ref = [token.flatName]
1867
+ }
1868
+ } else if (token.SELECT) {
1869
+ result = transformSubquery(token)
1870
+ } else {
1871
+ if (token.xpr) {
1872
+ result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
1873
+ }
1874
+ if (token.func && token.args) {
1875
+ result.args = getTransformedFunctionArgs(token.args, $baseLink)
1876
+ }
1877
+ }
1878
+
1879
+ if (result.cast) result.cast = resolveEnumCastType(result.cast)
1880
+ transformedTokenStream.push(result)
1881
+ }
1882
+ }
1883
+ }
1884
+ return transformedTokenStream
1885
+ }
1886
+
1887
+ /**
1888
+ * Expand the given definition and compare all leafs to `val`.
1889
+ *
1890
+ * @param {object} lhs with $refLinks
1891
+ * @param {string} operator one of allOps
1892
+ * @param {object} rhs either `null` or a column (with `ref` and `$refLinks`)
1893
+ * @param {object} $baseLink optional base `$refLink`, e.g. for infix filters of scoped queries.
1894
+ * In the following example, we must pass `bookshop:Reproduce` as $baseLink for `author`:
1895
+ *
1896
+ * `DELETE.from('bookshop.Reproduce[author = null]:accessGroup')`
1897
+ * ^^^^^^
1898
+ * @returns {array}
1899
+ */
1900
+ function expandComparison(lhs, operator, rhs, $baseLink = null) {
1901
+ const { definition: lhsDef, val: lhsVal } = lhs.val ? lhs : lhs.$refLinks.at(-1)
1902
+ const { definition: rhsDef, val: rhsVal } = rhs.val ? rhs : rhs.$refLinks?.at(-1) || {}
1903
+ const result = []
1904
+ if (lhsDef && rhsDef) {
1905
+ // both must be structured
1906
+ const lhsIsStructured = isAssocOrStruct(lhsDef)
1907
+ const rhsIsStructured = isAssocOrStruct(rhsDef)
1908
+ if (!lhsIsStructured)
1909
+ throw new Error(`Can't compare structure “${rhs.ref.map(idOnly).join('.')}” with non-structure “${lhs.ref.map(idOnly).join('.')}”`)
1910
+ if (!rhsIsStructured)
1911
+ throw new Error(`Can't compare structure “${lhs.ref.map(idOnly).join('.')}” with non-structure “${rhs.ref.map(idOnly).join('.')}”`)
1912
+
1913
+ const flatLhs = flattenWithBaseName(lhs)
1914
+ const flatRhs = flattenWithBaseName(rhs)
1915
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1916
+ while (flatLhs.length > 0) {
1917
+ // retrieve and remove one flat element from LHS and search for it in RHS (remove it there too)
1918
+ const { ref, _csnPath: lhs_csnPath } = flatLhs.shift()
1919
+ const indexOfElementOnRhs = flatRhs.findIndex(rhs => {
1920
+ const { _csnPath: rhs_csnPath } = rhs
1921
+ // all following steps must also be part of lhs
1922
+ return lhs_csnPath.slice(1).every((val, i) => val === rhs_csnPath[i + 1]) // first step is name of struct -> ignore
1923
+ })
1924
+ // not found in rhs --> exit
1925
+ if (indexOfElementOnRhs === -1) {
1926
+ const lhsPath = lhs.ref.map(idOnly).join('.')
1927
+ const rhsPath = rhs.ref.map(idOnly).join('.')
1928
+ throw new Error(`Can't compare "${lhsPath}" with "${rhsPath}": the operands must have the same structure`)
1929
+ }
1930
+ const cleansedRhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1931
+ result.push({ ref }, ...operator, cleansedRhs)
1932
+ if (flatLhs.length > 0) result.push(boolOp)
1933
+ }
1934
+ } else if (lhsDef && (rhsVal || rhs === 'null' || rhs.val === null)) {
1935
+ // compare with value
1936
+ const flatLhs = flattenWithBaseName(lhs)
1937
+ if (flatLhs.length !== 1 && rhsVal && rhs !== 'null')
1938
+ canOnlyCompareToExactlyOneLeaf(lhsDef, lhs.ref, rhsVal)
1939
+
1940
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1941
+ flatLhs.forEach((column, i) => {
1942
+ result.push(column, ...operator, rhs)
1943
+ if (flatLhs[i + 1]) result.push(boolOp)
1944
+ })
1945
+ } else if (lhsVal && rhsDef) {
1946
+ const flatRhs = flattenWithBaseName(rhs)
1947
+ // comparing a struct to a value is ok if structure has exactly one leaf
1948
+ if (flatRhs.length !== 1 && lhsVal)
1949
+ canOnlyCompareToExactlyOneLeaf(rhsDef, rhs.ref, lhsVal)
1950
+
1951
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1952
+ flatRhs.forEach((column, i) => {
1953
+ result.push(lhs, ...operator, column)
1954
+ if (flatRhs[i + 1]) result.push(boolOp)
1955
+ })
1956
+ }
1957
+ return result
1958
+
1959
+ function canOnlyCompareToExactlyOneLeaf(struct, structRef, val) {
1960
+ const what = struct.isAssociation ? 'association' : 'structure'
1961
+ const postfix = struct.isAssociation ? 'associations with one foreign key' : 'structures with one sub-element'
1962
+ throw new Error(
1963
+ `Can't compare ${what} "${structRef.map(idOnly).join('.')}" to value "${val}"; only possible for ${postfix}`
1964
+ )
1965
+ }
1966
+
1967
+ function flattenWithBaseName(def) {
1968
+ if (!def.$refLinks) return def
1969
+ const leaf = def.$refLinks[def.$refLinks.length - 1]
1970
+ const first = def.$refLinks[0]
1971
+ const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink)
1972
+ if (leaf.definition.parent.kind !== 'entity')
1973
+ // we need the base name
1974
+ return getFlatColumnsFor(leaf.definition, {
1975
+ baseName: def.ref
1976
+ .map(idOnly)
1977
+ .slice(0, def.ref.length - 1)
1978
+ .join('_'),
1979
+ tableAlias,
1980
+ })
1981
+ return getFlatColumnsFor(leaf.definition, { tableAlias })
1982
+ }
1983
+ }
1984
+
1985
+ function assertNoStructInXpr(token, context) {
1986
+ const definition = token.$refLinks?.at(-1).definition
1987
+ if (!definition) return
1988
+ const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1989
+ // unmanaged is always forbidden
1990
+ // expanding a ref in a `where`/`having` context
1991
+ if ((rejectStructs && definition?.target) || definition?.on)
1992
+ rejectAssocInExpression()
1993
+ if (rejectStructs && isStructured(definition))
1994
+ // REVISIT: let this through if not requested otherwise
1995
+ rejectStructInExpression()
1996
+
1997
+ function rejectAssocInExpression() {
1998
+ throw new Error(`An association can't be used as a value in an expression`)
1999
+ }
2000
+ function rejectStructInExpression() {
2001
+ throw new Error(`A structured element can't be used as a value in an expression`)
2002
+ }
2003
+ }
2004
+
2005
+ /**
2006
+ * Recursively walks over all `from` args. Association steps in the `ref`s
2007
+ * are transformed into `WHERE exists` subqueries. The given `from.ref`s
2008
+ * are always of length == 1 after processing.
2009
+ *
2010
+ * The steps in a `ref` are processed in reversed order. This is the main difference
2011
+ * to the `WHERE exists` expansion in the @function getTransformedTokenStream().
2012
+ *
2013
+ * @param {object} from
2014
+ * @param {object[]?} existingWhere custom where condition which is appended to the filter
2015
+ * conditions of the resulting `WHERE exists` subquery
2016
+ */
2017
+ function getTransformedFrom(from, existingWhere = []) {
2018
+ const transformedWhere = []
2019
+ let transformedFrom = copy(from) // REVISIT: too expensive!
2020
+ if (from.$refLinks) defineProperty(transformedFrom, '$refLinks', [...from.$refLinks])
2021
+ if (from.args) {
2022
+ transformedFrom.args = []
2023
+ from.args.forEach(arg => {
2024
+ if (arg.SELECT) {
2025
+ const { whereExists: e, transformedFrom: f } = getTransformedFrom(arg.SELECT.from, arg.SELECT.where)
2026
+ const transformedArg = { SELECT: { from: f, where: e } }
2027
+ transformedFrom.args.push(transformedArg)
2028
+ } else {
2029
+ const { transformedFrom: f } = getTransformedFrom(arg)
2030
+ transformedFrom.args.push(f)
2031
+ }
2032
+ })
2033
+ return { transformedFrom, transformedWhere: existingWhere }
2034
+ } else if (from.SELECT) {
2035
+ transformedFrom = transformSubquery(from)
2036
+ if (from.as) {
2037
+ // preserve explicit TA
2038
+ transformedFrom.as = from.as
2039
+ } else {
2040
+ // select from anonymous query, use artificial alias
2041
+ transformedFrom.as = Object.keys(inferred.sources)[0]
2042
+ }
2043
+ return { transformedFrom, transformedWhere: existingWhere }
2044
+ } else {
2045
+ return _transformFrom()
2046
+ }
2047
+ function _transformFrom() {
2048
+ if (typeof from === 'string') {
2049
+ // normalize to `ref`, i.e. for `UPDATE.entity('bookshop.Books')`
2050
+ return { transformedFrom: { ref: [from], as: getImplicitAlias(from) } }
2051
+ }
2052
+ transformedFrom.as =
2053
+ from.uniqueSubqueryAlias ||
2054
+ from.as ||
2055
+ getImplicitAlias(transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1].definition.name)
2056
+ const whereExistsSubSelects = []
2057
+ const filterConditions = []
2058
+ const refReverse = [...from.ref].reverse()
2059
+ const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
2060
+ for (let i = 0; i < refReverse.length; i += 1) {
2061
+ const current = $refLinksReverse[i]
2062
+
2063
+ let next = $refLinksReverse[i + 1]
2064
+ const nextStep = refReverse[i + 1] // only because we want the filter condition
2065
+
2066
+ if (current.definition.target && next) {
2067
+ const { where, ...args } = nextStep
2068
+ if (isStructured(next.definition)) {
2069
+ // find next association / entity in the ref because this is actually our real nextStep
2070
+ const nextStepIndex =
2071
+ 2 +
2072
+ $refLinksReverse
2073
+ .slice(i + 2)
2074
+ .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
2075
+ next = $refLinksReverse[nextStepIndex]
2076
+ }
2077
+ let as = getImplicitAlias(next.alias)
2078
+ /**
2079
+ * for an `expand` subquery, we do not need to add
2080
+ * the table alias of the `expand` host to the join tree
2081
+ * --> This is an artificial query, which will later be correlated
2082
+ * with the main query alias. see @function expandColumn()
2083
+ * There is one exception:
2084
+ * - if current and next have the same alias, we need to assign a new alias to the next
2085
+ *
2086
+ */
2087
+ if (!(inferred.SELECT?.expand === true && current.alias.toLowerCase() !== as.toLowerCase())) {
2088
+ as = getNextAvailableTableAlias(as)
2089
+ }
2090
+ next.alias = as
2091
+ const { definition: fkSource } = current
2092
+ ensureValidForeignKeys(fkSource, from.ref)
2093
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, where, false, args))
2094
+ }
2095
+ }
2096
+
2097
+ // OData variant w/o mentioning key
2098
+ if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) {
2099
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where, { $baseLink: $refLinksReverse[0] }))
2100
+ }
2101
+
2102
+ if (existingWhere.length > 0) filterConditions.push(existingWhere)
2103
+ if (whereExistsSubSelects.length > 0) {
2104
+ const { definition: leafAssoc, alias } = transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1]
2105
+ Object.assign(transformedFrom, {
2106
+ ref: [leafAssoc.target],
2107
+ as: alias,
2108
+ })
2109
+ transformedWhere.push(...['exists', { SELECT: whereExistsSubqueries(whereExistsSubSelects) }])
2110
+ filterConditions.forEach(f => {
2111
+ transformedWhere.push('and')
2112
+ if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
2113
+ else if (f.length > 3 || f.includes('or') || f.includes('and') || f.includes('in')) transformedWhere.push(asXpr(f))
2114
+ else transformedWhere.push(...f)
2115
+ })
2116
+ } else {
2117
+ if (filterConditions.length > 0) {
2118
+ filterConditions.reverse().forEach((f, index) => {
2119
+ if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
2120
+ else transformedWhere.push(...f)
2121
+ if (filterConditions[index + 1] !== undefined) transformedWhere.push('and')
2122
+ })
2123
+ }
2124
+ }
2125
+
2126
+ // adjust ref & $refLinks after associations have turned into where exists subqueries
2127
+ transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1)
2128
+
2129
+ const subquerySource =
2130
+ getDefinition(transformedFrom.$refLinks[0].definition.target) || transformedFrom.$refLinks[0].target
2131
+ const id = getLocalizedName(subquerySource)
2132
+ transformedFrom.ref = [subquerySource.params ? { id, args: from.ref.at(-1).args || {} } : id]
2133
+
2134
+ return { transformedWhere, transformedFrom }
2135
+ }
2136
+ }
2137
+
2138
+ function ensureValidForeignKeys(fkSource, ref, kind = null) {
2139
+ if (fkSource.keys && fkSource.keys.length === 0) {
2140
+ const path = prettyPrintRef(ref, model)
2141
+ if (kind === 'expand') throw new Error(`Can't expand “${fkSource.name}” as it has no foreign keys`)
2142
+ throw new Error(`Path step “${fkSource.name}” of “${path}” has no foreign keys`)
2143
+ }
2144
+ }
2145
+
2146
+ function whereExistsSubqueries(whereExistsSubSelects) {
2147
+ if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
2148
+ whereExistsSubSelects.reduce((prev, cur) => {
2149
+ if (prev.where) {
2150
+ prev.where.push('and', 'exists', { SELECT: cur })
2151
+ return cur
2152
+ } else {
2153
+ prev = cur
2154
+ }
2155
+ return prev
2156
+ }, {})
2157
+ return whereExistsSubSelects[0]
2158
+ }
2159
+
2160
+ function getNextAvailableTableAlias(id) {
2161
+ return inferred.joinTree.addNextAvailableTableAlias(id, inferred.outerQueries)
2162
+ }
2163
+
2164
+ /**
2165
+ * @param {CSN.Element} elt
2166
+ * @returns {boolean}
2167
+ */
2168
+ function isStructured(elt) {
2169
+ return Boolean(elt?.kind !== 'entity' && elt?.elements && !elt.isAssociation)
2170
+ }
2171
+
2172
+ /**
2173
+ * @param {CSN.Element} elt
2174
+ * @returns {boolean}
2175
+ */
2176
+ function isAssocOrStruct(elt) {
2177
+ return elt?.keys || (elt?.elements && elt.kind === 'element')
2178
+ }
2179
+
2180
+ /**
2181
+ * Calculates which elements are the backlinks of a $self comparison in a
2182
+ * given on-condition. That are the managed associations in the target of the
2183
+ * given association.
2184
+ *
2185
+ * @param {CSN.Association} assoc with on-condition
2186
+ * @returns {[CSN.Association] | null} all assocs which are compared to `$self`
2187
+ */
2188
+ function backlinkFor(assoc) {
2189
+ if (!assoc.on) return null
2190
+ const target = getDefinition(assoc.target)
2191
+ // technically we could have multiple backlinks
2192
+ const backlinks = []
2193
+ for (let i = 0; i < assoc.on.length; i += 3) {
2194
+ const lhs = assoc.on[i]
2195
+ const rhs = assoc.on[i + 2]
2196
+ if (lhs?.ref?.length === 1 && lhs.ref[0] === '$self') backlinks.push(rhs)
2197
+ else if (rhs?.ref?.length === 1 && rhs.ref[0] === '$self') backlinks.push(lhs)
2198
+ }
2199
+ return backlinks.map(each =>
2200
+ getElementForRef(
2201
+ each.ref.slice(1),
2202
+ each.ref[0] in { $self: true, $projection: true } ? getParentEntity(assoc) : target,
2203
+ ),
2204
+ )
2205
+ }
2206
+
2207
+ /**
2208
+ * Calculates the on-condition for the given (un-)managed association.
2209
+ *
2210
+ * @param {$refLink} assocRefLink with on-condition
2211
+ * @param {$refLink} targetSideRefLink the reflink which has the target alias of the association
2212
+ * @returns {[CSN.Association] | null} all assocs which are compared to `$self`
2213
+ */
2214
+ function onCondFor(assocRefLink, targetSideRefLink, inWhereOrJoin) {
2215
+ const { on, keys } = assocRefLink.definition
2216
+ const target = getDefinition(assocRefLink.definition.target)
2217
+ let res
2218
+ if (keys) {
2219
+ const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
2220
+ const transformedOn = []
2221
+ fkPkPairs.forEach((pair, i) => {
2222
+ const { sourceSide, targetSide } = pair
2223
+ sourceSide.ref.unshift(assocRefLink.alias)
2224
+ transformedOn.push(sourceSide, '=', targetSide)
2225
+ if (fkPkPairs[i + 1]) transformedOn.push('and')
2226
+ })
2227
+ res = transformedOn
2228
+ } else if (on) {
2229
+ res = calculateOnCondition(on)
2230
+ }
2231
+ return res
2232
+
2233
+ /**
2234
+ * For an unmanaged association, calculate the proper on-condition.
2235
+ * For a `$self = assoc.<backlink>` comparison, the three tokens are replaced
2236
+ * by the on-condition of the <backlink>.
2237
+ *
2238
+ *
2239
+ * @param {on} tokenStream the on condition of the unmanaged association
2240
+ * @returns the final on-condition for the unmanaged association
2241
+ */
2242
+ function calculateOnCondition(tokenStream) {
2243
+ const result = copy(tokenStream) // REVISIT: too expensive!
2244
+ for (let i = 0; i < result.length; i += 1) {
2245
+ const lhs = result[i]
2246
+ if (lhs.xpr) {
2247
+ const xpr = calculateOnCondition(lhs.xpr)
2248
+ result[i] = asXpr(xpr)
2249
+ continue
2250
+ }
2251
+ if (lhs.args) {
2252
+ const args = calculateOnCondition(lhs.args)
2253
+ result[i] = { ...lhs, args }
2254
+ continue
2255
+ }
2256
+ const rhs = result[i + 2]
2257
+ if (rhs?.ref || lhs.ref) {
2258
+ // if we have refs on each side of the comparison, we might need to perform tuple expansion
2259
+ // or flatten the structures
2260
+ const refLinkFaker = thing => {
2261
+ const { ref } = thing
2262
+ const assocHost = getParentEntity(assocRefLink.definition)
2263
+ defineProperty(thing, '$refLinks', [])
2264
+ let pseudoPath = false
2265
+ ref.reduce((prev, res, i) => {
2266
+ if (res === '$self') {
2267
+ // next is resolvable in entity
2268
+ thing.$refLinks.push({ definition: prev, target: prev })
2269
+ return prev
2270
+ }
2271
+ if (res in pseudos.elements) {
2272
+ pseudoPath = true
2273
+ thing.$refLinks.push({ definition: pseudos.elements[res], target: pseudos })
2274
+ return pseudos.elements[res]
2275
+ } else if (pseudoPath) {
2276
+ thing.$refLinks.push({ definition: {}, target: pseudos })
2277
+ return prev?.elements[res]
2278
+ }
2279
+ const definition =
2280
+ prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
2281
+ const target = getParentEntity(definition)
2282
+ thing.$refLinks[i] = {
2283
+ definition,
2284
+ target,
2285
+ alias: definition === assocRefLink.definition ? assocRefLink.alias : definition.name,
2286
+ }
2287
+ return prev?.elements?.[res] || getDefinition(prev?.target)?.elements[res] || pseudos.elements[res]
2288
+ }, assocHost)
2289
+ }
2290
+
2291
+ // comparison in on condition needs to be expanded...
2292
+ // re-use existing algorithm for that
2293
+ // we need to fake some $refLinks for that to work though...
2294
+ lhs?.ref && !lhs.$refLinks && refLinkFaker(lhs)
2295
+ rhs?.ref && !rhs.$refLinks && refLinkFaker(rhs)
2296
+ }
2297
+
2298
+ let backlink
2299
+ if (rhs?.ref && lhs?.ref) {
2300
+ if (lhs?.ref?.length === 1 && lhs.ref[0] === '$self')
2301
+ backlink = getElementForRef(
2302
+ rhs.ref.slice(1),
2303
+ rhs.ref[0] in { $self: true, $projection: true } ? getParentEntity(assocRefLink.definition) : target,
2304
+ )
2305
+ else if (rhs?.ref?.length === 1 && rhs.ref[0] === '$self')
2306
+ backlink = getElementForRef(
2307
+ lhs.ref.slice(1),
2308
+ lhs.ref[0] in { $self: true, $projection: true } ? getParentEntity(assocRefLink.definition) : target,
2309
+ )
2310
+ else {
2311
+ const lhsLeafDef = lhs.ref && lhs.$refLinks.at(-1).definition
2312
+ const rhsLeafDef = rhs.ref && rhs.$refLinks.at(-1).definition
2313
+ const lhsFirstDef = lhs.$refLinks.find(r => r.definition.kind === 'element')?.definition
2314
+ // compare structures in on-condition
2315
+ if ((lhsLeafDef?.target && rhsLeafDef?.target) || (lhsLeafDef?.elements && rhsLeafDef?.elements)) {
2316
+ if (rhs.$refLinks[0].definition !== assocRefLink.definition) {
2317
+ rhs.ref.unshift(targetSideRefLink.alias)
2318
+ rhs.$refLinks.unshift(targetSideRefLink)
2319
+ }
2320
+ if (lhsFirstDef !== assocRefLink.definition) {
2321
+ lhs.ref.unshift(targetSideRefLink.alias)
2322
+ lhs.$refLinks.unshift(targetSideRefLink)
2323
+ }
2324
+ const expandedComparison = getTransformedTokenStream([lhs, result[i + 1], rhs])
2325
+ const res = tokenStream[i + 3] ? [asXpr(expandedComparison)] : expandedComparison
2326
+ result.splice(i, 3, ...res)
2327
+ i += res.length
2328
+ continue
2329
+ }
2330
+ // assumption: if first step is the association itself, all following ref steps must be resolvable
2331
+ // within target `assoc.assoc.fk` -> `assoc.assoc_fk`
2332
+ else if (lhsFirstDef === getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name])
2333
+ result[i].ref = [assocRefLink.alias, lhs.ref.slice(lhs.ref[0] === '$self' ? 2 : 1).join('_')]
2334
+ // naive assumption: if the path starts with an association which is not the association from
2335
+ // which the on-condition originates, it must be a foreign key and hence resolvable in the source
2336
+ else if (lhsFirstDef?.target) result[i].ref = [result[i].ref.join('_')]
2337
+ }
2338
+ }
2339
+ if (backlink) {
2340
+ const wrapInXpr = result[i + 3] || result[i - 1] // if we have a complex on-condition, wrap each part in xpr
2341
+ let backlinkOnCondition = []
2342
+ if (backlink.on) {
2343
+ // unmanaged backlink -> prepend correct aliases
2344
+ backlinkOnCondition = backlink.on.map(t => {
2345
+ if (t.ref?.length > 1 && t.ref[0] === (backlink.name || targetSideRefLink.definition.name)) {
2346
+ return { ref: [targetSideRefLink.alias, ...t.ref.slice(1)] }
2347
+ } else if (t.ref) {
2348
+ if (t.ref.length > 1 && !(t.ref[0] in pseudos.elements))
2349
+ return { ref: [assocRefLink.alias, ...t.ref.slice(1)] }
2350
+ else return { ref: [assocRefLink.alias, ...t.ref] }
2351
+ } else {
2352
+ return t
2353
+ }
2354
+ })
2355
+ } else if (backlink.keys) {
2356
+ // sanity check: error out if we can't produce a join
2357
+ if (backlink.keys.length === 0) {
2358
+ throw new Error(
2359
+ `Path step “${assocRefLink.definition.name}” is a self comparison with “${getFullName(backlink)}” that has no foreign keys`,
2360
+ )
2361
+ }
2362
+ // managed backlink -> calculate fk-pk pairs
2363
+ const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
2364
+ fkPkPairs.forEach((pair, j) => {
2365
+ const { sourceSide, targetSide } = pair
2366
+ sourceSide.ref.unshift(assocRefLink.alias)
2367
+ backlinkOnCondition.push(sourceSide, '=', targetSide)
2368
+ if (!inWhereOrJoin) backlinkOnCondition.reverse()
2369
+ if (fkPkPairs[j + 1]) backlinkOnCondition.push('and')
2370
+ })
2371
+ }
2372
+ result.splice(i, 3, ...(wrapInXpr ? [asXpr(backlinkOnCondition)] : backlinkOnCondition))
2373
+ i += wrapInXpr ? 1 : backlinkOnCondition.length // skip inserted tokens
2374
+ } else if (lhs.ref && lhs.$refLinks[0]?.target !== pseudos) {
2375
+ if (lhs.ref[0] === '$self') {
2376
+ // $self in ref of length > 1
2377
+ // if $self is followed by association, the alias of the association must be used
2378
+ if (lhs.$refLinks[1].definition.isAssociation) result[i].ref.splice(0, 1)
2379
+ // otherwise $self is replaced by the alias of the entity
2380
+ else result[i].ref.splice(0, 1, targetSideRefLink.alias)
2381
+ } else if (lhs.ref.length > 1) {
2382
+ if (lhs.ref[0] !== assocRefLink.alias && lhs.ref[0] !== targetSideRefLink.alias) {
2383
+ // we need to find correct table alias for the structured access
2384
+ const { definition } = lhs.$refLinks[0]
2385
+ if (definition === getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name]) {
2386
+ // first step is the association itself -> use it's name as it becomes the table alias
2387
+ result[i].ref.splice(0, 1, assocRefLink.alias)
2388
+ } else if (
2389
+ definition.name in
2390
+ (targetSideRefLink.definition.elements || getDefinition(targetSideRefLink.definition.target).elements)
2391
+ ) {
2392
+ // first step is association which refers to its foreign key by dot notation
2393
+ result[i].ref = [targetSideRefLink.alias, lhs.ref.join('_')]
2394
+ }
2395
+ }
2396
+ } else if (lhs.ref.length === 1) result[i].ref.unshift(targetSideRefLink.alias)
2397
+ }
2398
+ }
2399
+ return result
2400
+ }
2401
+ /**
2402
+ * Recursively calculates the containing entity for a given element.
2403
+ * If the query is localized, this function may return the localized entity.
2404
+ *
2405
+ * @param {CSN.element} element
2406
+ * @returns {CSN.definition} the entity containing the given element
2407
+ */
2408
+ function getParentEntity(element) {
2409
+ if (!element.kind)
2410
+ // pseudo element
2411
+ return element
2412
+ if (element.kind === 'entity') return element
2413
+ else return getDefinition(getParentEntity(element.parent).name)
2414
+ }
2415
+ }
2416
+
2417
+ /**
2418
+ * For a given managed association, calculate the foreign key - parent key tuples.
2419
+ *
2420
+ * @param {CDS.Association} assoc the association for which the on condition shall be calculated
2421
+ * @param {object} targetSideRefLink the reflink which has the target alias of the (backlink) association
2422
+ * @param {boolean} flipSourceAndTarget target and source side are flipped in the where exists subquery
2423
+ * @returns {[{sourceSide: {ref: []}, targetSide: {ref:[]}}]} array of source side - target side reference tuples, i.e. the foreign keys and parent keys.
2424
+ */
2425
+ function getParentKeyForeignKeyPairs(assoc, targetSideRefLink, flipSourceAndTarget = false) {
2426
+ const res = []
2427
+ const backlink = backlinkFor(assoc)?.[0]
2428
+ const { keys, target } = backlink || assoc
2429
+ if (keys) {
2430
+ keys.forEach(fk => {
2431
+ const { ref, as } = fk
2432
+ const elem = getElementForRef(ref, getDefinition(target)) // find the element (the target element of the foreign key) in the target of the (backlink) association
2433
+ const flatParentKeys = getFlatColumnsFor(elem, { baseName: ref.slice(0, ref.length - 1).join('_') }) // it might be a structured element, so expand it into the full parent key tuple
2434
+ const flatAssociationName = getFullName(backlink || assoc) // get the name of the (backlink) association
2435
+ const flatForeignKeys = getFlatColumnsFor(elem, { baseName: flatAssociationName, columnAlias: as }) // the name of the (backlink) association is the base of the foreign key tuple, also respect aliased fk.
2436
+
2437
+ for (let i = 0; i < flatForeignKeys.length; i++) {
2438
+ if (flipSourceAndTarget) {
2439
+ // `where exists <assoc>` expansion
2440
+ // a backlink will cause the foreign key to be on the source side
2441
+ const refInTarget = backlink ? flatParentKeys[i] : flatForeignKeys[i]
2442
+ const refInSource = backlink ? flatForeignKeys[i] : flatParentKeys[i]
2443
+ res.push({
2444
+ sourceSide: refInSource,
2445
+ targetSide: {
2446
+ ref: [
2447
+ targetSideRefLink.alias,
2448
+ refInTarget.as ? `${flatAssociationName}_${refInTarget.as}` : refInTarget.ref[0],
2449
+ ],
2450
+ },
2451
+ })
2452
+ } else {
2453
+ // `select from <assoc>` to `where exists` expansion
2454
+ // a backlink will cause the foreign key to be on the target side
2455
+ const refInTarget = backlink ? flatForeignKeys[i] : flatParentKeys[i]
2456
+ const refInSource = backlink ? flatParentKeys[i] : flatForeignKeys[i]
2457
+ res.push({
2458
+ sourceSide: {
2459
+ ref: [refInSource.as ? `${flatAssociationName}_${refInSource.as}` : refInSource.ref[0]],
2460
+ },
2461
+ targetSide: { ref: [targetSideRefLink.alias, ...refInTarget.ref] },
2462
+ })
2463
+ }
2464
+ }
2465
+ })
2466
+ }
2467
+ return res
2468
+ }
2469
+
2470
+ /**
2471
+ * Constructs a where exists subquery for a given association - i.e. calculates foreign key / parent key
2472
+ * relations for the association.
2473
+ *
2474
+ * @param {$refLink} current step of the association path
2475
+ * @param {$refLink} next step of the association path
2476
+ * @param {object[]} customWhere infix filter which must be part of the where exists subquery on condition
2477
+ * @param {boolean} inWhere whether or not the path is part of the queries where clause
2478
+ * -> if it is, target and source side are flipped in the where exists subquery
2479
+ * @param {object} queryModifier optional query modifiers: group by, order by, limit, offset
2480
+ *
2481
+ * @returns {CQN.SELECT}
2482
+ */
2483
+ function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false, queryModifier = null) {
2484
+ const { definition } = current
2485
+ const { definition: nextDefinition } = next
2486
+ const on = []
2487
+ const fkSource = inWhere ? nextDefinition : definition
2488
+ // TODO: use onCondFor()
2489
+ if (fkSource.keys) {
2490
+ const pkFkPairs = getParentKeyForeignKeyPairs(fkSource, current, inWhere)
2491
+ pkFkPairs.forEach((pkFkPair, i) => {
2492
+ const { targetSide, sourceSide } = pkFkPair
2493
+ sourceSide.ref.unshift(next.alias)
2494
+ if (i > 0) on.push('and')
2495
+ on.push(sourceSide, '=', targetSide)
2496
+ })
2497
+ } else {
2498
+ const unmanagedOn = onCondFor(inWhere ? next : current, inWhere ? current : next, inWhere)
2499
+ on.push(...(customWhere && hasLogicalOr(unmanagedOn) ? [asXpr(unmanagedOn)] : unmanagedOn))
2500
+ }
2501
+
2502
+ const subquerySource = getDefinition(nextDefinition.target) || nextDefinition
2503
+ const id = getLocalizedName(subquerySource)
2504
+ const SELECT = {
2505
+ from: {
2506
+ ref: [subquerySource.params ? { id, args: queryModifier.args || {} } : id],
2507
+ as: next.alias,
2508
+ },
2509
+ columns: [
2510
+ {
2511
+ val: 1,
2512
+ // as: 'dummy'
2513
+ },
2514
+ ],
2515
+ where: on,
2516
+ }
2517
+ // this requires sub-sequent transformation of the subquery
2518
+ if (
2519
+ next.pathExpressionInsideFilter ||
2520
+ (queryModifier && ['orderBy', 'groupBy', 'having', 'limit', 'offset'].some(key => key in queryModifier))
2521
+ ) {
2522
+ SELECT.where = customWhere || []
2523
+ if (queryModifier) assignQueryModifiers(SELECT, queryModifier)
2524
+
2525
+ const transformedExists = transformSubquery({ SELECT })
2526
+ if (transformedExists.SELECT.where?.length) {
2527
+ const wrappedWhere = hasLogicalOr(transformedExists.SELECT.where)
2528
+ ? [asXpr(transformedExists.SELECT.where)]
2529
+ : transformedExists.SELECT.where
2530
+
2531
+ on.push('and', ...wrappedWhere)
2532
+ }
2533
+ transformedExists.SELECT.where = on
2534
+ return transformedExists.SELECT
2535
+ }
2536
+
2537
+ if (customWhere) {
2538
+ const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
2539
+ const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
2540
+ on.push('and', ...wrappedFilter)
2541
+ }
2542
+ return SELECT
2543
+ }
2544
+
2545
+ /**
2546
+ * For a given search term calculate a search expression which can be used in a where clause.
2547
+ * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match
2548
+ * the search results of the subquery.
2549
+ *
2550
+ * @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source.
2551
+ * @param {object} query - The FROM clause of the CQN statement.
2552
+ *
2553
+ * @returns {(Object|null)} returns either:
2554
+ * - an expression of the form `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
2555
+ * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression.
2556
+ * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself.
2557
+ */
2558
+ function getSearch(searchTerm, query) {
2559
+ const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead
2560
+ const searchIn = computeColumnsToBeSearched(inferred, entity)
2561
+ if (searchIn.length === 0) return null
2562
+
2563
+ const searchFunc = {
2564
+ func: 'search',
2565
+ args: [
2566
+ searchIn.length === 1 ? searchIn[0] : { list: searchIn },
2567
+ searchTerm.length === 1 && 'val' in searchTerm[0] ? searchTerm[0] : { xpr: searchTerm },
2568
+ ],
2569
+ }
2570
+ // for aggregated queries / search on subqueries we do not do a subquery search
2571
+ if (inferred.SELECT.groupBy || entity.SELECT)
2572
+ return searchFunc
2573
+
2574
+ const matchColumns = getPrimaryKey(entity)
2575
+ if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior
2576
+ return searchFunc
2577
+
2578
+ const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc)
2579
+ return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] }
2580
+ }
2581
+
2582
+ /**
2583
+ * Calculates the name of the source which can be used to address the given node.
2584
+ *
2585
+ * @param {object} node a csn object with a `ref` and `$refLinks`
2586
+ * @param {object} $baseLink optional base `$refLink`, e.g. for infix filters.
2587
+ * For an infix filter, we must explicitly pass the TA name
2588
+ * because the first step of the ref might not be part of
2589
+ * the combined elements of the query
2590
+ * @returns the source name which can be used to address the node
2591
+ */
2592
+ function getTableAlias(node, $baseLink = null) {
2593
+ if (!node || !node.$refLinks || !node.ref) {
2594
+ throw new Error('Invalid node')
2595
+ }
2596
+ if (node.$refLinks[0].$main) {
2597
+ if (node.isJoinRelevant) {
2598
+ return getJoinRelevantAlias(node)
2599
+ }
2600
+ return node.$refLinks[0].alias
2601
+ }
2602
+ if ($baseLink) {
2603
+ return getBaseLinkAlias($baseLink)
2604
+ }
2605
+ if (node.isJoinRelevant) {
2606
+ return getJoinRelevantAlias(node)
2607
+ }
2608
+ return getSelectOrEntityAlias(node) || getCombinedElementAlias(node)
2609
+ function getBaseLinkAlias($baseLink) {
2610
+ return $baseLink.alias
2611
+ }
2612
+
2613
+ function getJoinRelevantAlias(node) {
2614
+ return [...node.$refLinks]
2615
+ .reverse()
2616
+ .find($refLink => $refLink.definition.isAssociation && !$refLink.onlyForeignKeyAccess).alias
2617
+ }
2618
+
2619
+ function getSelectOrEntityAlias(node) {
2620
+ let firstRefLink = node.$refLinks[0].definition
2621
+ if (firstRefLink.SELECT || firstRefLink.kind === 'entity') {
2622
+ const firstStep = node.ref[0]
2623
+ /**
2624
+ * If the node.ref refers to an implicit alias which is later on changed by cqn4sql,
2625
+ * we need to replace the usage of the implicit alias, with the correct, auto-generated table alias.
2626
+ *
2627
+ * This is the case if the following holds true:
2628
+ * - the original query has NO explicit alias
2629
+ * - ref[0] equals the implicit alias of the query (i.e. from.ref[ from.length - 1 ].split('.').pop())
2630
+ * - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
2631
+ */
2632
+ if (
2633
+ inferred.SELECT?.from.uniqueSubqueryAlias &&
2634
+ !inferred.SELECT?.from.as &&
2635
+ getImplicitAlias(firstStep) === getImplicitAlias(transformedQuery.SELECT.from.ref[0])
2636
+ ) {
2637
+ return inferred.SELECT?.from.uniqueSubqueryAlias
2638
+ }
2639
+ return node.ref[0]
2640
+ }
2641
+ }
2642
+
2643
+ function getCombinedElementAlias(node) {
2644
+ return inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index
2645
+ }
2646
+ }
2647
+ function getTransformedFunctionArgs(args, $baseLink = null) {
2648
+ let result = null
2649
+ if (Array.isArray(args)) {
2650
+ result = args.flatMap(t => {
2651
+ if (!t.val)
2652
+ // this must not be touched
2653
+ return getTransformedTokenStream([t], { $baseLink })
2654
+ return t
2655
+ })
2656
+ } else if (typeof args === 'object') {
2657
+ result = {}
2658
+ for (const prop in args) {
2659
+ const t = args[prop]
2660
+ if (!t.val)
2661
+ // this must not be touched
2662
+ result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
2663
+ else result[prop] = t
2664
+ }
2665
+ }
2666
+ return result
2667
+ }
2668
+
2669
+ /**
2670
+ * Resolves an enum token to a value literal.
2671
+ *
2672
+ * If the token already has a `val`, it is used directly.
2673
+ * Otherwise, the enum value is resolved by looking up the symbol
2674
+ * in the enum definition found from the surrounding context.
2675
+ *
2676
+ * @param {object} token - The enum token with a `#` property.
2677
+ * @param {object[]} tokenStream - The surrounding token stream for context discovery.
2678
+ * @param {number} index - The index of the enum token in the token stream.
2679
+ * @param {object} [enumDef] - An already-discovered enum definition (optimization for lists).
2680
+ * @returns {object} A value token `{ val: resolvedValue }`.
2681
+ */
2682
+ function resolveEnumToken(token, tokenStream, index, enumDef) {
2683
+ if ('val' in token) {
2684
+ const result = { val: token.val }
2685
+ if (token.cast) result.cast = resolveEnumCastType(token.cast)
2686
+ return result
2687
+ }
2688
+
2689
+ // Check if the token itself has a cast with an enum type
2690
+ if (!enumDef && token.cast?.type) {
2691
+ const typeDef = model.definitions[token.cast.type]
2692
+ if (typeDef?.enum) enumDef = typeDef.enum
2693
+ }
2694
+
2695
+ if (!enumDef) enumDef = findEnumDefinition(tokenStream, index)
2696
+ if (!enumDef) {
2697
+ throw new Error(`Can't resolve enum value "#${token['#']}"`)
2698
+ }
2699
+
2700
+ const entry = enumDef[token['#']]
2701
+ if (!entry) {
2702
+ throw new Error(`Unknown enum symbol "#${token['#']}"`)
2703
+ }
2704
+
2705
+ const result = { val: 'val' in entry ? entry.val : token['#'] }
2706
+ if (token.cast) result.cast = resolveEnumCastType(token.cast)
2707
+ return result
2708
+ }
2709
+
2710
+ /**
2711
+ * If `cast.type` refers to a user-defined enum type, resolves it to the
2712
+ * underlying scalar CDS built-in type so that the SQL builder (`cqn2sql`)
2713
+ * can render a valid SQL type name.
2714
+ *
2715
+ * Example: `{ type: 'enums.Priority' }` → `{ type: 'cds.Integer' }`
2716
+ *
2717
+ * Non-enum types (including CDS built-ins) are returned unchanged.
2718
+ *
2719
+ * @param {object} cast - The cast descriptor with a `type` property.
2720
+ * @returns {object} The cast descriptor with the resolved type.
2721
+ */
2722
+ function resolveEnumCastType(cast) {
2723
+ if (!cast?.type) return cast
2724
+ let def = model.definitions[cast.type]
2725
+ while (def?.enum) {
2726
+ const baseType = def.type
2727
+ if (!baseType) return cast // no base type declared – leave as-is
2728
+ if (cds.builtin.types[baseType]) return { ...cast, type: baseType }
2729
+ def = model.definitions[baseType]
2730
+ }
2731
+ return cast
2732
+ }
2733
+
2734
+ /**
2735
+ * Scans the token stream around the given index to find an element
2736
+ * definition that has an `enum` property, which can be used to resolve
2737
+ * enum symbols to their values.
2738
+ *
2739
+ * @param {object[]} tokenStream - The token stream to scan.
2740
+ * @param {number} index - The index of the enum token.
2741
+ * @returns {object|null} The enum definition object, or null if not found.
2742
+ */
2743
+ function findEnumDefinition(tokenStream, index) {
2744
+ // Scan backward
2745
+ for (let j = index - 1; j >= 0; j--) {
2746
+ const t = tokenStream[j]
2747
+ if (typeof t === 'string') continue // operators, keywords
2748
+ if (t !== null && typeof t === 'object' && '#' in t) continue // other enum tokens
2749
+ if ('val' in t && !t.ref) continue // plain value literals
2750
+
2751
+ const def = t.$refLinks?.at(-1)?.definition
2752
+ if (def?.enum) return def.enum
2753
+ if (t.cast?.type) {
2754
+ const typeDef = model.definitions[t.cast.type]
2755
+ if (typeDef?.enum) return typeDef.enum
2756
+ }
2757
+ if (def) break // found a ref without enum type, stop
2758
+ }
2759
+
2760
+ // Scan forward
2761
+ for (let j = index + 1; j < tokenStream.length; j++) {
2762
+ const t = tokenStream[j]
2763
+ if (typeof t === 'string') continue
2764
+ if (t !== null && typeof t === 'object' && '#' in t) continue
2765
+ if ('val' in t && !t.ref) continue
2766
+
2767
+ const def = t.$refLinks?.at(-1)?.definition
2768
+ if (def?.enum) return def.enum
2769
+ if (t.cast?.type) {
2770
+ const typeDef = model.definitions[t.cast.type]
2771
+ if (typeDef?.enum) return typeDef.enum
2772
+ }
2773
+ if (def) break
2774
+ }
2775
+
2776
+ return null
2777
+ }
2778
+ }
2779
+
2780
+ function calculateElementName(token) {
2781
+ const nonJoinRelevantAssoc = [...token.$refLinks].findIndex(l => l.definition.isAssociation && l.onlyForeignKeyAccess)
2782
+ let name
2783
+ if (nonJoinRelevantAssoc !== -1)
2784
+ // calculate fk name
2785
+ name = token.ref.slice(nonJoinRelevantAssoc).join('_')
2786
+ else name = getFullName(token.$refLinks[token.$refLinks.length - 1].definition)
2787
+ return name
2788
+ }
2789
+
2790
+ /**
2791
+ * Calculate the flat name for a deeply nested element:
2792
+ * @example `entity E { struct: { foo: String} }` => `getFullName(foo)` => `struct_foo`
2793
+ *
2794
+ * @param {CSN.element} node an element
2795
+ * @param {object} name the last part of the name, e.g. the name of the deeply nested element
2796
+ * @returns the flat name of the element
2797
+ */
2798
+ function getFullName(node, name = node.name) {
2799
+ // REVISIT: this is an unfortunate implementation
2800
+ if (!node.parent || node.parent.kind === 'entity') return name
2801
+
2802
+ return getFullName(node.parent, `${node.parent.name}_${name}`)
2803
+ }
2804
+
2805
+ function copy(obj) {
2806
+ const walk = function (par, prop) {
2807
+ const val = prop ? par[prop] : par
2808
+
2809
+ // If value is native return
2810
+ if (typeof val !== 'object' || val == null || val instanceof RegExp || val instanceof Date || val instanceof Buffer)
2811
+ return val
2812
+
2813
+ const ret = Array.isArray(val) ? [] : {}
2814
+ Object.keys(val).forEach(k => {
2815
+ ret[k] = walk(val, k)
2816
+ })
2817
+ return ret
2818
+ }
2819
+
2820
+ return walk(obj)
2821
+ }
2822
+
2823
+ function hasLogicalOr(tokenStream) {
2824
+ return tokenStream.some(t => t in { OR: true, or: true })
2825
+ }
2826
+
2827
+ function getParentEntity(element) {
2828
+ if (element.kind === 'entity') return element
2829
+ else return getParentEntity(element.parent)
2830
+ }
2831
+
2832
+ function asXpr(thing) {
2833
+ return { xpr: thing }
2834
+ }
2835
+
2836
+ function assignQueryModifiers(SELECT, modifiers) {
2837
+ for (const [key, val] of Object.entries(modifiers)) {
2838
+ if (key in { orderBy: 1, groupBy: 1 }) {
2839
+ if (SELECT[key]) SELECT[key].push(...val)
2840
+ else SELECT[key] = val
2841
+ } else if (key === 'limit') {
2842
+ // limit defined on the query has precedence
2843
+ if (!SELECT.limit) SELECT.limit = val
2844
+ } else if (key === 'having') {
2845
+ if (!SELECT.having) SELECT.having = val
2846
+ else SELECT.having.push('and', ...val)
2847
+ } else if (key === 'where') {
2848
+ // ignore OData shortcut variant: `… bookshop.Orders:items[2]`
2849
+ if (!val || val.length === 1 && val[0].val) continue
2850
+ if (!SELECT.where) SELECT.where = val
2851
+ // infix filter comes first in resulting where
2852
+ else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)]
2853
+ }
2854
+ }
2855
+ }
2856
+
2857
+ /**
2858
+ * Assigns the given `element` as non-enumerable property 'element' onto `col`.
2859
+ *
2860
+ * @param {object} col
2861
+ * @param {csn.Element} element
2862
+ */
2863
+ function setElementOnColumns(col, element) {
2864
+ defineProperty(col, 'element', element)
2865
+ }
2866
+
2867
+ function getPrimaryKey(entity, tableAlias = null) {
2868
+ const primaryKey = []
2869
+ for (const k of Object.keys(entity.elements)) {
2870
+ const e = entity.elements[k]
2871
+ if (e.key === true && !e.virtual && e.isAssociation !== true) {
2872
+ primaryKey.push({ ref: tableAlias ? [tableAlias, e.name] : [e.name] })
2873
+ }
2874
+ }
2875
+ return primaryKey
2876
+ }
2877
+
2878
+ const getName = col => col.as || col.ref?.at(-1)
2879
+ const idOnly = ref => ref.id || ref
2880
+ const refWithConditions = step => {
2881
+ let appendix
2882
+ const { args, where } = step
2883
+ if (where && args) appendix = JSON.stringify(where) + JSON.stringify(args)
2884
+ else if (where) appendix = JSON.stringify(where)
2885
+ else if (args) appendix = JSON.stringify(args)
2886
+ return appendix ? step.id + appendix : step
2887
+ }
2888
+ const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
2889
+
2890
+ module.exports = Object.assign(cqn4sql, {
2891
+ // for own tests only:
2892
+ eqOps,
2893
+ notEqOps,
2894
+ notSupportedOps,
2895
+ })