@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.
@@ -0,0 +1,922 @@
1
+ 'use strict'
2
+
3
+ const cds = require('@sap/cds/lib')
4
+
5
+ const JoinTree = require('./join-tree')
6
+ const { pseudos } = require('./pseudos')
7
+ // REVISIT: we should always return cds.linked elements
8
+ const cdsTypes = cds.linked({
9
+ definitions: {
10
+ Timestamp: { type: 'cds.Timestamp' },
11
+ DateTime: { type: 'cds.DateTime' },
12
+ Date: { type: 'cds.Date' },
13
+ Time: { type: 'cds.Time' },
14
+ String: { type: 'cds.String' },
15
+ Decimal: { type: 'cds.Decimal' },
16
+ Integer: { type: 'cds.Integer' },
17
+ Boolean: { type: 'cds.Boolean' },
18
+ },
19
+ }).definitions
20
+ for (const each in cdsTypes) cdsTypes[`cds.${each}`] = cdsTypes[each]
21
+
22
+ /**
23
+ * @param {CQN|CQL} originalQuery
24
+ * @param {CSN} [model]
25
+ * @returns {InferredCQN} = q with .target and .elements
26
+ */
27
+ function infer(originalQuery, model = cds.context?.model || cds.model) {
28
+ if (!model) cds.error('Please specify a model')
29
+ const inferred = typeof originalQuery === 'string' ? cds.parse.cql(originalQuery) : cds.ql.clone(originalQuery)
30
+
31
+ // REVISIT: The more edge use cases we support, thes less optimized are we for the 90+% use cases
32
+ // e.g. there's a lot of overhead for infer( SELECT.from(Books) )
33
+ if (originalQuery.SET) cds.error('”UNION” based queries are not supported')
34
+ const _ =
35
+ inferred.SELECT ||
36
+ inferred.INSERT ||
37
+ inferred.UPSERT ||
38
+ inferred.UPDATE ||
39
+ inferred.DELETE ||
40
+ inferred.CREATE ||
41
+ inferred.DROP ||
42
+ inferred.STREAM
43
+ const sources = inferTarget(_.from || _.into || _.entity, {})
44
+ const joinTree = new JoinTree(sources)
45
+ const aliases = Object.keys(sources)
46
+ Object.defineProperties(inferred, {
47
+ // REVISIT: public, or for local reuse, or in cqn4sql only?
48
+ sources: { value: sources, writable: true },
49
+ target: { value: aliases.length === 1 ? sources[aliases[0]] : originalQuery, writable: true }, // REVISIT: legacy?
50
+ })
51
+ // also enrich original query -> writable because it may be inferred again
52
+ Object.defineProperties(originalQuery, {
53
+ sources: { value: sources, writable: true },
54
+ target: {
55
+ value: aliases.length === 1 ? sources[aliases[0]] : originalQuery,
56
+ writable: true,
57
+ },
58
+ })
59
+ if (originalQuery.SELECT || originalQuery.DELETE || originalQuery.UPDATE || originalQuery.STREAM) {
60
+ const $combinedElements = inferCombinedElements()
61
+ /**
62
+ * TODO: this function is currently only called on DELETE's
63
+ * because it correctly set's up the $refLink's in the
64
+ * where clause: This functionality should be pulled out
65
+ * of ´inferQueryElement()` as this is a subtle side effect
66
+ */
67
+ const elements = inferQueryElements($combinedElements)
68
+ Object.defineProperties(inferred, {
69
+ $combinedElements: { value: $combinedElements, writable: true },
70
+ elements: { value: elements, writable: true },
71
+ joinTree: { value: joinTree, writable: true }, // REVISIT: eliminate
72
+ })
73
+ // also enrich original query -> writable because it may be inferred again
74
+ Object.defineProperty(originalQuery, 'elements', { value: elements, writable: true })
75
+ }
76
+ return inferred
77
+
78
+ /**
79
+ * Infers all query sources from a given SQL-like query's `from` clause.
80
+ * It drills down into join arguments of the `from` clause.
81
+ *
82
+ * This function helps identify each source, target, and association within the `from` clause.
83
+ * It processes the `from` clause in the query and maps each source to a respective target and alias.
84
+ * In case of any errors like missing definitions or associations, this function will throw an error.
85
+ *
86
+ * @function inferTarget
87
+ * @param {object|string} from - The `from` clause of the query to infer the target from.
88
+ * It could be an object or a string.
89
+ * @param {object} querySources - An object to map the query sources.
90
+ * Each key is a query source alias, and its value is the corresponding CSN Definition.
91
+ * @returns {object} The updated `querySources` object with inferred sources from the `from` clause.
92
+ */
93
+ function inferTarget(from, querySources) {
94
+ const { ref } = from
95
+ if (ref) {
96
+ const first = ref[0].id || ref[0]
97
+ let target = getDefinition(first, model)
98
+ if (!target) cds.error(`"${first}" not found in the definitions of your model`)
99
+ if (ref.length > 1) {
100
+ target = from.ref.slice(1).reduce((d, r) => {
101
+ const next = d.elements[r.id || r]?.elements ? d.elements[r.id || r] : d.elements[r.id || r]?._target
102
+ if (!next) cds.error(`No association "${r.id || r}" in ${d.kind} "${d.name}": ${d}`)
103
+ return next
104
+ }, target)
105
+ }
106
+ if (target.kind !== 'entity' && !target._isAssociation)
107
+ throw new Error(/Query source must be a an entity or an association/)
108
+
109
+ attachRefLinksToArg(from) // REVISIT: remove
110
+ const alias =
111
+ from.uniqueSubqueryAlias ||
112
+ from.as ||
113
+ (ref.length === 1 ? first.match(/[^.]+$/)[0] : ref[ref.length - 1].id || ref[ref.length - 1])
114
+ if (alias in querySources) throw new Error(`Duplicate alias "${alias}"`)
115
+ querySources[alias] = target
116
+ } else if (from.args) {
117
+ from.args.forEach(a => inferTarget(a, querySources))
118
+ } else if (from.SELECT) {
119
+ infer(from, model) // we need the .elements in the sources
120
+ querySources[from.as || ''] = from
121
+ } else if (typeof from === 'string') {
122
+ querySources[/([^.]*)$/.exec(from)[0]] = getDefinition(from, model)
123
+ } else if (from.SET) {
124
+ infer(from, model)
125
+ }
126
+ return querySources
127
+ }
128
+
129
+ // REVISIT: this helper is doing by far too much, with too many side effects
130
+
131
+ /**
132
+ * This function recursively traverses through all 'ref' steps of the 'arg' object and enriches it by attaching
133
+ * additional information. For each 'ref' step, it adds the corresponding definition and the target in which the
134
+ * next 'ref' step should be looked up.
135
+ *
136
+ *
137
+ * @param {Object} arg - The argument object that will be augmented with additional properties.
138
+ * It must contain a 'ref' property, which is an array representing the steps to be processed.
139
+ * Optionally, it can also contain an 'xpr' property, which is also processed recursively.
140
+ *
141
+ * @param {Object} $baseLink - Optional parameter. It represents the environment in which the first 'ref' step should be
142
+ * resolved. It's needed for infix filter / expand columns. It must contain a 'definition'
143
+ * property, which is an object representing the base environment.
144
+ *
145
+ * @param {boolean} expandOrExists - Optional parameter, defaults to false. It indicates whether the 'arg' is part of a
146
+ * 'column.expand' or preceded by an 'exists'. When true, unmanaged association paths
147
+ * are allowed -> $baseLink is an `expand` or `assoc` preceded by `exists`.
148
+ *
149
+ * @throws Will throw an error if a 'ref' step cannot be found in the current environment or if a 'ref' step
150
+ * represents an unmanaged association in the case of infix filters and 'expandOrExists' is false.
151
+ *
152
+ * @returns {void} This function does not return a value; it mutates the 'arg' object directly.
153
+ */
154
+ function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
155
+ const { ref, xpr } = arg
156
+ if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
157
+ if (!ref) return
158
+ init$refLinks(arg)
159
+ ref.forEach((step, i) => {
160
+ const id = step.id || step
161
+ if (i === 0) {
162
+ // infix filter never have table alias
163
+ // we need to search for first step in ´model.definitions[infixAlias]`
164
+ if ($baseLink) {
165
+ const { definition } = $baseLink
166
+ const elements = definition.elements || definition._target?.elements
167
+ const e = elements?.[id] || cds.error`"${id}" not found in the elements of "${definition.name}"`
168
+ if (e.target) {
169
+ // only fk access in infix filter
170
+ const nextStep = ref[1]?.id || ref[1]
171
+ // no unmanaged assoc in infix filter path
172
+ if (!expandOrExists && e.on)
173
+ throw new Error(
174
+ `"${e.name}" in path "${arg.ref.map(idOnly).join('.')}" must not be an unmanaged association`,
175
+ )
176
+ // no non-fk traversal in infix filter
177
+ if (!expandOrExists && nextStep && !(nextStep in e.foreignKeys))
178
+ throw new Error(`Only foreign keys of "${e.name}" can be accessed in infix filter`)
179
+ }
180
+ arg.$refLinks.push({ definition: e, target: e._target || e })
181
+ // filter paths are flattened
182
+ // REVISIT: too much augmentation -> better remove flatName..
183
+ Object.defineProperty(arg, 'flatName', { value: ref.join('_'), writable: true })
184
+ } else {
185
+ // must be in model.definitions
186
+ const definition = getDefinition(id, model)
187
+ arg.$refLinks[0] = { definition, target: definition }
188
+ }
189
+ } else {
190
+ const recent = arg.$refLinks[i - 1]
191
+ const { elements } = recent.target
192
+ const e = elements[id]
193
+ if (!e) throw new Error(`"${id}" not found in the elements of "${arg.$refLinks[i - 1].definition.name}"`)
194
+ arg.$refLinks.push({ definition: e, target: e._target || e })
195
+ }
196
+ arg.$refLinks[i].alias = !ref[i + 1] && arg.as ? arg.as : id.split('.').pop()
197
+
198
+ // link refs in where
199
+ if (step.where) {
200
+ // REVISIT: why do we need to walk through these so early?
201
+ if (arg.$refLinks[i].definition.kind === 'entity' || arg.$refLinks[i].definition._target) {
202
+ let existsPredicate = false
203
+ const walkTokenStream = token => {
204
+ if (token === 'exists') {
205
+ // no joins for infix filters along `exists <path>`
206
+ existsPredicate = true
207
+ } else if (token.xpr) {
208
+ // don't miss an exists within an expression
209
+ token.xpr.forEach(walkTokenStream)
210
+ } else {
211
+ attachRefLinksToArg(token, arg.$refLinks[i], existsPredicate)
212
+ existsPredicate = false
213
+ }
214
+ }
215
+ step.where.forEach(walkTokenStream)
216
+ } else throw new Error('A filter can only be provided when navigating along associations')
217
+ }
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Calculates the `$combinedElements` based on the provided queries `sources`.
223
+ * The `$combinedElements` of a query consist of all accessible elements across all
224
+ * the table aliases found in the from clause.
225
+ *
226
+ * The `$combinedElements` are attached to the query as a non-enumerable property.
227
+ * Each entry in the `$combinedElements` dictionary maps from the element name
228
+ * to an array of objects containing the index and table alias where the element can be found.
229
+ *
230
+ * @returns {Object} The `$combinedElements` dictionary, which maps element names to an array of objects
231
+ * containing the index and table alias where the element can be found.
232
+ */
233
+ function inferCombinedElements() {
234
+ const combinedElements = {}
235
+ for (const index in sources) {
236
+ const tableAlias = sources[index]
237
+ for (const key in tableAlias.elements) {
238
+ if (key in combinedElements) combinedElements[key].push({ index, tableAlias })
239
+ else combinedElements[key] = [{ index, tableAlias }]
240
+ }
241
+ }
242
+ return combinedElements
243
+ }
244
+
245
+ /**
246
+ * Assigns the given `element` as non-enumerable property 'element' onto `col`.
247
+ *
248
+ * @param {object} col
249
+ * @param {csn.Element} element
250
+ */
251
+ function setElementOnColumns(col, element) {
252
+ Object.defineProperty(col, 'element', {
253
+ value: element,
254
+ writable: true,
255
+ })
256
+ }
257
+
258
+ /**
259
+ * Walks over all columns of a query's `SELECT` and infers each `ref`, `xpr`, or `val` as a query element
260
+ * based on the query's `$combinedElements` and `sources`.
261
+ *
262
+ * The inferred `elements` are attached to the query as a non-enumerable property.
263
+ *
264
+ * Also walks over other `ref`s in the query, validates them, and attaches `$refLinks`.
265
+ * This includes handling `where`, infix filters within column `refs`, or other `csn` paths.
266
+ *
267
+ * @param {Object} $combinedElements The `$combinedElements` dictionary of the query, which maps element names
268
+ * to an array of objects containing the index and table alias where the element can be found.
269
+ * @returns {Object} The inferred `elements` dictionary of the query, which maps element names to their corresponding definitions.
270
+ */
271
+ function inferQueryElements($combinedElements) {
272
+ let queryElements = {}
273
+ const { columns, where, groupBy, having, orderBy } = _
274
+ if (!columns) {
275
+ inferElementsFromWildCard(aliases)
276
+ } else {
277
+ let wildcardSelect = false
278
+ const refs = []
279
+ columns.forEach(col => {
280
+ if (col === '*') {
281
+ wildcardSelect = true
282
+ } else if (col.val !== undefined || col.xpr || col.SELECT || col.func || col.param) {
283
+ const as = col.as || col.func || col.val
284
+ if (as === undefined) throw cds.error`Expecting expression to have an alias name`
285
+ if (queryElements[as]) throw cds.error`Duplicate definition of element “${as}”`
286
+ if (col.xpr || col.SELECT) {
287
+ queryElements[as] = getElementForXprOrSubquery(col)
288
+ } else if (col.func) {
289
+ col.args?.forEach(arg => inferQueryElement(arg, false)) // {func}.args are optional
290
+ queryElements[as] = getElementForCast(col)
291
+ } else {
292
+ // either binding parameter (col.param) or value
293
+ queryElements[as] = col.cast ? getElementForCast(col) : getCdsTypeForVal(col.val)
294
+ }
295
+ setElementOnColumns(col, queryElements[as])
296
+ } else if (col.ref) {
297
+ refs.push(col)
298
+ } else if (col.expand) {
299
+ inferQueryElement(col)
300
+ } else {
301
+ throw cds.error`Not supported: ${JSON.stringify(col)}`
302
+ }
303
+ })
304
+ refs.forEach(col => {
305
+ inferQueryElement(col)
306
+ const { definition } = col.$refLinks[col.$refLinks.length - 1]
307
+ if (col.cast)
308
+ // final type overwritten -> element not visible anymore
309
+ setElementOnColumns(col, getElementForCast(col))
310
+ else if ((col.ref.length === 1) & (col.ref[0] === '$user'))
311
+ // shortcut to $user.id
312
+ setElementOnColumns(col, queryElements[col.as || '$user'])
313
+ else setElementOnColumns(col, definition)
314
+ })
315
+ if (wildcardSelect) inferElementsFromWildCard(aliases)
316
+ }
317
+ if (orderBy) {
318
+ // link $refLinks -> special name resolution rules for orderBy
319
+ orderBy.forEach(token => {
320
+ let $baseLink
321
+ // first check if token ref is resolvable in query elements
322
+ if (columns) {
323
+ const e = queryElements[token.ref?.[0]]
324
+ const isAssocExpand = e?.$assocExpand // expand on structure can be addressed
325
+ if (e && !isAssocExpand) $baseLink = { definition: { elements: queryElements }, target: inferred }
326
+ } else {
327
+ // fallback to elements of query source
328
+ $baseLink = null
329
+ }
330
+
331
+ inferQueryElement(token, false, $baseLink)
332
+ })
333
+ }
334
+ if (where) {
335
+ let skipJoins
336
+ const walkTokenStream = token => {
337
+ if (token === 'exists') {
338
+ // no joins for infix filters along `exists <path>`
339
+ skipJoins = true
340
+ } else if (token.xpr) {
341
+ // don't miss an exists within an expression
342
+ token.xpr.forEach(walkTokenStream)
343
+ } else {
344
+ inferQueryElement(token, false, null, { inExists: skipJoins, inExpr: true })
345
+ skipJoins = false
346
+ }
347
+ }
348
+ where.forEach(walkTokenStream)
349
+ }
350
+ if (groupBy)
351
+ // link $refLinks
352
+ groupBy.forEach(token => inferQueryElement(token, false))
353
+ if (having)
354
+ // link $refLinks
355
+ having.forEach(token => inferQueryElement(token, false))
356
+ if (_.with)
357
+ // consider UPDATE.with
358
+ Object.values(_.with).forEach(val => inferQueryElement(val, false))
359
+
360
+ return queryElements
361
+
362
+ /**
363
+ * This function is responsible for inferring a query element based on a provided column.
364
+ * It initializes and attaches a non-enumerable `$refLinks` property to the column,
365
+ * which stores an array of objects that represent the corresponding artifact of the ref step.
366
+ * Each object in the `$refLinks` array corresponds to the same index position in the `column.ref` array.
367
+ * Based on the leaf artifact (last object in the `$refLinks` array), the query element is inferred.
368
+ *
369
+ * @param {object} column - The column object that contains the properties to infer a query element.
370
+ * @param {boolean} [insertIntoQueryElements=true] - Determines whether the inferred element should be inserted into the queries elements.
371
+ * For instance, it's set to false when walking over the where clause.
372
+ * @param {object} [$baseLink=null] - A base reference link, usually it's an object with a definition and a target.
373
+ * Used for infix filters, exists <assoc> and nested projections.
374
+ * @param {object} [context={}] - Contextual information for element inference.
375
+ * @param {boolean} [context.inExists=false] - Flag to control the creation of joins for non-association path traversals.
376
+ * for `exists <assoc>` paths we do not need to create joins for path expressions as they are part of the semi-joined subquery.
377
+ * @param {boolean} [context.inExpr=false] - Flag to signal whether the element is part of an expression.
378
+ * Used to ignore non-persisted elements.
379
+ * @param {boolean} [context.inNestedProjection=false] - Flag to signal whether the element is part of a nested projection.
380
+ *
381
+ * Note:
382
+ * - `inExists` is used to specify cases where no joins should be created for non-association path traversals.
383
+ * It is primarily used for infix filters in `exists assoc[parent.foo='bar']`, where it becomes part of a semi-join.
384
+ * - Columns with a `param` property are parameter references resolved into values only at execution time.
385
+ * - Columns with an `args` property are function calls in expressions.
386
+ * - Columns with a `list` property represent a list of values (e.g., for the IN operator).
387
+ * - Columns with a `SELECT` property represent subqueries.
388
+ *
389
+ * @throws {Error} If an unmanaged association is found in an infix filter path, an error is thrown.
390
+ * @throws {Error} If a non-foreign key traversal is found in an infix filter, an error is thrown.
391
+ * @throws {Error} If a first step is not found in the combined elements, an error is thrown.
392
+ * @throws {Error} If a filter is provided while navigating along non-associations, an error is thrown.
393
+ * @throws {Error} If the same element name is inferred more than once, an error is thrown.
394
+ *
395
+ * @returns {void}
396
+ */
397
+
398
+ function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, context) {
399
+ const { inExists, inExpr, inNestedProjection } = context || {}
400
+ if (column.param) return // parameter references are only resolved into values on execution e.g. :val, :1 or ?
401
+ if (column.args) column.args.forEach(arg => inferQueryElement(arg, false, $baseLink, context)) // e.g. function in expression
402
+ if (column.list) column.list.forEach(arg => inferQueryElement(arg, false, $baseLink, context))
403
+ if (column.xpr)
404
+ column.xpr.forEach(token => inferQueryElement(token, false, $baseLink, { ...context, inExpr: true })) // e.g. function in expression
405
+ if (column.SELECT) return
406
+
407
+ if (!column.ref) {
408
+ if (column.expand) queryElements[column.as] = resolveExpand(column)
409
+ return
410
+ }
411
+
412
+ init$refLinks(column)
413
+ // if any path step points to an artifact with `@cds.persistence.skip`
414
+ // we must ignore the element from the queries elements
415
+ let isPersisted = true
416
+ const firstStepIsTableAlias =
417
+ (column.ref.length > 1 && column.ref[0] in sources) ||
418
+ // nested projection on table alias
419
+ (column.ref.length === 1 && column.ref[0] in sources && column.inline)
420
+ const firstStepIsSelf =
421
+ !firstStepIsTableAlias && column.ref.length > 1 && ['$self', '$projection'].includes(column.ref[0])
422
+ const nameSegments = []
423
+ // if a (segment) of a (structured) foreign key is renamed, we must not include
424
+ // the aliased ref segments into the name of the final foreign key which is e.g. used in
425
+ // on conditions of joins
426
+ const skipAliasedFkSegmentsOfNameStack = []
427
+ let pseudoPath = false
428
+ column.ref.forEach((step, i) => {
429
+ const id = step.id || step
430
+ if (i === 0) {
431
+ if (id in pseudos.elements) {
432
+ // pseudo path
433
+ column.$refLinks.push({ definition: pseudos.elements[id], target: pseudos })
434
+ pseudoPath = true // only first path step must be well defined
435
+ nameSegments.push(id)
436
+ } else if ($baseLink) {
437
+ const { definition, target } = $baseLink
438
+ const elements = definition.elements || definition._target?.elements
439
+ if (elements && id in elements) {
440
+ const element = elements[id]
441
+ if (!inExists && !inNestedProjection && element.target) {
442
+ // only fk access in infix filter
443
+ const nextStep = column.ref[1]?.id || column.ref[1]
444
+ // no unmanaged assoc in infix filter path
445
+ if (element.on)
446
+ throw new Error(
447
+ `"${element.name}" in path "${column.ref
448
+ .map(idOnly)
449
+ .join('.')}" must not be an unmanaged association`,
450
+ )
451
+ // no non-fk traversal in infix filter
452
+ if (nextStep && !(nextStep in element.foreignKeys))
453
+ throw new Error(`Only foreign keys of "${element.name}" can be accessed in infix filter`)
454
+ }
455
+ column.$refLinks.push({ definition: elements[id], target })
456
+ } else {
457
+ stepNotFoundInPredecessor(id, definition.name)
458
+ }
459
+ nameSegments.push(id)
460
+ } else if (firstStepIsTableAlias) {
461
+ column.$refLinks.push({ definition: sources[id], target: sources[id] })
462
+ } else if (firstStepIsSelf) {
463
+ column.$refLinks.push({ definition: { elements: queryElements }, target: { elements: queryElements } })
464
+ } else if (column.ref.length > 1 && inferred.outerQueries?.find(outer => id in outer.sources)) {
465
+ // outer query accessed via alias
466
+ const outerAlias = inferred.outerQueries.find(outer => id in outer.sources)
467
+ column.$refLinks.push({ definition: outerAlias.sources[id], target: outerAlias.sources[id] })
468
+ } else if (id in $combinedElements) {
469
+ if ($combinedElements[id].length > 1) stepIsAmbiguous(id) // exit
470
+ const definition = $combinedElements[id][0].tableAlias.elements[id]
471
+ const $refLink = { definition, target: $combinedElements[id][0].tableAlias }
472
+ column.$refLinks.push($refLink)
473
+ nameSegments.push(id)
474
+ } else {
475
+ stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements)
476
+ }
477
+ } else {
478
+ const { definition } = column.$refLinks[i - 1]
479
+ const elements = definition.elements || definition._target?.elements
480
+ if (elements && id in elements) {
481
+ const $refLink = { definition: elements[id], target: column.$refLinks[i - 1].target }
482
+ column.$refLinks.push($refLink)
483
+ } else if (firstStepIsSelf) {
484
+ stepNotFoundInColumnList(id)
485
+ } else if (column.ref[0] === '$user' && pseudoPath) {
486
+ // `$user.some.unknown.element` -> no error
487
+ column.$refLinks.push({ definition: {}, target: column.$refLinks[i - 1].target })
488
+ } else if (id === '$dummy') {
489
+ // `some.known.element.$dummy` -> no error; used by cds.ql to simulate joins
490
+ column.$refLinks.push({ definition: { name: '$dummy', parent: column.$refLinks[i - 1].target } })
491
+ Object.defineProperty(column, 'isJoinRelevant', { value: true })
492
+ } else {
493
+ const notFoundIn = pseudoPath ? column.ref[i - 1] : getFullPathForLinkedArg(column)
494
+ stepNotFoundInPredecessor(id, notFoundIn)
495
+ }
496
+ const foreignKeyAlias = Array.isArray(definition.keys)
497
+ ? definition.keys.find(k => {
498
+ if (k.ref.every((step, j) => column.ref[i + j] === step)) {
499
+ skipAliasedFkSegmentsOfNameStack.push(...k.ref.slice(1))
500
+ return true
501
+ }
502
+ return false
503
+ })?.as
504
+ : null
505
+ if (foreignKeyAlias) nameSegments.push(foreignKeyAlias)
506
+ else if (skipAliasedFkSegmentsOfNameStack[0] === id) skipAliasedFkSegmentsOfNameStack.shift()
507
+ else nameSegments.push(id)
508
+ }
509
+
510
+ if (step.where) {
511
+ const danglingFilter = !(column.ref[i + 1] || column.expand || inExists)
512
+ if (!column.$refLinks[i].definition.target || danglingFilter)
513
+ throw new Error(/A filter can only be provided when navigating along associations/)
514
+ if (!column.expand) Object.defineProperty(column, 'isJoinRelevant', { value: true })
515
+ // books[exists genre[code='A']].title --> column is join relevant but inner exists filter is not
516
+ let skipJoinsForFilter = inExists
517
+ step.where.forEach(token => {
518
+ if (token === 'exists') {
519
+ // no joins for infix filters along `exists <path>`
520
+ skipJoinsForFilter = true
521
+ } else if (token.ref || token.xpr) {
522
+ inferQueryElement(token, false, column.$refLinks[i], {
523
+ inExists: skipJoinsForFilter,
524
+ inExpr: !!token.xpr,
525
+ })
526
+ } else if (token.func) {
527
+ token.args?.forEach(arg =>
528
+ inferQueryElement(arg, false, column.$refLinks[i], { inExists: skipJoinsForFilter, inExpr: true }),
529
+ )
530
+ }
531
+ })
532
+ }
533
+
534
+ column.$refLinks[i].alias = !column.ref[i + 1] && column.as ? column.as : id.split('.').pop()
535
+ if (column.$refLinks[i].definition._target?.['@cds.persistence.skip'] === true) isPersisted = false
536
+ if (!column.ref[i + 1]) {
537
+ const flatName = nameSegments.join('_')
538
+ Object.defineProperty(column, 'flatName', { value: flatName, writable: true })
539
+ // if column is casted, we overwrite it's origin with the new type
540
+ if (column.cast) {
541
+ const base = getElementForCast(column)
542
+ if (insertIntoQueryElements) queryElements[column.as || flatName] = getCopyWithAnnos(column, base)
543
+ } else if (column.expand) {
544
+ const elements = resolveExpand(column)
545
+ if (insertIntoQueryElements) queryElements[column.as || flatName] = elements
546
+ } else if (column.inline && insertIntoQueryElements) {
547
+ const elements = resolveInline(column)
548
+ queryElements = { ...queryElements, ...elements }
549
+ } else {
550
+ // shortcut for `ref: ['$user']` -> `ref: ['$user', 'id']`
551
+ const leafArt =
552
+ i === 0 && id === '$user' ? column.$refLinks[i].definition.elements.id : column.$refLinks[i].definition
553
+ // infer element based on leaf artifact of path
554
+ if (insertIntoQueryElements) {
555
+ let elementName
556
+ if (column.as) {
557
+ elementName = column.as
558
+ } else {
559
+ // if the navigation the user has written differs from the final flat ref - e.g. for renamed foreign keys -
560
+ // the inferred name of the element equals the flat version of the user-written ref.
561
+ const refNavigation = column.ref
562
+ .slice(firstStepIsSelf || firstStepIsTableAlias ? 1 : 0)
563
+ .map(idOnly)
564
+ .join('_')
565
+ if (refNavigation !== flatName) elementName = refNavigation
566
+ else elementName = flatName
567
+ }
568
+ if (queryElements[elementName] !== undefined)
569
+ throw new Error(`Duplicate definition of element “${elementName}”`)
570
+ queryElements[elementName] = getCopyWithAnnos(column, leafArt)
571
+ }
572
+ }
573
+ }
574
+ })
575
+
576
+ // ignore whole expand if target of assoc along path has ”@cds.persistence.skip”
577
+ if (column.expand) {
578
+ const { $refLinks } = column
579
+ const skip = $refLinks.some(
580
+ link => model.definitions[link.definition.target]?.['@cds.persistence.skip'] === true,
581
+ )
582
+ if (skip) {
583
+ $refLinks[$refLinks.length - 1].skipExpand = true
584
+ return
585
+ }
586
+ }
587
+ const virtual = (column.$refLinks[column.$refLinks.length - 1].definition.virtual || !isPersisted) && !inExpr
588
+ // check if we need to merge the column `ref` into the join tree of the query
589
+ if (!inExists && !virtual && isColumnJoinRelevant(column)) {
590
+ Object.defineProperty(column, 'isJoinRelevant', { value: true })
591
+ joinTree.mergeColumn(column)
592
+ }
593
+
594
+ /**
595
+ * Resolves and processes the inline attribute of a column in a database query.
596
+ *
597
+ * @param {Object} col - The column object with properties: `inline` and `$refLinks`.
598
+ * @param {string} [namePrefix=col.as || col.flatName] - Prefix for naming new columns. Defaults to `col.as` or `col.flatName`.
599
+ * @returns {Object} - An object with resolved and processed inline column definitions.
600
+ *
601
+ * Procedure:
602
+ * 1. Iterate through `inline` array. For each `inlineCol`:
603
+ * a. If `inlineCol` equals '*', wildcard elements are processed and added to the `elements` object.
604
+ * b. If `inlineCol` has inline or expand attributes, corresponding functions are called recursively and the resulting elements are added to the `elements` object.
605
+ * c. If `inlineCol` has val or func attributes, new elements are created and added to the `elements` object.
606
+ * d. Otherwise, the corresponding `$refLinks` definition is added to the `elements` object.
607
+ * 2. Returns the `elements` object.
608
+ */
609
+ function resolveInline(col, namePrefix = col.as || col.flatName) {
610
+ const { inline, $refLinks } = col
611
+ const $leafLink = $refLinks[$refLinks.length - 1]
612
+ let elements = {}
613
+ inline.forEach(inlineCol => {
614
+ inferQueryElement(inlineCol, false, $leafLink, { inExpr: true, inNestedProjection: true })
615
+ if (inlineCol === '*') {
616
+ const wildCardElements = {}
617
+ // either the `.elements´ of the struct or the `.elements` of the assoc target
618
+ const leafLinkElements = $leafLink.definition.elements || $leafLink.definition._target.elements
619
+ Object.entries(leafLinkElements).forEach(([k, v]) => {
620
+ const name = namePrefix ? `${namePrefix}_${k}` : k
621
+ // if overwritten/excluded omit from wildcard elements
622
+ // in elements the names are already flat so consider the prefix
623
+ // in excluding, the elements are addressed without the prefix
624
+ if (!(name in elements || col.excluding?.some(e => e === k))) wildCardElements[name] = v
625
+ })
626
+ elements = { ...elements, ...wildCardElements }
627
+ } else {
628
+ const nameParts = namePrefix ? [namePrefix] : []
629
+ if (inlineCol.as) nameParts.push(inlineCol.as)
630
+ else nameParts.push(...inlineCol.ref.map(idOnly))
631
+ const name = nameParts.join('_')
632
+ if (inlineCol.inline) {
633
+ const inlineElements = resolveInline(inlineCol, name)
634
+ elements = { ...elements, ...inlineElements }
635
+ } else if (inlineCol.expand) {
636
+ const expandElements = resolveExpand(inlineCol)
637
+ elements = { ...elements, [name]: expandElements }
638
+ } else if (inlineCol.val) {
639
+ elements[name] = { ...getCdsTypeForVal(inlineCol.val) }
640
+ } else if (inlineCol.func) {
641
+ elements[name] = {}
642
+ } else {
643
+ elements[name] = inlineCol.$refLinks[inlineCol.$refLinks.length - 1].definition
644
+ }
645
+ }
646
+ })
647
+ return elements
648
+ }
649
+
650
+ /**
651
+ * Resolves a query column which has an `expand` property.
652
+ *
653
+ * @param {Object} col - The column object with properties: `expand` and `$refLinks`.
654
+ * @returns {Object} - A `cds.struct` object with expanded column definitions.
655
+ *
656
+ * Procedure:
657
+ * - if `$leafLink` is an association, constructs an `expandSubquery` and infers a new query structure.
658
+ * Returns a new `cds.struct` if the association has a target cardinality === 1 or a `cds.array` for to many relations.
659
+ * - else constructs an `elements` object based on the refs `expand` found in the expand and returns a new `cds.struct` with these `elements`.
660
+ */
661
+ function resolveExpand(col) {
662
+ const { expand, $refLinks } = col
663
+ const $leafLink = $refLinks?.[$refLinks.length - 1]
664
+ if ($leafLink?.definition._target) {
665
+ const expandSubquery = {
666
+ SELECT: {
667
+ from: $leafLink.definition._target.name,
668
+ columns: expand.filter(c => !c.inline),
669
+ },
670
+ }
671
+ if (col.as) expandSubquery.SELECT.as = col.as
672
+ const inferredExpandSubquery = infer(expandSubquery, model)
673
+ const res = $leafLink.definition.is2one
674
+ ? new cds.struct({ elements: inferredExpandSubquery.elements })
675
+ : new cds.array({ items: new cds.struct({ elements: inferredExpandSubquery.elements }) })
676
+ return Object.defineProperty(res, '$assocExpand', { value: true })
677
+ } // struct
678
+ let elements = {}
679
+ expand.forEach(e => {
680
+ if (e === '*') {
681
+ elements = { ...elements, ...$leafLink.definition.elements }
682
+ } else {
683
+ inferQueryElement(e, false, $leafLink, { inExpr: true, inNestedProjection: true })
684
+ if (e.expand) elements[e.as || e.flatName] = resolveExpand(e)
685
+ if (e.inline) elements = { ...elements, ...resolveInline(e) }
686
+ else elements[e.as || e.flatName] = e.$refLinks ? e.$refLinks[e.$refLinks.length - 1].definition : e
687
+ }
688
+ })
689
+ return new cds.struct({ elements })
690
+ }
691
+
692
+ function stepNotFoundInPredecessor(step, def) {
693
+ throw new Error(`"${step}" not found in "${def}"`)
694
+ }
695
+
696
+ function stepIsAmbiguous(step) {
697
+ throw new Error(
698
+ `ambiguous reference to "${step}", write ${Object.values($combinedElements[step])
699
+ .map(ta => `"${ta.index}.${step}"`)
700
+ .join(', ')} instead`,
701
+ )
702
+ }
703
+
704
+ function stepNotFoundInCombinedElements(step) {
705
+ throw new Error(
706
+ `"${step}" not found in the elements of ${Object.values(sources)
707
+ .map(def => `"${def.name || /* subquery */ def.as}"`)
708
+ .join(', ')}`,
709
+ )
710
+ }
711
+
712
+ function stepNotFoundInColumnList(step) {
713
+ const err = [`"${step}" not found in the columns list of query`]
714
+ // if the `elt` from a `$self.elt` path is found in the `$combinedElements` -> hint to remove `$self`
715
+ if (step in $combinedElements)
716
+ err.push(` did you mean ${$combinedElements[step].map(ta => `"${ta.index || ta.as}.${step}"`).join(',')}?`)
717
+ throw new Error(err)
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Checks whether or not the `ref` of the given column is join relevant.
723
+ * A `ref` is considered join relevant if it includes an association traversal and:
724
+ * - the association is unmanaged
725
+ * - a non-foreign key access is performed
726
+ * - an infix filter is applied at the association
727
+ *
728
+ * @param {object} column the column with the `ref` to check for join relevance
729
+ * @returns {boolean} true if the column ref needs to be merged into a join tree
730
+ */
731
+ function isColumnJoinRelevant(column) {
732
+ let fkAccess = false
733
+ let assoc = null
734
+ for (let i = 0; i < column.ref.length; i++) {
735
+ const ref = column.ref[i]
736
+ const link = column.$refLinks[i]
737
+ if (link.definition.on && link.definition.isAssociation) {
738
+ if (!column.ref[i + 1]) {
739
+ if (column.expand && assoc) return true
740
+ // if unmanaged assoc is exposed, ignore it
741
+ return false
742
+ }
743
+ return true
744
+ }
745
+ if (assoc && assoc.keys?.some(key => key.ref.every((step, j) => column.ref[i + j] === step))) {
746
+ // foreign key access without filters never join relevant
747
+ return false
748
+ }
749
+ if (link.definition.target && link.definition.keys) {
750
+ if (column.ref[i + 1] || assoc) fkAccess = false
751
+ else fkAccess = true
752
+ assoc = link.definition
753
+ if (ref.where) {
754
+ // always join relevant except for expand assoc
755
+ if (column.expand && !column.ref[i + 1]) return false
756
+ return true
757
+ }
758
+ }
759
+ }
760
+
761
+ if (!assoc) return false
762
+ if (fkAccess) return false
763
+ else return true
764
+ }
765
+
766
+ /**
767
+ * Iterates over all `$combinedElements` of the `query` and puts them into the `query`s `elements`,
768
+ * if there is not already an element with the same name present.
769
+ */
770
+ function inferElementsFromWildCard() {
771
+ if (Object.keys(queryElements).length === 0 && aliases.length === 1) {
772
+ // only one query source and no overwritten columns
773
+ Object.entries(sources[aliases[0]].elements).forEach(([name, element]) => {
774
+ if (element.type !== 'cds.LargeBinary') queryElements[name] = element
775
+ })
776
+ return
777
+ }
778
+
779
+ const exclude = _.excluding ? x => _.excluding.includes(x) : () => false
780
+ const ambiguousElements = {}
781
+ Object.entries($combinedElements).forEach(([name, tableAliases]) => {
782
+ if (Object.keys(tableAliases).length > 1) {
783
+ ambiguousElements[name] = tableAliases
784
+ return ambiguousElements[name]
785
+ }
786
+ if (exclude(name) || name in queryElements) return true
787
+ const element = tableAliases[0].tableAlias.elements[name]
788
+ if (element.type !== 'cds.LargeBinary') queryElements[name] = element
789
+ })
790
+
791
+ if (Object.keys(ambiguousElements).length > 0) throwAmbiguousWildcardError()
792
+
793
+ function throwAmbiguousWildcardError() {
794
+ const err = []
795
+ err.push('Ambiguous wildcard elements:')
796
+ Object.keys(ambiguousElements).forEach(name => {
797
+ const tableAliasNames = Object.values(ambiguousElements[name]).map(v => v.index)
798
+ err.push(
799
+ ` select "${name}" explicitly with ${tableAliasNames
800
+ .map(taName => `"${taName}.${name}"`)
801
+ .join(', ')}`,
802
+ )
803
+ })
804
+ throw new Error(err.join('\n'))
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Returns a new object which is the inferred element for the given `col`.
810
+ * A cast type (via cast function) on the column gets preserved.
811
+ *
812
+ * @param {object} col
813
+ * @returns object
814
+ */
815
+ function getElementForXprOrSubquery(col) {
816
+ const { xpr } = col
817
+ let skipJoins = false
818
+ xpr?.forEach(token => {
819
+ if (token === 'exists') {
820
+ // no joins for infix filters along `exists <path>`
821
+ skipJoins = true
822
+ } else {
823
+ inferQueryElement(token, false, null, { inExists: skipJoins, inExpr: true })
824
+ skipJoins = false
825
+ }
826
+ })
827
+ const base = getElementForCast(col.cast ? col : xpr?.[0] || col)
828
+ if (col.key) base.key = col.key // > preserve key on column
829
+ return getCopyWithAnnos(col, base)
830
+ }
831
+
832
+ /**
833
+ * Returns an object with the cast-type defined in the cast of the `thing`.
834
+ * If no cast property is present, it just returns an empty object.
835
+ * The type of the cast is mapped to the `cds` type if possible.
836
+ *
837
+ * @param {object} thing with the cast property
838
+ * @returns {object}
839
+ */
840
+ function getElementForCast(thing) {
841
+ const { cast, $refLinks } = thing
842
+ if (!cast) return {}
843
+ if ($refLinks?.[$refLinks.length - 1].definition.elements)
844
+ // no cast on structure
845
+ cds.error`Structured elements can't be cast to a different type`
846
+ thing.cast = cdsTypes[cast.type] || cast
847
+ return thing.cast
848
+ }
849
+ }
850
+
851
+ /**
852
+ * return a new object based on @param base
853
+ * with all annotations found in @param from
854
+ *
855
+ * @param {object} from
856
+ * @param {object} base
857
+ * @returns {object} a copy of @param base with all annotations of @param from
858
+ * @TODO prototype based
859
+ */
860
+ // REVISIT: TODO: inferred.elements should be linked
861
+ function getCopyWithAnnos(from, base) {
862
+ const result = { ...base }
863
+ // REVISIT: we don't need to and hence should not handle annotations at runtime
864
+ for (const prop in from) {
865
+ if (prop.startsWith('@')) result[prop] = from[prop]
866
+ }
867
+
868
+ if (from.as && base.name !== from.as) Object.defineProperty(result, 'name', { value: from.as }) // TODO double check if this is needed
869
+ // in subqueries we need the linked element if an outer query accesses it
870
+ return Object.setPrototypeOf(result, base)
871
+ }
872
+
873
+ // REVISIT: functions without return are by nature side-effect functions -> bad
874
+ function init$refLinks(arg) {
875
+ Object.defineProperty(arg, '$refLinks', {
876
+ value: [],
877
+ writable: true,
878
+ })
879
+ }
880
+
881
+ function getCdsTypeForVal(val) {
882
+ // REVISIT: JS null should have a type for proper DB layer conversion logic
883
+ // if(val === null) return {type:'cds.String'}
884
+ switch (typeof val) {
885
+ case 'string':
886
+ return cdsTypes.String
887
+ case 'boolean':
888
+ return cdsTypes.Boolean
889
+ case 'number':
890
+ return Number.isSafeInteger(val) ? cdsTypes.Integer : cdsTypes.Decimal
891
+ default:
892
+ return {}
893
+ }
894
+ }
895
+
896
+ /** gets the CSN element for the given name from the model */
897
+ function getDefinition(name, model) {
898
+ return model.definitions[name] || cds.error`"${name}" not found in the definitions of your model`
899
+ }
900
+
901
+ /**
902
+ * Returns the csn path as string for a given column ref with sibling $refLinks
903
+ *
904
+ * @param {object} arg
905
+ * @returns {string}
906
+ */
907
+ function getFullPathForLinkedArg(arg) {
908
+ let firstStepIsEntity = false
909
+ return arg.$refLinks.reduce((res, cur, i) => {
910
+ if (cur.definition.kind === 'entity') {
911
+ firstStepIsEntity = true
912
+ if (arg.$refLinks.length === 1) return `${cur.definition.name}`
913
+ return `${cur.definition.name}`
914
+ }
915
+ const dot = i === 1 && firstStepIsEntity ? ':' : '.' // divide with colon if first step is entity
916
+ return res !== '' ? res + dot + cur.definition.name : cur.definition.name
917
+ }, '')
918
+ }
919
+ }
920
+ const idOnly = ref => ref.id || ref
921
+
922
+ module.exports = infer