@cap-js/db-service 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cqn4sql.js ADDED
@@ -0,0 +1,1846 @@
1
+ 'use strict'
2
+
3
+ const cds = require('@sap/cds/lib')
4
+ const { computeColumnsToBeSearched } = require('@sap/cds/libx/_runtime/cds-services/services/utils/columns.js')
5
+
6
+ const infer = require('./infer')
7
+
8
+ /**
9
+ * For operators of <eqOps>, this is replaced by comparing all leaf elements with null, combined with and.
10
+ * 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 (...)
11
+ */
12
+ const eqOps = [['is'], ['='] /* ['=='] */]
13
+ /**
14
+ * For operators of <notEqOps>, do the same but use or instead of and.
15
+ * This ensures that not struc == <value> is the same as struc != <value>.
16
+ */
17
+ const notEqOps = [['is', 'not'], ['<>'], ['!=']]
18
+ /**
19
+ * not supported in comparison w/ struct because of unclear semantics
20
+ */
21
+ const notSupportedOps = [['>'], ['<'], ['>='], ['<=']]
22
+
23
+ const allOps = eqOps.concat(eqOps).concat(notEqOps).concat(notSupportedOps)
24
+
25
+ const { pseudos } = require('./infer/pseudos')
26
+ /**
27
+ * Transforms a CDL style query into SQL-Like CQN:
28
+ * - transform association paths in `from` to `WHERE exists` subqueries
29
+ * - transforms columns into their flat representation.
30
+ * 1. Flatten managed associations to their foreign
31
+ * 2. Flatten structures to their leafs
32
+ * 3. Replace join-relevant ref paths (i.e. non-fk accesses in association paths) with the correct join alias
33
+ * - transforms `expand` columns into special, normalized subqueries
34
+ * - transform `where` clause.
35
+ * That is the flattening of all `ref`s and the expansion of `where exists` predicates
36
+ * - rewrites `from` clause:
37
+ * Each join relevant association path traversal is translated to a join condition.
38
+ *
39
+ * `cqn4sql` is applied recursively to all queries found in `from`, `columns` and `where`
40
+ * of a query.
41
+ *
42
+ * @param {object} originalQuery
43
+ * @param {object} model
44
+ * @returns {object} transformedQuery the transformed query
45
+ */
46
+ function cqn4sql(originalQuery, model = cds.context?.model || cds.model) {
47
+ const inferred = infer(originalQuery, model)
48
+ if (originalQuery.SELECT?.from.args && !originalQuery.joinTree) return inferred
49
+
50
+ let transformedQuery = cds.ql.clone(inferred)
51
+ const kind = inferred.cmd || Object.keys(inferred)[0]
52
+
53
+ if (inferred.INSERT || inferred.UPSERT) {
54
+ transformedQuery = transformQueryForInsertUpsert(kind)
55
+ } else {
56
+ const queryProp = inferred[kind]
57
+ if (!inferred.STREAM?.from && inferred.STREAM?.into) {
58
+ transformedQuery = transformStreamQuery()
59
+ } else {
60
+ const { entity, where } = queryProp
61
+ const from = queryProp.from
62
+
63
+ const transformedProp = { __proto__: queryProp } // IMPORTANT: don't lose anything you might not know of
64
+
65
+ // Transform the existing where, prepend table aliases, and so on...
66
+ if (where) {
67
+ transformedProp.where = getTransformedTokenStream(where)
68
+ }
69
+
70
+ // Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
71
+ // The already transformed `where` clause is then glued together with the resulting subqueries.
72
+ const { transformedWhere, transformedFrom } = getTransformedFrom(from || entity, transformedProp.where)
73
+
74
+ if (inferred.SELECT) {
75
+ transformedQuery = transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery)
76
+ } else {
77
+ if (from) {
78
+ transformedProp.from = transformedFrom
79
+ } else {
80
+ transformedProp.entity = transformedFrom
81
+ }
82
+
83
+ if (transformedWhere?.length > 0) {
84
+ transformedProp.where = transformedWhere
85
+ }
86
+
87
+ transformedQuery[kind] = transformedProp
88
+
89
+ if (inferred.UPDATE?.with) {
90
+ Object.entries(inferred.UPDATE.with).forEach(([key, val]) => {
91
+ const transformed = getTransformedTokenStream([val])
92
+ inferred.UPDATE.with[key] = transformed[0]
93
+ })
94
+ }
95
+ }
96
+
97
+ if (inferred.joinTree && !inferred.joinTree.isInitial) {
98
+ transformedQuery[kind].from = translateAssocsToJoins(transformedQuery[kind].from)
99
+ }
100
+ }
101
+ }
102
+
103
+ return transformedQuery
104
+
105
+ function transformSelectQuery(queryProp, transformedFrom, transformedWhere, transformedQuery) {
106
+ const { columns, having, groupBy, orderBy, limit } = queryProp
107
+
108
+ // Trivial replacement -> no transformations needed
109
+ if (limit) {
110
+ transformedQuery.SELECT.limit = limit
111
+ }
112
+
113
+ transformedQuery.SELECT.from = transformedFrom
114
+
115
+ if (transformedWhere?.length > 0) {
116
+ transformedQuery.SELECT.where = transformedWhere
117
+ }
118
+
119
+ if (columns) {
120
+ transformedQuery.SELECT.columns = getTransformedColumns(columns)
121
+ } else {
122
+ transformedQuery.SELECT.columns = getColumnsForWildcard()
123
+ }
124
+
125
+ // Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
126
+ if (having) {
127
+ transformedQuery.SELECT.having = getTransformedTokenStream(having)
128
+ }
129
+
130
+ if (groupBy) {
131
+ const transformedGroupBy = getTransformedOrderByGroupBy(groupBy)
132
+ if (transformedGroupBy.length) {
133
+ transformedQuery.SELECT.groupBy = transformedGroupBy
134
+ }
135
+ }
136
+
137
+ // Since all the expressions in the SELECT part of the query have been computed,
138
+ // one can reference aliases of the queries columns in the orderBy clause.
139
+ if (orderBy) {
140
+ const transformedOrderBy = getTransformedOrderByGroupBy(orderBy, true)
141
+ if (transformedOrderBy.length) {
142
+ transformedQuery.SELECT.orderBy = transformedOrderBy
143
+ }
144
+ }
145
+
146
+ if (inferred.SELECT.search) {
147
+ // Search target can be a navigation, in that case use _target to get the correct entity
148
+ const where = transformSearchToWhere(inferred.SELECT.search, transformedFrom)
149
+ if (where) {
150
+ transformedQuery.SELECT.where = where
151
+ }
152
+ }
153
+ return transformedQuery
154
+ }
155
+
156
+ /**
157
+ * Transforms a query object for INSERT or UPSERT operations by modifying the `into` clause.
158
+ *
159
+ * @param {string} kind - The type of operation: "INSERT" or "UPSERT".
160
+ *
161
+ * @returns {Object} - The transformed query with updated `into` clause.
162
+ */
163
+ function transformQueryForInsertUpsert(kind) {
164
+ const { as } = transformedQuery[kind].into
165
+ transformedQuery[kind].into = { ref: [inferred.target.name] }
166
+ if (as) transformedQuery[kind].into.as = as
167
+ return transformedQuery
168
+ }
169
+
170
+ /**
171
+ * Transforms a stream query, replacing the `where` and `into` clauses after processing.
172
+ *
173
+ * @param {Object} inferred - The inferred object containing the STREAM query.
174
+ * @param {Object} transformedQuery - The query object to be transformed.
175
+ *
176
+ * @returns {Object} - The transformed query with updated STREAM clauses.
177
+ */
178
+ function transformStreamQuery() {
179
+ const { into, where } = inferred.STREAM
180
+ const transformedProp = { __proto__: inferred.STREAM }
181
+ if (where) {
182
+ transformedProp.where = getTransformedTokenStream(where)
183
+ }
184
+ const { transformedWhere, transformedFrom } = getTransformedFrom(into, transformedProp.where)
185
+ if (transformedWhere?.length > 0) {
186
+ transformedProp.where = transformedWhere
187
+ }
188
+ transformedProp.into = transformedFrom
189
+ transformedQuery.STREAM = transformedProp
190
+ return transformedQuery
191
+ }
192
+
193
+ /**
194
+ * Transforms a search expression to a WHERE clause for a SELECT operation.
195
+ *
196
+ * @param {Object} search - The search expression which shall be applied to the searchable columns on the query source.
197
+ * @param {Object} from - The FROM clause of the CQN statement.
198
+ *
199
+ * @returns {(Object|Array|undefined)} - If the target of the query contains searchable elements, the function returns an array that represents the WHERE clause.
200
+ * If the SELECT query already contains a WHERE clause, this array includes the existing clause and appends an AND condition with the new 'contains' clause.
201
+ * If the SELECT query does not contain a WHERE clause, the returned array solely consists of the 'contains' clause.
202
+ * If the target entity of the query does not contain searchable elements, the function returns null.
203
+ *
204
+ */
205
+ function transformSearchToWhere(search, from) {
206
+ const entity = from.$refLinks[0].definition._target || from.$refLinks[0].definition
207
+ const searchIn = computeColumnsToBeSearched(inferred, entity, from.as)
208
+ if (searchIn.length > 0) {
209
+ const xpr = search
210
+ const contains = {
211
+ func: 'search',
212
+ args: [
213
+ searchIn.length > 1 ? { list: searchIn } : { ...searchIn[0] },
214
+ xpr.length === 1 && 'val' in xpr[0] ? xpr[0] : { xpr },
215
+ ],
216
+ }
217
+
218
+ if (transformedQuery.SELECT.where) {
219
+ return [asXpr(transformedQuery.SELECT.where), 'and', contains]
220
+ } else {
221
+ return [contains]
222
+ }
223
+ } else {
224
+ return null
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Rewrites the from clause based on the `query.joinTree`.
230
+ *
231
+ * For each join relevant node in the join tree, the respective join is generated.
232
+ * Each join relevant node in the join tree has an unique table alias which is the query source for the respective
233
+ * path traversals. Hence, all join relevant `ref`s must be rewritten to point to the generated join aliases. However,
234
+ * this is done in the @function getFlatColumnsFor().
235
+ *
236
+ * @returns {CQN.from}
237
+ */
238
+ function translateAssocsToJoins() {
239
+ let from
240
+ /**
241
+ * remember already seen aliases, do not create a join for them again
242
+ */
243
+ const alreadySeen = new Map()
244
+ inferred.joinTree._roots.forEach(r => {
245
+ const args = r.queryArtifact.SELECT
246
+ ? [{ SELECT: transformSubquery(r.queryArtifact).SELECT, as: r.alias }]
247
+ : [{ ref: [localized(r.queryArtifact)], as: r.alias }]
248
+ from = { join: 'left', args, on: [] }
249
+ r.children.forEach(c => {
250
+ from = joinForBranch(from, c)
251
+ from = { join: 'left', args: [from], on: [] }
252
+ })
253
+ })
254
+ return from.args.length > 1 ? from : from.args[0]
255
+
256
+ function joinForBranch(lhs, node) {
257
+ const nextAssoc = inferred.joinTree.findNextAssoc(node)
258
+ if (!nextAssoc || alreadySeen.has(nextAssoc.$refLink.alias)) return lhs.args.length > 1 ? lhs : lhs.args[0]
259
+
260
+ lhs.on.push(
261
+ ...onCondFor(
262
+ nextAssoc.$refLink,
263
+ node.parent.$refLink || /** tree roots do not have $refLink */ {
264
+ alias: node.parent.alias,
265
+ definition: node.parent.queryArtifact,
266
+ target: node.parent.queryArtifact,
267
+ },
268
+ /** flip source and target in on condition */ true,
269
+ ),
270
+ )
271
+
272
+ const arg = {
273
+ ref: [localized(model.definitions[nextAssoc.$refLink.definition.target])],
274
+ as: nextAssoc.$refLink.alias,
275
+ }
276
+ lhs.args.push(arg)
277
+ alreadySeen.set(nextAssoc.$refLink.alias, true)
278
+ if (nextAssoc.where) {
279
+ const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
280
+ lhs.on = [
281
+ ...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
282
+ 'and',
283
+ ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter),
284
+ ]
285
+ }
286
+ if (node.children) {
287
+ node.children.forEach(c => {
288
+ lhs = { join: 'left', args: [lhs], on: [] }
289
+ lhs = joinForBranch(lhs, c)
290
+ })
291
+ }
292
+ return lhs.args.length > 1 ? lhs : lhs.args[0]
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Walks over a list of columns (ref's, xpr, subqueries, val), applies flattening on structured types and expands wildcards.
298
+ *
299
+ * @param {object[]} columns
300
+ * @returns {object[]} the transformed representation of the input. Expanded and flattened.
301
+ */
302
+ function getTransformedColumns(columns) {
303
+ const transformedColumns = []
304
+
305
+ for (let i = 0; i < columns.length; i++) {
306
+ const col = columns[i]
307
+
308
+ if (col.expand) {
309
+ handleExpand(col)
310
+ } else if (col.inline) {
311
+ handleInline(col)
312
+ } else if (col.ref) {
313
+ handleRef(col)
314
+ } else if (col === '*') {
315
+ handleWildcard(columns)
316
+ } else {
317
+ handleDefault(col)
318
+ }
319
+ }
320
+
321
+ if (transformedColumns.length === 0 && columns.length) {
322
+ handleEmptyColumns(columns)
323
+ }
324
+
325
+ return transformedColumns
326
+
327
+ function handleExpand(col) {
328
+ const { $refLinks } = col
329
+ const last = $refLinks?.[$refLinks.length - 1]
330
+ if (last && !last.skipExpand && last.definition.isAssociation) {
331
+ const expandedSubqueryColumn = expandColumn(col)
332
+ transformedColumns.push(expandedSubqueryColumn)
333
+ } else if (!last?.skipExpand) {
334
+ const expandCols = nestedProjectionOnStructure(col, 'expand')
335
+ transformedColumns.push(...expandCols)
336
+ }
337
+ }
338
+
339
+ function handleInline(col) {
340
+ const inlineCols = nestedProjectionOnStructure(col)
341
+ transformedColumns.push(...inlineCols)
342
+ }
343
+
344
+ function handleRef(col) {
345
+ if (pseudos.elements[col.ref[0]] || col.param) {
346
+ transformedColumns.push({ ...col })
347
+ return
348
+ }
349
+
350
+ const tableAlias = getQuerySourceName(col)
351
+ // re-adjust usage of implicit alias in subquery
352
+ if (col.$refLinks[0].definition.kind === 'entity' && col.ref[0] !== tableAlias) {
353
+ col.ref[0] = tableAlias
354
+ }
355
+ const leaf = col.$refLinks[col.$refLinks.length - 1].definition
356
+ if (leaf.virtual === true) return
357
+
358
+ let baseName
359
+ if (col.ref.length >= 2) {
360
+ baseName = col.ref.slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1).join('_')
361
+ }
362
+
363
+ let columnAlias = col.as || (col.isJoinRelevant ? col.flatName : null)
364
+ const refNavigation = col.ref.slice(col.ref[0] === tableAlias ? 1 : 0).join('_')
365
+ if (!columnAlias && col.flatName && col.flatName !== refNavigation) columnAlias = refNavigation
366
+
367
+ if (col.$refLinks.some(link => link.definition._target?.['@cds.persistence.skip'] === true)) return
368
+
369
+ const flatColumns = getFlatColumnsFor(col, { baseName, columnAlias, tableAlias })
370
+ flatColumns.forEach(flatColumn => {
371
+ const { as } = flatColumn
372
+ if (!(as && transformedColumns.some(inserted => inserted?.as === as))) transformedColumns.push(flatColumn)
373
+ })
374
+ }
375
+
376
+ function handleWildcard(columns) {
377
+ const wildcardIndex = columns.indexOf('*')
378
+ const ignoreInWildcardExpansion = columns.slice(0, wildcardIndex)
379
+ const { excluding } = inferred.SELECT
380
+ if (excluding) ignoreInWildcardExpansion.push(...excluding)
381
+
382
+ const wildcardColumns = getColumnsForWildcard(ignoreInWildcardExpansion, columns.slice(wildcardIndex + 1))
383
+ transformedColumns.push(...wildcardColumns)
384
+ }
385
+
386
+ function handleDefault(col) {
387
+ let transformedColumn = getTransformedColumn(col)
388
+ if (col.as) transformedColumn.as = col.as
389
+
390
+ const replaceWith = transformedColumns.findIndex(t => (t.as || t.ref[t.ref.length - 1]) === transformedColumn.as)
391
+ if (replaceWith === -1) transformedColumns.push(transformedColumn)
392
+ else transformedColumns.splice(replaceWith, 1, transformedColumn)
393
+
394
+ Object.defineProperty(transformedColumn, 'element', { value: originalQuery.elements[col.as] })
395
+ }
396
+
397
+ function getTransformedColumn(col) {
398
+ if (col.SELECT) {
399
+ if (isLocalized(inferred.target)) col.SELECT.localized = true
400
+ if (!col.SELECT.from.as) {
401
+ const uniqueSubqueryAlias = inferred.joinTree.addNextAvailableTableAlias(
402
+ getLastStringSegment(col.SELECT.from.ref[col.SELECT.from.ref.length - 1]),
403
+ originalQuery.outerQueries,
404
+ )
405
+ Object.defineProperty(col.SELECT.from, 'uniqueSubqueryAlias', { value: uniqueSubqueryAlias })
406
+ }
407
+ return transformSubquery(col)
408
+ } else if (col.xpr) {
409
+ return { xpr: getTransformedTokenStream(col.xpr) }
410
+ } else if (col.func) {
411
+ return {
412
+ func: col.func,
413
+ args: col.args && getTransformedTokenStream(col.args),
414
+ as: col.func,
415
+ }
416
+ } else {
417
+ return copy(col)
418
+ }
419
+ }
420
+
421
+ function handleEmptyColumns(columns) {
422
+ if (columns.some(c => c.$refLinks?.[c.$refLinks.length - 1].definition.type === 'cds.Composition')) return
423
+ throw new cds.error('Queries must have at least one non-virtual column')
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Calculates the columns for a nested projection on a structure.
429
+ *
430
+ * @param {object} col
431
+ * @param {'inline'|'expand'} prop the property on which to operate. Default is `inline`.
432
+ * @returns a list of flat columns.
433
+ */
434
+ function nestedProjectionOnStructure(col, prop = 'inline') {
435
+ const res = []
436
+
437
+ col[prop].forEach((nestedProjection, i) => {
438
+ let rewrittenColumns = []
439
+ if (nestedProjection === '*') {
440
+ res.push(...expandNestedProjectionWildcard(col, i, prop))
441
+ } else {
442
+ const nameParts = col.as ? [col.as] : [col.ref.map(idOnly).join('_')]
443
+ nameParts.push(nestedProjection.as ? nestedProjection.as : nestedProjection.ref.map(idOnly).join('_'))
444
+ const name = nameParts.join('_')
445
+ if (nestedProjection.ref) {
446
+ const augmentedInlineCol = { ...nestedProjection }
447
+ augmentedInlineCol.ref = col.ref ? [...col.ref, ...nestedProjection.ref] : nestedProjection.ref
448
+ if (col.as || nestedProjection.as || nestedProjection.isJoinRelevant) {
449
+ augmentedInlineCol.as = nameParts.join('_')
450
+ }
451
+ Object.defineProperties(augmentedInlineCol, {
452
+ $refLinks: { value: [...nestedProjection.$refLinks], writable: true },
453
+ isJoinRelevant: {
454
+ value: nestedProjection.isJoinRelevant,
455
+ writable: true,
456
+ },
457
+ })
458
+ // if the expand is not anonymous, we must prepend the expand columns path
459
+ // to make sure the full path is resolvable
460
+ if (col.ref) {
461
+ augmentedInlineCol.$refLinks.unshift(...col.$refLinks)
462
+ augmentedInlineCol.isJoinRelevant = augmentedInlineCol.isJoinRelevant || col.isJoinRelevant
463
+ }
464
+ const flatColumns = getTransformedColumns([augmentedInlineCol])
465
+ flatColumns.forEach(flatColumn => {
466
+ const flatColumnName = flatColumn.as || flatColumn.ref[flatColumn.ref.length - 1]
467
+ if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === flatColumnName)) {
468
+ const rewrittenColumn = { ...flatColumn }
469
+ if (nestedProjection.as) rewrittenColumn.as = flatColumnName
470
+ rewrittenColumns.push(rewrittenColumn)
471
+ }
472
+ })
473
+ } else {
474
+ // func, xpr, val..
475
+ // we need to check if the column was already added
476
+ // in the wildcard expansion
477
+ if (!res.some(c => (c.as || c.ref.slice(1).map(idOnly).join('_')) === name)) {
478
+ const rewrittenColumn = { ...nestedProjection }
479
+ rewrittenColumn.as = name
480
+ rewrittenColumns.push(rewrittenColumn)
481
+ }
482
+ }
483
+ }
484
+ res.push(...rewrittenColumns)
485
+ })
486
+
487
+ return res
488
+ }
489
+
490
+ /**
491
+ * Expand the wildcard of the given column into all leaf elements.
492
+ * Respect smart wildcard rules and excluding clause.
493
+ *
494
+ * Every column before the wildcardIndex is excluded from the wildcard expansion.
495
+ * Columns after the wildcardIndex overwrite columns within the wildcard expansion in place.
496
+ *
497
+ * @TODO use this also for `expand` wildcards on structures.
498
+ *
499
+ * @param {csn.Column} col
500
+ * @param {integer} wildcardIndex
501
+ * @returns an array of columns which represents the expanded wildcard
502
+ */
503
+ function expandNestedProjectionWildcard(col, wildcardIndex, prop = 'inline') {
504
+ const res = []
505
+ // everything before the wildcard is inserted before the wildcard
506
+ // and ignored from the wildcard expansion
507
+ const exclude = col[prop].slice(0, wildcardIndex)
508
+ // everything after the wildcard, is a potential replacement
509
+ // in the wildcard expansion
510
+ const replace = []
511
+ // we need to absolutefy the refs
512
+ col[prop].slice(wildcardIndex + 1).forEach(c => {
513
+ const fakeColumn = { ...c }
514
+ if (fakeColumn.ref) {
515
+ fakeColumn.ref = [...col.ref, ...fakeColumn.ref]
516
+ fakeColumn.$refLinks = [...col.$refLinks, ...c.$refLinks]
517
+ }
518
+ replace.push(fakeColumn)
519
+ })
520
+ // respect excluding clause
521
+ if (col.excluding) {
522
+ // fake the ref since excluding only has strings
523
+ col.excluding.forEach(c => {
524
+ const fakeColumn = {
525
+ ref: [...col.ref, c],
526
+ }
527
+ exclude.push(fakeColumn)
528
+ })
529
+ }
530
+
531
+ if (col.$refLinks[col.$refLinks.length - 1].definition.kind === 'entity')
532
+ res.push(...getColumnsForWildcard(exclude, replace))
533
+ else
534
+ res.push(
535
+ ...getFlatColumnsFor(col, { columnAlias: col.as, tableAlias: getQuerySourceName(col) }, [], {
536
+ exclude,
537
+ replace,
538
+ }),
539
+ )
540
+ return res
541
+ }
542
+
543
+ /**
544
+ * This function converts a column with an `expand` property into a subquery.
545
+ *
546
+ * It operates by using the following steps:
547
+ *
548
+ * 1. It creates an intermediate SQL query, selecting `from <effective query source>:...<column>.ref { ...<column>.expand }`.
549
+ * For example, from the query `SELECT from Authors { books { title } }`, it generates:
550
+ * - `SELECT from Authors:books as books {title}`
551
+ *
552
+ * 2. It then adds the properties `expand: true` and `one: <expand assoc>.is2one` to the intermediate SQL query.
553
+ *
554
+ * 3. It applies `cqn4sql` to the intermediate query (ensuring the aliases of the outer query are maintained).
555
+ * For example, `cqn4sql(…)` is used to create the following query:
556
+ * - `SELECT from Books as books {books.title} where exists ( SELECT 1 from Authors as Authors where Authors.ID = books.author_ID )`
557
+ *
558
+ * 4. It then replaces the `exists <subquery>` with the where condition of the `<subquery>` and correlates it with the effective query source.
559
+ * For example, this query is created:
560
+ * - `SELECT from Books as books { books.title } where Authors.ID = books.author_ID`
561
+ *
562
+ * 5. Lastly, it replaces the `expand` column of the original query with the transformed subquery.
563
+ * For example, the query becomes:
564
+ * - `SELECT from Authors { (SELECT from Books as books { books.title } where Authors.ID = books.author_ID) as books }`
565
+ *
566
+ * @param {CSN.column} column - The column with the 'expand' property to be transformed into a subquery.
567
+ *
568
+ * @returns {Object} Returns a subquery correlated with the enclosing query, with added properties `expand:true` and `one:true|false`.
569
+ */
570
+ function expandColumn(column) {
571
+ let outerAlias
572
+ let subqueryFromRef
573
+ if (column.isJoinRelevant) {
574
+ // all n-1 steps of the expand column are already transformed into joins
575
+ // find the last join relevant association. That is the n-1 assoc in the ref path.
576
+ // slice the ref array beginning from the n-1 assoc in the ref and take that as the postfix for the subqueries from ref.
577
+ ;[...column.$refLinks]
578
+ .reverse()
579
+ .slice(1)
580
+ .find((link, i) => {
581
+ if (link.definition.isAssociation) {
582
+ subqueryFromRef = [link.definition.target, ...column.ref.slice(-(i + 1), column.ref.length)]
583
+ // alias of last join relevant association is also the correlation alias for the subquery
584
+ outerAlias = link.alias
585
+ return true
586
+ }
587
+ })
588
+ } else {
589
+ outerAlias = transformedQuery.SELECT.from.as
590
+ subqueryFromRef = [
591
+ ...transformedQuery.SELECT.from.ref,
592
+ ...(column.$refLinks[0].definition.kind === 'entity' ? column.ref.slice(1) : column.ref),
593
+ ]
594
+ }
595
+
596
+ // this is the alias of the column which holds the correlated subquery
597
+ const columnAlias =
598
+ column.as ||
599
+ (column.$refLinks[0].definition.kind === 'entity'
600
+ ? column.ref.slice(1).map(idOnly).join('_') // omit explicit table alias from name of column
601
+ : column.ref.map(idOnly).join('_'))
602
+
603
+ // we need to respect the aliases of the outer query, so the columnAlias might not be suitable
604
+ // as table alias for the correlated subquery
605
+ const uniqueSubqueryAlias = getNextAvailableTableAlias(columnAlias, originalQuery.outerQueries)
606
+
607
+ // `SELECT from Authors { books.genre as genreOfBooks { name } } becomes `SELECT from Books:genre as genreOfBooks`
608
+ const from = { ref: subqueryFromRef, as: uniqueSubqueryAlias }
609
+ const subqueryBase = Object.fromEntries(
610
+ // preserve all props on subquery (`limit`, `order by`, …) but `expand` and `ref`
611
+ Object.entries(column).filter(([key]) => !(key in { ref: true, expand: true })),
612
+ )
613
+ const subquery = {
614
+ SELECT: {
615
+ ...subqueryBase,
616
+ from,
617
+ columns: JSON.parse(JSON.stringify(column.expand)),
618
+ expand: true,
619
+ one: column.$refLinks[column.$refLinks.length - 1].definition.is2one,
620
+ },
621
+ }
622
+ if (isLocalized(inferred.target)) subquery.SELECT.localized = true
623
+ const expanded = transformSubquery(subquery)
624
+ const correlated = _correlate({ ...expanded, as: columnAlias }, outerAlias)
625
+ Object.defineProperty(correlated, 'elements', { value: subquery.elements })
626
+ return correlated
627
+
628
+ function _correlate(subq, outer) {
629
+ const subqueryFollowingExists = (a, indexOfExists) => a[indexOfExists + 1]
630
+ let {
631
+ SELECT: { where },
632
+ } = subq
633
+ let recent = where
634
+ let i = where.indexOf('exists')
635
+ while (i !== -1) {
636
+ where = subqueryFollowingExists((recent = where), i).SELECT.where
637
+ i = where.indexOf('exists')
638
+ }
639
+ const existsIndex = recent.indexOf('exists')
640
+ recent.splice(
641
+ existsIndex,
642
+ 2,
643
+ ...where.map(x => {
644
+ return replaceAliasWithSubqueryAlias(x)
645
+ }),
646
+ )
647
+
648
+ function replaceAliasWithSubqueryAlias(x) {
649
+ const existsSubqueryAlias = recent[existsIndex + 1].SELECT.from.as
650
+ if (existsSubqueryAlias === x.ref?.[0]) return { ref: [outer, ...x.ref.slice(1)] }
651
+ if (x.xpr) x.xpr = x.xpr.map(replaceAliasWithSubqueryAlias)
652
+ return x
653
+ }
654
+ return subq
655
+ }
656
+ }
657
+
658
+ function getTransformedOrderByGroupBy(columns, inOrderBy = false) {
659
+ const res = []
660
+ for (let i = 0; i < columns.length; i++) {
661
+ const col = columns[i]
662
+ if (col.isJoinRelevant) {
663
+ const tableAlias$refLink = getQuerySourceName(col)
664
+ const transformedColumn = {
665
+ ref: [tableAlias$refLink, getFullName(col.$refLinks[col.$refLinks.length - 1].definition)],
666
+ }
667
+ if (col.sort) transformedColumn.sort = col.sort
668
+ if (col.nulls) transformedColumn.nulls = col.nulls
669
+ res.push(transformedColumn)
670
+ } else if (pseudos.elements[col.ref?.[0]]) {
671
+ res.push({ ...col })
672
+ } else if (col.ref) {
673
+ if (col.$refLinks.some(link => link.definition._target?.['@cds.persistence.skip'] === true)) continue
674
+ const { target } = col.$refLinks[0]
675
+ const tableAlias = target.SELECT ? null : getQuerySourceName(col) // do not prepend TA if orderBy column addresses element of query
676
+ const leaf = col.$refLinks[col.$refLinks.length - 1].definition
677
+ if (leaf.virtual === true) continue // already in getFlatColumnForElement
678
+ let baseName
679
+ if (col.ref.length >= 2) {
680
+ // leaf might be intermediate structure
681
+ baseName = col.ref.slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1).join('_')
682
+ }
683
+ const flatColumns = getFlatColumnsFor(col, { baseName, tableAlias })
684
+ /**
685
+ * We can't guarantee that the element order will NOT change in the future.
686
+ * We claim that the element order doesn't matter, hence we can't allow elements
687
+ * in the order by clause which expand to more than one column, as the order impacts
688
+ * the result.
689
+ */
690
+ if (inOrderBy && flatColumns.length > 1)
691
+ cds.error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
692
+ if (col.nulls) flatColumns[0].nulls = col.nulls
693
+ if (col.sort) flatColumns[0].sort = col.sort
694
+ res.push(...flatColumns)
695
+ } else {
696
+ let transformedColumn
697
+ if (col.SELECT) transformedColumn = transformSubquery(col)
698
+ else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
699
+ else if (col.func) transformedColumn = { args: getTransformedTokenStream(col.args), func: col.func }
700
+ // val
701
+ else transformedColumn = copy(col)
702
+ if (col.sort) transformedColumn.sort = col.sort
703
+ if (col.nulls) transformedColumn.nulls = col.nulls
704
+ res.push(transformedColumn)
705
+ }
706
+ }
707
+ return res
708
+ }
709
+
710
+ /**
711
+ * Transforms a subquery.
712
+ *
713
+ * If the current query contains outer queries (is itself a subquery),
714
+ * it appends the current inferred query.
715
+ * Otherwise, it initializes the `outerQueries` array and adds the inferred query.
716
+ * The `outerQueries` property makes sure
717
+ * that the table aliases of the outer queries are accessible within the scope of the subquery.
718
+ * Lastly, it recursively calls cqn4sql on the subquery.
719
+ *
720
+ * @param {object} q - The query to be transformed. This should be a subquery object.
721
+ * @returns {object} - The cqn4sql transformed subquery.
722
+ */
723
+ function transformSubquery(q) {
724
+ if (q.outerQueries) q.outerQueries.push(inferred)
725
+ else {
726
+ const outerQueries = inferred.outerQueries || []
727
+ outerQueries.push(inferred)
728
+ Object.defineProperty(q, 'outerQueries', { value: outerQueries })
729
+ }
730
+ return cqn4sql(q, model)
731
+ }
732
+
733
+ /**
734
+ * This function converts a wildcard into explicit columns.
735
+ *
736
+ * Based on the query's `$combinedElements` attribute, the function computes the flat column representations
737
+ * and returns them. Additionally, it prepends the respective table alias to each column. Columns specified
738
+ * in the `excluding` clause are ignored during this transformation.
739
+ *
740
+ * Furthermore, foreign keys (FK) for OData CSN and blobs are excluded from the wildcard expansion.
741
+ *
742
+ * @param {Array} exclude - An optional list of columns to be excluded during the wildcard expansion.
743
+ * @param {Array} replace - An optional list of columns to replace during the wildcard expansion.
744
+ *
745
+ * @returns {Array} Returns an array of explicit columns derived from the wildcard.
746
+ */
747
+ function getColumnsForWildcard(exclude = [], replace = []) {
748
+ const wildcardColumns = []
749
+ Object.keys(inferred.$combinedElements).forEach(k => {
750
+ const { index, tableAlias } = inferred.$combinedElements[k][0]
751
+ const element = tableAlias.elements[k]
752
+ // ignore FK for odata csn / ignore blobs from wildcard expansion
753
+ if (isODataFlatForeignKey(element) || (element['@Core.MediaType'] && !element['@Core.IsURL'])) return
754
+ // for wildcard on subquery in from, just reference the elements
755
+ if (tableAlias.SELECT && !element.elements && !element.target) {
756
+ wildcardColumns.push(index ? { ref: [index, k] } : { ref: [k] })
757
+ } else {
758
+ const flatColumns = getFlatColumnsFor(element, { tableAlias: index }, [], { exclude, replace }, true)
759
+ wildcardColumns.push(...flatColumns)
760
+ }
761
+ })
762
+ return wildcardColumns
763
+
764
+ /**
765
+ * HACK for odata csn input - foreign keys are already part of the elements in this csn flavor
766
+ * not excluding them from the wildcard columns would cause duplicate columns upon foreign key expansion
767
+ * @param {CSN.element} e
768
+ * @returns {boolean} true if the element is a flat foreign key generated by the compiler
769
+ */
770
+ function isODataFlatForeignKey(e) {
771
+ return Boolean(e['@odata.foreignKey4'] || e._foreignKey4)
772
+ }
773
+ }
774
+
775
+ /**
776
+ * Resolve `ref` within `def` and return the element
777
+ *
778
+ * @param {string[]} ref
779
+ * @param {CSN.Artifact} def
780
+ * @returns {CSN.Element}
781
+ */
782
+ function getElementForRef(ref, def) {
783
+ return ref.reduce((prev, res) => {
784
+ return prev?.elements?.[res] || prev?._target?.elements[res]
785
+ }, def)
786
+ }
787
+
788
+ /**
789
+ * Recursively expands a structured element into flat columns, representing all leaf paths.
790
+ * This function transforms complex structured elements into simple column representations.
791
+ *
792
+ * For each element, the function checks if it's a structure, an association or a scalar,
793
+ * and proceeds accordingly. If the element is a structure, it recursively fetches flat columns for all sub-elements.
794
+ * If it's an association, it fetches flat columns for it's foreign keys.
795
+ * If it's a scalar, it creates a flat column for it.
796
+ *
797
+ * Columns excluded in a wildcard expansion or replaced by other columns are also handled accordingly.
798
+ *
799
+ * @param {object} column - The structured element which needs to be expanded.
800
+ * @param {string} baseName - The prefixes of the column reference (joined with '_'). Optional.
801
+ * @param {string} columnAlias - The explicit alias which the user has defined for the column.
802
+ * For instance `{ struct.foo as bar}` will be transformed into
803
+ * `{ struct_foo_leaf1 as bar_foo_leaf1, struct_foo_leaf2 as bar_foo_leaf2 }`.
804
+ * @param {string} tableAlias - The table alias to prepend to the column name. Optional.
805
+ * @param {Array} csnPath - An array containing CSN paths. Optional.
806
+ * @param {Array} exclude - An array of columns to be excluded from the flat structure. Optional.
807
+ * @param {Array} replace - An array of columns to be replaced in the flat structure. Optional.
808
+ *
809
+ * @returns {object[]} Returns an array of flat column(s) for the given element.
810
+ */
811
+ function getFlatColumnsFor(column, names, csnPath = [], excludeAndReplace, isWildcard = false) {
812
+ if (!column) return column
813
+ if (column.val || column.func || column.SELECT) return [column]
814
+
815
+ let { baseName, columnAlias, tableAlias } = names
816
+ const { exclude, replace } = excludeAndReplace || {}
817
+ const { $refLinks, flatName, isJoinRelevant } = column
818
+ let leafAssoc
819
+ let element = $refLinks ? $refLinks[$refLinks.length - 1].definition : column
820
+ if (isWildcard && element.type === 'cds.LargeBinary') return []
821
+ if (element.on) return [] // unmanaged doesn't make it into columns
822
+ else if (element.virtual === true) return []
823
+ else if (!isJoinRelevant && flatName) baseName = flatName
824
+ else if (isJoinRelevant) {
825
+ const leaf = column.$refLinks[column.$refLinks.length - 1]
826
+ leafAssoc = [...column.$refLinks].reverse().find(link => link.definition.isAssociation)
827
+ const { foreignKeys } = leafAssoc.definition
828
+ if (foreignKeys && leaf.alias in foreignKeys) {
829
+ element = leafAssoc.definition
830
+ baseName = getFullName(leafAssoc.definition)
831
+ columnAlias = column.ref.slice(0, -1).map(idOnly).join('_')
832
+ } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition)
833
+ } else baseName = baseName ? `${baseName}_${element.name}` : getFullName(element)
834
+
835
+ // now we have the name of the to be expanded column
836
+ // it could be a structure, an association or a scalar
837
+ // check if the column shall be skipped
838
+ // e.g. for wildcard elements which have been overwritten before
839
+ if (exclude && getReplacement(exclude)) return []
840
+ const replacedBy = getReplacement(replace)
841
+ if (replacedBy) {
842
+ // the replacement alias is the baseName of the flat structure
843
+ // e.g. `office.{ *, address.city as floor }`
844
+ // for the `ref: [ office, floor ]` we find the replacement
845
+ // `ref: [ office, address, city]` so the `baseName` of the replacement
846
+ if (replacedBy.as) replacedBy.as = baseName
847
+ // we might have a new base ref
848
+ if (replacedBy.ref && replacedBy.ref.length > 1)
849
+ baseName = getFullName(replacedBy.$refLinks?.[replacedBy.$refLinks.length - 2].definition)
850
+ if (replacedBy.isJoinRelevant)
851
+ // we need to provide the correct table alias
852
+ tableAlias = getQuerySourceName(replacedBy)
853
+
854
+ return getFlatColumnsFor(replacedBy, { baseName, columnAlias: replacedBy.as, tableAlias }, csnPath)
855
+ }
856
+
857
+ csnPath.push(element.name)
858
+
859
+ if (element.keys) {
860
+ const flatColumns = []
861
+ element.keys.forEach(fk => {
862
+ const fkElement = getElementForRef(fk.ref, element._target)
863
+ let fkBaseName
864
+ if (!leafAssoc || leafAssoc.onlyForeignKeyAccess)
865
+ fkBaseName = `${baseName}_${fk.as || fk.ref[fk.ref.length - 1]}`
866
+ // e.g. if foreign key is accessed via infix filter - use join alias to access key in target
867
+ else fkBaseName = fk.ref[fk.ref.length - 1]
868
+ const fkPath = [...csnPath, fk.ref[fk.ref.length - 1]]
869
+ if (fkElement.elements) {
870
+ // structured key
871
+ Object.values(fkElement.elements).forEach(e => {
872
+ let alias
873
+ if (columnAlias) {
874
+ const fkName = fk.as
875
+ ? `${fk.as}_${e.name}` // foreign key might also be re-named: `assoc { id as foo }`
876
+ : `${fk.ref.join('_')}_${e.name}`
877
+ alias = `${columnAlias}_${fkName}`
878
+ }
879
+ flatColumns.push(
880
+ ...getFlatColumnsFor(
881
+ e,
882
+ { baseName: fkBaseName, columnAlias: alias, tableAlias },
883
+ [...fkPath],
884
+ excludeAndReplace,
885
+ isWildcard,
886
+ ),
887
+ )
888
+ })
889
+ } else if (fkElement.isAssociation) {
890
+ // assoc as key
891
+ flatColumns.push(
892
+ ...getFlatColumnsFor(
893
+ fkElement,
894
+ { baseName, columnAlias, tableAlias },
895
+ csnPath,
896
+ excludeAndReplace,
897
+ isWildcard,
898
+ ),
899
+ )
900
+ } else {
901
+ // leaf reached
902
+ let flatColumn
903
+ if (columnAlias) flatColumn = { ref: [fkBaseName], as: `${columnAlias}_${fk.ref.join('_')}` }
904
+ else flatColumn = { ref: [fkBaseName] }
905
+ if (tableAlias) flatColumn.ref.unshift(tableAlias)
906
+ Object.defineProperty(flatColumn, 'element', { value: fkElement })
907
+ Object.defineProperty(flatColumn, '_csnPath', { value: csnPath })
908
+ flatColumns.push(flatColumn)
909
+ }
910
+ })
911
+ return flatColumns
912
+ } else if (element.elements) {
913
+ const flatRefs = []
914
+ Object.values(element.elements).forEach(e => {
915
+ const alias = columnAlias ? `${columnAlias}_${e.name}` : null
916
+ flatRefs.push(
917
+ ...getFlatColumnsFor(
918
+ e,
919
+ { baseName, columnAlias: alias, tableAlias },
920
+ [...csnPath],
921
+ excludeAndReplace,
922
+ isWildcard,
923
+ ),
924
+ )
925
+ })
926
+ return flatRefs
927
+ }
928
+ const flatRef = tableAlias ? { ref: [tableAlias, baseName] } : { ref: [baseName] }
929
+ if (column.cast) {
930
+ flatRef.cast = column.cast
931
+ if (!columnAlias)
932
+ // provide an explicit alias
933
+ columnAlias = baseName
934
+ }
935
+ if (column.sort) flatRef.sort = column.sort
936
+ if (columnAlias) flatRef.as = columnAlias
937
+ Object.defineProperty(flatRef, 'element', { value: element })
938
+ Object.defineProperty(flatRef, '_csnPath', { value: csnPath })
939
+ return [flatRef]
940
+
941
+ function getReplacement(from) {
942
+ return from?.find(replacement => {
943
+ const nameOfExcludedColumn = replacement.as || replacement.ref?.[replacement.ref.length - 1] || replacement
944
+ return nameOfExcludedColumn === element.name
945
+ })
946
+ }
947
+ }
948
+
949
+ /**
950
+ * Transforms a CQN token stream (e.g. `where`, `xpr` or `having`) into a SQL like expression.
951
+ *
952
+ * Expand `exists <assoc>` into `WHERE EXISTS` subqueries, apply flattening to `ref`s.
953
+ * Recursively apply `cqn4sql` to query expressions found in the token stream.
954
+ *
955
+ * @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
956
+ * object representing a CQN construct such as a column, an operator,
957
+ * or a subquery.
958
+ * @param {object} [$baseLink=null] - The context in which the `ref`s in the token stream are resolvable.
959
+ * It serves as the reference point for resolving associations in
960
+ * statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
961
+ * Here, the $baseLink for `anotherAssoc` would be `assoc`.
962
+ * @returns {object[]} - The transformed token stream.
963
+ */
964
+ function getTransformedTokenStream(tokenStream, $baseLink = null) {
965
+ const transformedWhere = []
966
+ for (let i = 0; i < tokenStream.length; i++) {
967
+ const token = tokenStream[i]
968
+ if (token === 'exists') {
969
+ transformedWhere.push(token)
970
+ const whereExistsSubSelects = []
971
+ const { ref, $refLinks } = tokenStream[i + 1]
972
+ if (!ref) continue
973
+ if (ref[0] in { $self: true, $projection: true })
974
+ cds.error(`Unexpected "${ref[0]}" following "exists", remove it or add a table alias instead`)
975
+ const firstStepIsTableAlias = ref.length > 1 && ref[0] in inferred.sources
976
+ for (let j = 0; j < ref.length; j += 1) {
977
+ let current, next
978
+ const step = ref[j]
979
+ const id = step.id || step
980
+ if (j === 0) {
981
+ if (firstStepIsTableAlias) continue
982
+ current = $baseLink || {
983
+ definition: $refLinks[0].target,
984
+ target: $refLinks[0].target,
985
+ // if the first step of a where is not a table alias,
986
+ // the table alias is the query source where the current ref step
987
+ // originates from. As no table alias is specified, there must be
988
+ // only one table alias for the given ref step
989
+ alias: inferred.$combinedElements[id][0].index,
990
+ }
991
+ next = $refLinks[0]
992
+ } else {
993
+ current = $refLinks[j - 1]
994
+ next = $refLinks[j]
995
+ }
996
+
997
+ if (isStructured(next.definition)) {
998
+ // find next association / entity in the ref because this is actually our real nextStep
999
+ const nextAssocIndex =
1000
+ 2 + $refLinks.slice(j + 2).findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1001
+ next = $refLinks[nextAssocIndex]
1002
+ j = nextAssocIndex
1003
+ }
1004
+
1005
+ const as = getNextAvailableTableAlias(getLastStringSegment(next.alias))
1006
+ next.alias = as
1007
+ whereExistsSubSelects.push(getWhereExistsSubquery(current, next, step.where, true))
1008
+ }
1009
+
1010
+ const whereExists = { SELECT: whereExistsSubqueries(whereExistsSubSelects) }
1011
+ transformedWhere[i + 1] = whereExists
1012
+ // skip newly created subquery from being iterated
1013
+ i += 1
1014
+ } else if (token.list) {
1015
+ if (token.list.length === 0) {
1016
+ // replace `[not] in <empty list>` to harmonize behavior across dbs
1017
+ const precedingTwoTokens = tokenStream.slice(i - 2, i)
1018
+ const firstPrecedingToken =
1019
+ typeof precedingTwoTokens[0] === 'string' ? precedingTwoTokens[0].toLowerCase() : ''
1020
+ const secondPrecedingToken =
1021
+ typeof precedingTwoTokens[1] === 'string' ? precedingTwoTokens[1].toLowerCase() : ''
1022
+
1023
+ if (firstPrecedingToken === 'not') {
1024
+ transformedWhere.splice(i - 2, 2, 'is', 'not', 'null')
1025
+ } else if (secondPrecedingToken === 'in') {
1026
+ transformedWhere.splice(i - 1, 1, '=', { val: null })
1027
+ } else {
1028
+ transformedWhere.push({ list: [] })
1029
+ }
1030
+ } else {
1031
+ transformedWhere.push({ list: getTransformedTokenStream(token.list) })
1032
+ }
1033
+ } else if (tokenStream.length === 1 && token.val && $baseLink) {
1034
+ // infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
1035
+ const def = $baseLink.definition._target || $baseLink.definition
1036
+ const keys = def.keys // use key aspect on entity
1037
+ const keyValComparisons = []
1038
+ const flatKeys = []
1039
+ Object.values(keys)
1040
+ // up__ID already part of inner where exists, no need to add it explicitly here
1041
+ .filter(k => k !== backlinkFor($baseLink.definition)?.[0])
1042
+ .forEach(v => {
1043
+ flatKeys.push(...getFlatColumnsFor(v, { tableAlias: $baseLink.alias }))
1044
+ })
1045
+ if (flatKeys.length > 1)
1046
+ throw new Error('Filters can only be applied to managed associations which result in a single foreign key')
1047
+ flatKeys.forEach(c => keyValComparisons.push([...[c, '=', token]]))
1048
+ keyValComparisons.forEach((kv, j) =>
1049
+ transformedWhere.push(...kv) && keyValComparisons[j + 1] ? transformedWhere.push('and') : null,
1050
+ )
1051
+ } else if (token.ref && token.param) {
1052
+ transformedWhere.push({ ...token })
1053
+ } else if (pseudos.elements[token.ref?.[0]]) {
1054
+ transformedWhere.push({ ...token })
1055
+ } else {
1056
+ // expand `struct = null | struct2`
1057
+ const { definition } = token.$refLinks?.[token.$refLinks.length - 1] || {}
1058
+ const next = tokenStream[i + 1]
1059
+ if (allOps.some(([firstOp]) => firstOp === next) && (definition?.elements || definition?.keys)) {
1060
+ const ops = [next]
1061
+ let indexRhs = i + 2
1062
+ let rhs = tokenStream[i + 2] // either another operator (i.e. `not like` et. al.) or the operand, i.e. the val | null
1063
+ if (allOps.some(([, secondOp]) => secondOp === rhs)) {
1064
+ ops.push(rhs)
1065
+ rhs = tokenStream[i + 3]
1066
+ indexRhs += 1
1067
+ }
1068
+ if (
1069
+ isAssocOrStruct(rhs.$refLinks?.[rhs.$refLinks.length - 1].definition) ||
1070
+ rhs.val !== undefined ||
1071
+ /* unary operator `is null` parsed as string */
1072
+ rhs === 'null'
1073
+ ) {
1074
+ if (notSupportedOps.some(([firstOp]) => firstOp === next))
1075
+ cds.error(`The operator "${next}" is not supported for structure comparison`)
1076
+ const newTokens = expandComparison(token, ops, rhs)
1077
+ const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1])
1078
+ transformedWhere.push(...(needXpr ? [asXpr(newTokens)] : newTokens))
1079
+ i = indexRhs // jump to next relevant index
1080
+ }
1081
+ } else {
1082
+ // reject associations in expression, except if we are in an infix filter -> $baseLink is set
1083
+ assertNoStructInXpr(token, $baseLink)
1084
+
1085
+ let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
1086
+ if (token.ref) {
1087
+ const tableAlias = getQuerySourceName(token, $baseLink)
1088
+ if (!$baseLink && token.isJoinRelevant) {
1089
+ result.ref = [tableAlias, getFullName(token.$refLinks[token.$refLinks.length - 1].definition)]
1090
+ } else if (tableAlias) {
1091
+ result.ref = [tableAlias, token.flatName]
1092
+ } else {
1093
+ // if there is no table alias, we might select from an anonymous subquery
1094
+ result.ref = [token.flatName]
1095
+ }
1096
+ } else if (token.SELECT) {
1097
+ result = transformSubquery(token)
1098
+ } else if (token.xpr) {
1099
+ result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1100
+ } else if (token.func && token.args) {
1101
+ result.args = token.args.map(t => {
1102
+ if (!t.val)
1103
+ // this must not be touched
1104
+ return getTransformedTokenStream([t], $baseLink)[0]
1105
+ return t
1106
+ })
1107
+ }
1108
+
1109
+ transformedWhere.push(result)
1110
+ }
1111
+ }
1112
+ }
1113
+ return transformedWhere
1114
+ }
1115
+
1116
+ /**
1117
+ * Expand the given definition and compare all leafs to `val`.
1118
+ *
1119
+ * @param {object} token with $refLinks
1120
+ * @param {string} operator one of allOps
1121
+ * @param {object} value either `null` or a column (with `ref` and `$refLinks`)
1122
+ * @returns {array}
1123
+ */
1124
+ function expandComparison(token, operator, value) {
1125
+ const { definition } = token.$refLinks[token.$refLinks.length - 1]
1126
+ let flatRhs
1127
+ const result = []
1128
+ if (value.$refLinks) {
1129
+ // structural comparison
1130
+ flatRhs = flattenWithBaseName(value)
1131
+ }
1132
+
1133
+ if (flatRhs) {
1134
+ const flatLhs = flattenWithBaseName(token)
1135
+
1136
+ //Revisit: Early exit here? We kndow we cant compare the structs, however we do not know exactly why
1137
+ // --> calculate error message or exit early? See test "proper error if structures cannot be compared / too many elements on lhs"
1138
+ if (flatRhs.length !== flatLhs.length)
1139
+ // make sure we can compare both structures
1140
+ cds.error(
1141
+ `Can't compare "${definition.name}" with "${
1142
+ value.$refLinks[value.$refLinks.length - 1].definition.name
1143
+ }": the operands must have the same structure`,
1144
+ )
1145
+ const pathNotFoundErr = []
1146
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1147
+ const rhsPath = value.ref.join('.') // original path of the comparison, used in error message
1148
+ while (flatLhs.length > 0) {
1149
+ // retrieve and remove one flat element from LHS and search for it in RHS (remove it there too)
1150
+ const { ref, _csnPath: lhs_csnPath } = flatLhs.shift()
1151
+ const indexOfElementOnRhs = flatRhs.findIndex(rhs => {
1152
+ const { _csnPath: rhs_csnPath } = rhs
1153
+ // all following steps must also be part of lhs
1154
+ return lhs_csnPath.slice(1).every((val, i) => val === rhs_csnPath[i + 1]) // first step is name of struct -> ignore
1155
+ })
1156
+ if (indexOfElementOnRhs === -1) {
1157
+ pathNotFoundErr.push(`Path "${lhs_csnPath.slice(1).join('.')}" not found in "${rhsPath}"`)
1158
+ continue
1159
+ }
1160
+ const rhs = flatRhs.splice(indexOfElementOnRhs, 1)[0] // remove the element also from RHS
1161
+ result.push({ ref }, ...operator, rhs)
1162
+ if (flatLhs.length > 0) result.push(boolOp)
1163
+ }
1164
+ if (flatRhs.length) {
1165
+ // if we still have elements in flatRhs -> those were not found in lhs
1166
+ const lhsPath = token.ref.join('.') // original path of the comparison, used in error message
1167
+ flatRhs.forEach(t => pathNotFoundErr.push(`Path "${t._csnPath.slice(1).join('.')}" not found in "${lhsPath}"`))
1168
+ cds.error(`Can't compare "${lhsPath}" with "${rhsPath}": ${pathNotFoundErr.join(', ')}`)
1169
+ }
1170
+ } else {
1171
+ // compare with value
1172
+ const flatLhs = flattenWithBaseName(token)
1173
+ if (flatLhs.length > 1 && value.val !== null && value !== 'null')
1174
+ cds.error(`Can't compare structure "${token.ref.join('.')}" with value "${value.val}"`)
1175
+ const boolOp = notEqOps.some(([f, s]) => operator[0] === f && operator[1] === s) ? 'or' : 'and'
1176
+ flatLhs.forEach((column, i) => {
1177
+ result.push(column, ...operator, value)
1178
+ if (flatLhs[i + 1]) result.push(boolOp)
1179
+ })
1180
+ }
1181
+ return result
1182
+
1183
+ function flattenWithBaseName(def) {
1184
+ if (!def.$refLinks) return def
1185
+ const leaf = def.$refLinks[def.$refLinks.length - 1]
1186
+ const first = def.$refLinks[0]
1187
+ const tableAlias = getQuerySourceName(def, def.ref.length > 1 && first.definition.isAssociation ? first : null)
1188
+ if (leaf.definition.parent.kind !== 'entity')
1189
+ // we need the base name
1190
+ return getFlatColumnsFor(leaf.definition, {
1191
+ baseName: def.ref.slice(0, def.ref.length - 1).join('_'),
1192
+ tableAlias,
1193
+ })
1194
+ return getFlatColumnsFor(leaf.definition, { tableAlias })
1195
+ }
1196
+ }
1197
+
1198
+ function assertNoStructInXpr(token, inInfixFilter = false) {
1199
+ if (!inInfixFilter && token.$refLinks?.[token.$refLinks.length - 1].definition.target)
1200
+ // revisit: let this through if not requested otherwise
1201
+ rejectAssocInExpression()
1202
+ if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1203
+ // revisit: let this through if not requested otherwise
1204
+ rejectStructInExpression()
1205
+
1206
+ function rejectAssocInExpression() {
1207
+ throw new Error(/An association can't be used as a value in an expression/)
1208
+ }
1209
+ function rejectStructInExpression() {
1210
+ throw new Error(/A structured element can't be used as a value in an expression/)
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Recursively walks over all `from` args. Association steps in the `ref`s
1216
+ * are transformed into `WHERE exists` subqueries. The given `from.ref`s
1217
+ * are always of length == 1 after processing.
1218
+ *
1219
+ * The steps in a `ref` are processed in reversed order. This is the main difference
1220
+ * to the `WHERE exists` expansion in the @function getTransformedTokenStream().
1221
+ *
1222
+ * @param {object} from
1223
+ * @param {object[]?} existingWhere custom where condition which is appended to the filter
1224
+ * conditions of the resulting `WHERE exists` subquery
1225
+ */
1226
+ function getTransformedFrom(from, existingWhere = []) {
1227
+ const transformedWhere = []
1228
+ let transformedFrom = copy(from) // REVISIT: too expensive!
1229
+ if (from.$refLinks)
1230
+ Object.defineProperty(transformedFrom, '$refLinks', { value: [...from.$refLinks], writable: true })
1231
+ if (from.args) {
1232
+ transformedFrom.args = []
1233
+ from.args.forEach(arg => {
1234
+ if (arg.SELECT) {
1235
+ const { whereExists: e, transformedFrom: f } = getTransformedFrom(arg.SELECT.from, arg.SELECT.where)
1236
+ const transformedArg = { SELECT: { from: f, where: e } }
1237
+ transformedFrom.args.push(transformedArg)
1238
+ } else {
1239
+ const { transformedFrom: f } = getTransformedFrom(arg)
1240
+ transformedFrom.args.push(f)
1241
+ }
1242
+ })
1243
+ return { transformedFrom }
1244
+ } else if (from.SELECT) {
1245
+ transformedFrom = transformSubquery(from)
1246
+ if (from.as)
1247
+ // preserve explicit TA
1248
+ transformedFrom.as = from.as
1249
+ return { transformedFrom }
1250
+ } else {
1251
+ return _transformFrom()
1252
+ }
1253
+ function _transformFrom() {
1254
+ if (typeof from === 'string') {
1255
+ // normalize to `ref`, i.e. for `UPDATE.entity('bookshop.Books')`
1256
+ return { transformedFrom: { ref: [from], as: getLastStringSegment(from) } }
1257
+ }
1258
+ transformedFrom.as =
1259
+ from.uniqueSubqueryAlias ||
1260
+ from.as ||
1261
+ getLastStringSegment(transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1].definition.name)
1262
+ const whereExistsSubSelects = []
1263
+ const filterConditions = []
1264
+ const refReverse = [...from.ref].reverse()
1265
+ const $refLinksReverse = [...transformedFrom.$refLinks].reverse()
1266
+ for (let i = 0; i < refReverse.length; i += 1) {
1267
+ const stepLink = $refLinksReverse[i]
1268
+
1269
+ let nextStepLink = $refLinksReverse[i + 1]
1270
+ const nextStep = refReverse[i + 1] // only because we want the filter condition
1271
+
1272
+ if (stepLink.definition.target && nextStepLink) {
1273
+ const { where } = nextStep
1274
+ if (isStructured(nextStepLink.definition)) {
1275
+ // find next association / entity in the ref because this is actually our real nextStep
1276
+ const nextStepIndex =
1277
+ 2 +
1278
+ $refLinksReverse
1279
+ .slice(i + 2)
1280
+ .findIndex(rl => rl.definition.isAssociation || rl.definition.kind === 'entity')
1281
+ nextStepLink = $refLinksReverse[nextStepIndex]
1282
+ }
1283
+ let as = getLastStringSegment(nextStepLink.alias)
1284
+ /**
1285
+ * for an `expand` subquery, we do not need to add
1286
+ * the table alias of the `expand` host to the join tree
1287
+ * --> This is an artificial query, which will later be correlated
1288
+ * with the main query alias. see @function expandColumn()
1289
+ */
1290
+ if (!(originalQuery.SELECT?.expand === true)) {
1291
+ as = getNextAvailableTableAlias(as)
1292
+ }
1293
+ nextStepLink.alias = as
1294
+ whereExistsSubSelects.push(getWhereExistsSubquery(stepLink, nextStepLink, where))
1295
+ }
1296
+ }
1297
+
1298
+ // only append infix filter to outer where if it is the leaf of the from ref
1299
+ if (refReverse[0].where)
1300
+ filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
1301
+
1302
+ if (existingWhere.length > 0) filterConditions.push(existingWhere)
1303
+ if (whereExistsSubSelects.length > 0) {
1304
+ const { definition: leafAssoc, alias } = transformedFrom.$refLinks[transformedFrom.$refLinks.length - 1]
1305
+ Object.assign(transformedFrom, {
1306
+ ref: [leafAssoc.target],
1307
+ as: alias,
1308
+ })
1309
+ transformedWhere.push(...['exists', { SELECT: whereExistsSubqueries(whereExistsSubSelects) }])
1310
+ filterConditions.forEach(f => {
1311
+ transformedWhere.push('and')
1312
+ if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
1313
+ else if (f.length > 3) transformedWhere.push(asXpr(f))
1314
+ else transformedWhere.push(...f)
1315
+ })
1316
+ } else {
1317
+ if (filterConditions.length > 0) {
1318
+ filterConditions.reverse().forEach((f, index) => {
1319
+ if (filterConditions.length > 1) transformedWhere.push(asXpr(f))
1320
+ else transformedWhere.push(...f)
1321
+ if (filterConditions[index + 1] !== undefined) transformedWhere.push('and')
1322
+ })
1323
+ }
1324
+ }
1325
+
1326
+ // adjust ref & $refLinks after associations have turned into where exists subqueries
1327
+ transformedFrom.$refLinks.splice(0, transformedFrom.$refLinks.length - 1)
1328
+ transformedFrom.ref = [localized(transformedFrom.$refLinks[0].target)]
1329
+
1330
+ return { transformedWhere, transformedFrom }
1331
+ }
1332
+ }
1333
+
1334
+ function whereExistsSubqueries(whereExistsSubSelects) {
1335
+ if (whereExistsSubSelects.length === 1) return whereExistsSubSelects[0]
1336
+ whereExistsSubSelects.reduce((prev, cur) => {
1337
+ if (prev.where) {
1338
+ prev.where.push('and', 'exists', { SELECT: cur })
1339
+ return cur
1340
+ } else {
1341
+ prev = cur
1342
+ }
1343
+ return prev
1344
+ }, {})
1345
+ return whereExistsSubSelects[0]
1346
+ }
1347
+
1348
+ function getNextAvailableTableAlias(id) {
1349
+ return inferred.joinTree.addNextAvailableTableAlias(id, inferred.outerQueries)
1350
+ }
1351
+
1352
+ function asXpr(thing) {
1353
+ return { xpr: thing }
1354
+ }
1355
+
1356
+ /**
1357
+ * @param {CSN.Element} elt
1358
+ * @returns {boolean}
1359
+ */
1360
+ function isStructured(elt) {
1361
+ return Boolean(elt?.elements && elt.kind === 'element')
1362
+ }
1363
+
1364
+ /**
1365
+ * @param {CSN.Element} elt
1366
+ * @returns {boolean}
1367
+ */
1368
+ function isAssocOrStruct(elt) {
1369
+ return elt?.keys || (elt?.elements && elt.kind === 'element')
1370
+ }
1371
+
1372
+ /**
1373
+ * Calculates which elements are the backlinks of a $self comparison in a
1374
+ * given on-condition. That are the managed associations in the target of the
1375
+ * given association.
1376
+ *
1377
+ * @param {CSN.Association} assoc with on-condition
1378
+ * @returns {[CSN.Association] | null} all assocs which are compared to `$self`
1379
+ */
1380
+ function backlinkFor(assoc) {
1381
+ if (!assoc.on) return null
1382
+ const target = model.definitions[assoc.target]
1383
+ // technically we could have multiple backlinks
1384
+ const backlinks = []
1385
+ for (let i = 0; i < assoc.on.length; i += 3) {
1386
+ const lhs = assoc.on[i]
1387
+ const rhs = assoc.on[i + 2]
1388
+ if (lhs?.ref?.length === 1 && lhs.ref[0] === '$self') backlinks.push(rhs)
1389
+ else if (rhs?.ref?.length === 1 && rhs.ref[0] === '$self') backlinks.push(lhs)
1390
+ }
1391
+ return backlinks.map(each =>
1392
+ getElementForRef(
1393
+ each.ref.slice(1),
1394
+ each.ref[0] in { $self: true, $projection: true } ? getParentEntity(assoc) : target,
1395
+ ),
1396
+ )
1397
+
1398
+ function getParentEntity(element) {
1399
+ if (element.kind === 'entity') return element
1400
+ else return getParentEntity(element.parent)
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * Calculates the on-condition for the given (un-)managed association.
1406
+ *
1407
+ * @param {$refLink} assocRefLink with on-condition
1408
+ * @param {$refLink} targetSideRefLink the reflink which has the target alias of the association
1409
+ * @returns {[CSN.Association] | null} all assocs which are compared to `$self`
1410
+ */
1411
+ function onCondFor(assocRefLink, targetSideRefLink, inWhereOrJoin) {
1412
+ const { on, keys } = assocRefLink.definition
1413
+ const target = model.definitions[assocRefLink.definition.target]
1414
+ let res
1415
+ // technically we could have multiple backlinks
1416
+ if (keys) {
1417
+ const fkPkPairs = getParentKeyForeignKeyPairs(assocRefLink.definition, targetSideRefLink, true)
1418
+ const transformedOn = []
1419
+ fkPkPairs.forEach((pair, i) => {
1420
+ const { sourceSide, targetSide } = pair
1421
+ sourceSide.ref.unshift(assocRefLink.alias)
1422
+ transformedOn.push(sourceSide, '=', targetSide)
1423
+ if (fkPkPairs[i + 1]) transformedOn.push('and')
1424
+ })
1425
+ res = transformedOn
1426
+ } else if (on) {
1427
+ res = calculateOnCondition(on)
1428
+ }
1429
+ return res
1430
+
1431
+ /**
1432
+ * For an unmanaged association, calculate the proper on-condition.
1433
+ * For a `$self = assoc.<backlink>` comparison, the three tokens are replaced
1434
+ * by the on-condition of the <backlink>.
1435
+ *
1436
+ *
1437
+ * @param {on} tokenStream the on condition of the unmanaged association
1438
+ * @returns the final on-condition for the unmanaged association
1439
+ */
1440
+ function calculateOnCondition(tokenStream) {
1441
+ const result = copy(tokenStream) // REVISIT: too expensive!
1442
+ for (let i = 0; i < result.length; i += 1) {
1443
+ const lhs = result[i]
1444
+ if (lhs.xpr) {
1445
+ const xpr = calculateOnCondition(lhs.xpr)
1446
+ result[i] = asXpr(xpr)
1447
+ continue
1448
+ }
1449
+ const rhs = result[i + 2]
1450
+ let backlink
1451
+ if (rhs?.ref && lhs?.ref) {
1452
+ if (lhs?.ref?.length === 1 && lhs.ref[0] === '$self')
1453
+ backlink = getElementForRef(
1454
+ rhs.ref.slice(1),
1455
+ rhs.ref[0] in { $self: true, $projection: true } ? getParentEntity(assocRefLink.definition) : target,
1456
+ )
1457
+ else if (rhs?.ref?.length === 1 && rhs.ref[0] === '$self')
1458
+ backlink = getElementForRef(
1459
+ lhs.ref.slice(1),
1460
+ lhs.ref[0] in { $self: true, $projection: true } ? getParentEntity(assocRefLink.definition) : target,
1461
+ )
1462
+ else {
1463
+ // if we have refs on each side of the comparison, we might need to perform tuple expansion
1464
+ // or flatten the structures
1465
+ // REVISIT: this whole section needs a refactoring, it is too complex and some edge cases may still be not considered...
1466
+ const refLinkFaker = thing => {
1467
+ const { ref } = thing
1468
+ const assocHost = getParentEntity(assocRefLink.definition)
1469
+ Object.defineProperty(thing, '$refLinks', {
1470
+ value: [],
1471
+ writable: true,
1472
+ })
1473
+ ref.reduce((prev, res, i) => {
1474
+ if (res === '$self')
1475
+ // next is resolvable in entity
1476
+ return prev
1477
+ const definition = prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
1478
+ const target = getParentEntity(definition)
1479
+ thing.$refLinks[i] = { definition, target, alias: definition.name }
1480
+ return prev?.elements?.[res] || prev?._target?.elements[res] || pseudos.elements[res]
1481
+ }, assocHost)
1482
+ }
1483
+
1484
+ // comparison in on condition needs to be expanded...
1485
+ // re-use existing algorithm for that
1486
+ // we need to fake some $refLinks for that to work though...
1487
+ lhs?.ref && refLinkFaker(lhs)
1488
+ rhs?.ref && refLinkFaker(rhs)
1489
+ const lhsLeafArt = lhs.ref && lhs.$refLinks[lhs.$refLinks.length - 1].definition
1490
+ const rhsLeafArt = rhs.ref && rhs.$refLinks[rhs.$refLinks.length - 1].definition
1491
+ if (lhsLeafArt?.target || rhsLeafArt?.target) {
1492
+ if (rhs.$refLinks[0].definition !== assocRefLink.definition) {
1493
+ rhs.ref.unshift(targetSideRefLink.alias)
1494
+ rhs.$refLinks.unshift(targetSideRefLink)
1495
+ }
1496
+ if (lhs.$refLinks[0].definition !== assocRefLink.definition) {
1497
+ lhs.ref.unshift(targetSideRefLink.alias)
1498
+ lhs.$refLinks.unshift(targetSideRefLink)
1499
+ }
1500
+ const expandedComparison = getTransformedTokenStream([lhs, result[i + 1], rhs])
1501
+ const res = tokenStream[i + 3] ? [asXpr(expandedComparison)] : expandedComparison
1502
+ result.splice(i, 3, ...res)
1503
+ i += res.length
1504
+ continue
1505
+ }
1506
+ // naive assumption: if first step is the association itself, all following ref steps must be resolvable
1507
+ // within target `assoc.assoc.fk` -> `assoc.assoc_fk`
1508
+ else if (
1509
+ lhs.$refLinks[0].definition ===
1510
+ getParentEntity(assocRefLink.definition).elements[assocRefLink.definition.name]
1511
+ )
1512
+ result[i].ref = [result[i].ref[0], lhs.ref.slice(1).join('_')]
1513
+ // naive assumption: if the path starts with an association which is not the association from
1514
+ // which the on-condition originates, it must be a foreign key and hence resolvable in the source
1515
+ else if (lhs.$refLinks[0].definition.target) result[i].ref = [result[i].ref.join('_')]
1516
+ }
1517
+ }
1518
+ if (backlink) {
1519
+ const wrapInXpr = result[i + 3] || result[i - 1] // if we have a complex on-condition, wrap each part in xpr
1520
+ let backlinkOnCondition = []
1521
+ if (backlink.on) {
1522
+ // unmanaged backlink -> prepend correct aliases
1523
+ backlinkOnCondition = backlink.on.map(t => {
1524
+ if (t.ref?.length > 1 && t.ref[0] === (backlink.name || targetSideRefLink.definition.name)) {
1525
+ return { ref: [targetSideRefLink.alias, ...t.ref.slice(1)] }
1526
+ } else if (t.ref) {
1527
+ if (t.ref.length > 1 && !(t.ref[0] in pseudos.elements))
1528
+ return { ref: [assocRefLink.alias, ...t.ref.slice(1)] }
1529
+ else return { ref: [assocRefLink.alias, ...t.ref] }
1530
+ } else {
1531
+ return t
1532
+ }
1533
+ })
1534
+ } else if (backlink.keys) {
1535
+ // managed backlink -> calculate fk-pk pairs
1536
+ const fkPkPairs = getParentKeyForeignKeyPairs(backlink, targetSideRefLink)
1537
+ fkPkPairs.forEach((pair, j) => {
1538
+ const { sourceSide, targetSide } = pair
1539
+ sourceSide.ref.unshift(assocRefLink.alias)
1540
+ backlinkOnCondition.push(sourceSide, '=', targetSide)
1541
+ if (!inWhereOrJoin) backlinkOnCondition.reverse()
1542
+ if (fkPkPairs[j + 1]) backlinkOnCondition.push('and')
1543
+ })
1544
+ }
1545
+ result.splice(i, 3, ...(wrapInXpr ? [asXpr(backlinkOnCondition)] : backlinkOnCondition))
1546
+ i += wrapInXpr ? 1 : backlinkOnCondition.length // skip inserted tokens
1547
+ } else if (lhs.ref) {
1548
+ if (lhs.ref[0] === '$self') result[i].ref.splice(0, 1, targetSideRefLink.alias)
1549
+ else if (lhs.ref.length > 1) {
1550
+ if (
1551
+ !(lhs.ref[0] in pseudos.elements) &&
1552
+ lhs.ref[0] !== assocRefLink.alias &&
1553
+ lhs.ref[0] !== targetSideRefLink.alias
1554
+ ) {
1555
+ // we need to find correct table alias for the structured access
1556
+ const { definition } = lhs.$refLinks[0]
1557
+ if (definition === assocRefLink.definition) {
1558
+ // first step is the association itself -> use it's name as it becomes the table alias
1559
+ result[i].ref.splice(0, 1, assocRefLink.alias)
1560
+ } else if (
1561
+ Object.values(
1562
+ targetSideRefLink.definition.elements || targetSideRefLink.definition._target.elements,
1563
+ ).some(e => e === definition)
1564
+ ) {
1565
+ // first step is association which refers to its foreign key by dot notation
1566
+ result[i].ref = [targetSideRefLink.alias, lhs.ref.join('_')]
1567
+ }
1568
+ }
1569
+ } else if (lhs.ref.length === 1) result[i].ref.unshift(targetSideRefLink.alias)
1570
+ }
1571
+ }
1572
+ return result
1573
+ }
1574
+ /**
1575
+ * Recursively calculates the containing entity for a given element.
1576
+ *
1577
+ * @param {CSN.element} element
1578
+ * @returns {CSN.definition} the entity containing the given element
1579
+ */
1580
+ function getParentEntity(element) {
1581
+ if (!element.kind)
1582
+ // pseudo element
1583
+ return element
1584
+ if (element.kind === 'entity') return element
1585
+ else return model.definitions[localized(getParentEntity(element.parent))]
1586
+ }
1587
+ }
1588
+
1589
+ /**
1590
+ * For a given managed association, calculate the foreign key - parent key tuples.
1591
+ *
1592
+ * @param {CDS.Association} assoc the association for which the on condition shall be calculated
1593
+ * @param {object} targetSideRefLink the reflink which has the target alias of the (backlink) association
1594
+ * @param {boolean} flipSourceAndTarget target and source side are flipped in the where exists subquery
1595
+ * @returns {[{sourceSide: {ref: []}, targetSide: {ref:[]}}]} array of source side - target side reference tuples, i.e. the foreign keys and parent keys.
1596
+ */
1597
+ function getParentKeyForeignKeyPairs(assoc, targetSideRefLink, flipSourceAndTarget = false) {
1598
+ const res = []
1599
+ const backlink = backlinkFor(assoc)?.[0]
1600
+ const { keys, _target } = backlink || assoc
1601
+ if (keys) {
1602
+ keys.forEach(fk => {
1603
+ const { ref, as } = fk
1604
+ const elem = getElementForRef(ref, _target) // find the element (the target element of the foreign key) in the target of the (backlink) association
1605
+ 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
1606
+ const flatAssociationName = getFullName(backlink || assoc) // get the name of the (backlink) association
1607
+ 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.
1608
+
1609
+ for (let i = 0; i < flatForeignKeys.length; i++) {
1610
+ if (flipSourceAndTarget) {
1611
+ // `where exists <assoc>` expansion
1612
+ // a backlink will cause the foreign key to be on the source side
1613
+ const refInTarget = backlink ? flatParentKeys[i] : flatForeignKeys[i]
1614
+ const refInSource = backlink ? flatForeignKeys[i] : flatParentKeys[i]
1615
+ res.push({
1616
+ sourceSide: refInSource,
1617
+ targetSide: {
1618
+ ref: [
1619
+ targetSideRefLink.alias,
1620
+ refInTarget.as ? `${flatAssociationName}_${refInTarget.as}` : refInTarget.ref[0],
1621
+ ],
1622
+ },
1623
+ })
1624
+ } else {
1625
+ // `select from <assoc>` to `where exists` expansion
1626
+ // a backlink will cause the foreign key to be on the target side
1627
+ const refInTarget = backlink ? flatForeignKeys[i] : flatParentKeys[i]
1628
+ const refInSource = backlink ? flatParentKeys[i] : flatForeignKeys[i]
1629
+ res.push({
1630
+ sourceSide: {
1631
+ ref: [refInSource.as ? `${flatAssociationName}_${refInSource.as}` : refInSource.ref[0]],
1632
+ },
1633
+ targetSide: { ref: [targetSideRefLink.alias, ...refInTarget.ref] },
1634
+ })
1635
+ }
1636
+ }
1637
+ })
1638
+ }
1639
+ return res
1640
+ }
1641
+
1642
+ /**
1643
+ * Constructs a where exists subquery for a given association - i.e. calculates foreign key / parent key
1644
+ * relations for the association.
1645
+ *
1646
+ * @param {$refLink} current step of the association path
1647
+ * @param {$refLink} next step of the association path
1648
+ * @param {object[]} customWhere infix filter which must be part of the where exists subquery on condition
1649
+ * @param {boolean} inWhere whether or not the path is part of the queries where clause
1650
+ * -> if it is, target and source side are flipped in the where exists subquery
1651
+ * @returns {CQN.SELECT}
1652
+ */
1653
+ function getWhereExistsSubquery(current, next, customWhere = null, inWhere = false) {
1654
+ const { definition } = current
1655
+ const { definition: nextDefinition } = next
1656
+ const on = []
1657
+ const fkSource = inWhere ? nextDefinition : definition
1658
+ // TODO: use onCondFor()
1659
+ if (fkSource.keys) {
1660
+ const pkFkPairs = getParentKeyForeignKeyPairs(fkSource, current, inWhere)
1661
+ pkFkPairs.forEach((pkFkPair, i) => {
1662
+ const { targetSide, sourceSide } = pkFkPair
1663
+ sourceSide.ref.unshift(next.alias)
1664
+ if (i > 0) on.push('and')
1665
+ on.push(sourceSide, '=', targetSide)
1666
+ })
1667
+ } else {
1668
+ const unmanagedOn = onCondFor(inWhere ? next : current, inWhere ? current : next, inWhere)
1669
+ on.push(...(customWhere && hasLogicalOr(unmanagedOn) ? [asXpr(unmanagedOn)] : unmanagedOn))
1670
+ }
1671
+ // infix filter conditions are wrapped in `xpr` when added to the on-condition
1672
+ if (customWhere) {
1673
+ const filter = getTransformedTokenStream(customWhere, next)
1674
+ on.push(...['and', ...(hasLogicalOr(filter) ? [asXpr(filter)] : filter)])
1675
+ }
1676
+
1677
+ const SELECT = {
1678
+ from: {
1679
+ ref: [localized(assocTarget(nextDefinition) || nextDefinition)],
1680
+ as: next.alias,
1681
+ },
1682
+ columns: [
1683
+ {
1684
+ val: 1,
1685
+ // as: 'dummy'
1686
+ },
1687
+ ],
1688
+ where: on,
1689
+ }
1690
+ return SELECT
1691
+ }
1692
+
1693
+ /**
1694
+ * If the query is `localized`, return the name of the `localized` entity for the `definition`.
1695
+ * If there is no `localized` entity for the `definition`, return the name of the `definition`
1696
+ *
1697
+ * @param {CSN.definition} definition
1698
+ * @returns the name of the localized entity for the given `definition` or `definition.name`
1699
+ */
1700
+ function localized(definition) {
1701
+ if (!isLocalized(definition)) return definition.name
1702
+ const view = model.definitions[`localized.${definition.name}`]
1703
+ return view?.name || definition.name
1704
+ }
1705
+
1706
+ /**
1707
+ * If a given query is required to be translated, the query has
1708
+ * the `.localized` property set to `true`. If that is the case,
1709
+ * and the definition has not set the `@cds.localized` annotation
1710
+ * to `false`, the given definition must be translated.
1711
+ *
1712
+ * @returns true if the given definition shall be localized
1713
+ */
1714
+ function isLocalized(definition) {
1715
+ return inferred.SELECT?.localized && definition['@cds.localized'] !== false
1716
+ }
1717
+
1718
+ /**
1719
+ * Get the csn definition of the target of a given association
1720
+ *
1721
+ * @param assoc
1722
+ * @returns the csn definition of the association target or null if it is not an association
1723
+ */
1724
+ function assocTarget(assoc) {
1725
+ return model.definitions[assoc.target] || null
1726
+ }
1727
+
1728
+ /**
1729
+ * Calculate the flat name for a deeply nested element:
1730
+ * @example `entity E { struct: { foo: String} }` => `getFullName(foo)` => `struct_foo`
1731
+ *
1732
+ * @param {CSN.element} node an element
1733
+ * @param {object} name the last part of the name, e.g. the name of the deeply nested element
1734
+ * @returns the flat name of the element
1735
+ */
1736
+ function getFullName(node, name = node.name) {
1737
+ if (node.parent.kind === 'entity') return name
1738
+
1739
+ return getFullName(node.parent, `${node.parent.name}_${name}`)
1740
+ }
1741
+
1742
+ /**
1743
+ * Calculates the name of the source which can be used to address the given node.
1744
+ *
1745
+ * @param {object} node a csn object with a `ref` and `$refLinks`
1746
+ * @param {object} $baseLink optional base `$refLink`, e.g. for infix filters.
1747
+ * For an infix filter, we must explicitly pass the TA name
1748
+ * because the first step of the ref might not be part of
1749
+ * the combined elements of the query
1750
+ * @returns the source name which can be used to address the node
1751
+ */
1752
+ function getQuerySourceName(node, $baseLink = null) {
1753
+ if (!node || !node.$refLinks || !node.ref) {
1754
+ throw new Error('Invalid node')
1755
+ }
1756
+ if ($baseLink) {
1757
+ return getBaseLinkAlias($baseLink)
1758
+ }
1759
+
1760
+ if (node.isJoinRelevant) {
1761
+ return getJoinRelevantAlias(node)
1762
+ }
1763
+
1764
+ return getSelectOrEntityAlias(node) || getCombinedElementAlias(node)
1765
+ function getBaseLinkAlias($baseLink) {
1766
+ return $baseLink.alias
1767
+ }
1768
+
1769
+ function getJoinRelevantAlias(node) {
1770
+ return [...node.$refLinks]
1771
+ .reverse()
1772
+ .find($refLink => $refLink.definition.isAssociation && !$refLink.onlyForeignKeyAccess).alias
1773
+ }
1774
+
1775
+ function getSelectOrEntityAlias(node) {
1776
+ let firstRefLink = node.$refLinks[0].definition
1777
+ if (firstRefLink.SELECT || firstRefLink.kind === 'entity') {
1778
+ const firstStep = node.ref[0]
1779
+ /**
1780
+ * If the node.ref refers to an implicit alias which is later on changed by cqn4sql,
1781
+ * we need to replace the usage of the implicit alias, with the correct, auto-generated table alias.
1782
+ *
1783
+ * This is the case if the following holds true:
1784
+ * - the original query has NO explicit alias
1785
+ * - ref[0] equals the implicit alias of the query (i.e. from.ref[ from.length - 1 ].split('.').pop())
1786
+ * - but differs from the explicit alias, assigned by cqn4sql (i.e. <subquery>.from.uniqueSubqueryAlias)
1787
+ */
1788
+ if (
1789
+ originalQuery.SELECT?.from.uniqueSubqueryAlias &&
1790
+ !originalQuery.SELECT?.from.as &&
1791
+ firstStep === getLastStringSegment(transformedQuery.SELECT.from.ref[0])
1792
+ ) {
1793
+ return originalQuery.SELECT?.from.uniqueSubqueryAlias
1794
+ }
1795
+ return node.ref[0]
1796
+ }
1797
+ }
1798
+
1799
+ function getCombinedElementAlias(node) {
1800
+ return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]][0].index)
1801
+ }
1802
+ }
1803
+ }
1804
+
1805
+ module.exports = Object.assign(cqn4sql, {
1806
+ // for own tests only:
1807
+ eqOps,
1808
+ notEqOps,
1809
+ notSupportedOps,
1810
+ })
1811
+
1812
+ function copy(obj) {
1813
+ const walk = function (par, prop) {
1814
+ const val = prop ? par[prop] : par
1815
+
1816
+ // If value is native return
1817
+ if (typeof val !== 'object' || val == null || val instanceof RegExp || val instanceof Date || val instanceof Buffer)
1818
+ return val
1819
+
1820
+ const ret = Array.isArray(val) ? [] : {}
1821
+ Object.keys(val).forEach(k => {
1822
+ ret[k] = walk(val, k)
1823
+ })
1824
+ return ret
1825
+ }
1826
+
1827
+ return walk(obj)
1828
+ }
1829
+
1830
+ function hasLogicalOr(tokenStream) {
1831
+ return tokenStream.some(t => t in { OR: true, or: true })
1832
+ }
1833
+
1834
+ /**
1835
+ * Returns the last segment of a string after the last dot.
1836
+ *
1837
+ * @param {string} str - The input string.
1838
+ * @returns {string} The last segment of the string after the last dot. If there is no dot in the string, the function returns the original string.
1839
+ */
1840
+ function getLastStringSegment(str) {
1841
+ const index = str.lastIndexOf('.')
1842
+ return index != -1 ? str.substring(index + 1) : str
1843
+ }
1844
+
1845
+ const idOnly = ref => ref.id || ref
1846
+ const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl