Skip to content

Commit 7ec18f2

Browse files
feat(flattening): allow flattening of n-ary structures in list (#1337)
a structured element which expands to `>=1` sub elements / foreign keys can be expanded to all its leafs in a `list` or in a `group by`. **Restrictions**: - in other list like constructs like `function.args` & `order by` this is only possible for structures with exactly one scalar leaf due to unclear semantics. The `getTransformedTokenStream` function has access to the context in which the token stream shall be expanded. With this information it is still possible reject a structure/assoc as a value in `having` & `where` other changes: feat(`flattening`): allow flattening of structures with exactly one leaf in `order by` and `args`
1 parent 48e8090 commit 7ec18f2

File tree

5 files changed

+240
-83
lines changed

5 files changed

+240
-83
lines changed

db-service/lib/cqn4sql.js

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ function cqn4sql(originalQuery, model) {
9696

9797
// Transform the existing where, prepend table aliases, and so on...
9898
if (where) {
99-
transformedProp.where = getTransformedTokenStream(where)
99+
transformedProp.where = getTransformedTokenStream(where, { prop: 'where' })
100100
}
101101

102102
// Transform the from clause: association path steps turn into `WHERE EXISTS` subqueries.
@@ -191,7 +191,7 @@ function cqn4sql(originalQuery, model) {
191191

192192
// Like the WHERE clause, aliases from the SELECT list are not accessible for `group by`/`having` (in most DB's)
193193
if (having) {
194-
transformedQuery.SELECT.having = getTransformedTokenStream(having)
194+
transformedQuery.SELECT.having = getTransformedTokenStream(having, { prop: 'having' })
195195
}
196196

197197
if (groupBy) {
@@ -314,7 +314,7 @@ function cqn4sql(originalQuery, model) {
314314
lhs.args.push(arg)
315315
alreadySeen.set(nextAssoc.$refLink.alias, true)
316316
if (nextAssoc.where) {
317-
const filter = getTransformedTokenStream(nextAssoc.where, nextAssoc.$refLink)
317+
const filter = getTransformedTokenStream(nextAssoc.where, { $baseLink: nextAssoc.$refLink })
318318
lhs.on = [
319319
...(hasLogicalOr(lhs.on) ? [asXpr(lhs.on)] : lhs.on),
320320
'and',
@@ -521,14 +521,14 @@ function cqn4sql(originalQuery, model) {
521521
}
522522
}
523523

524-
function resolveCalculatedElement(column, omitAlias = false, baseLink = null) {
524+
function resolveCalculatedElement(column, omitAlias = false, $baseLink = null) {
525525
let value
526526

527527
if (column.$refLinks) {
528528
const { $refLinks } = column
529529
value = $refLinks[$refLinks.length - 1].definition.value
530530
if (column.$refLinks.length > 1) {
531-
baseLink =
531+
$baseLink =
532532
[...$refLinks].reverse().find($refLink => $refLink.definition.isAssociation) ||
533533
// if there is no association in the path, the table alias is the base link
534534
// TA might refer to subquery -> we need to propagate the alias to all paths of the calc element
@@ -541,13 +541,13 @@ function cqn4sql(originalQuery, model) {
541541

542542
let res
543543
if (ref) {
544-
res = getTransformedTokenStream([value], baseLink)[0]
544+
res = getTransformedTokenStream([value], { $baseLink })[0]
545545
} else if (xpr) {
546-
res = { xpr: getTransformedTokenStream(value.xpr, baseLink) }
546+
res = { xpr: getTransformedTokenStream(value.xpr, { $baseLink }) }
547547
} else if (val !== undefined) {
548548
res = { val }
549549
} else if (func) {
550-
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
550+
res = { args: getTransformedFunctionArgs(value.args, $baseLink), func: value.func }
551551
}
552552
if (!omitAlias) res.as = column.as || column.name || column.flatName
553553
return res
@@ -1018,7 +1018,7 @@ function cqn4sql(originalQuery, model) {
10181018
* the result.
10191019
*/
10201020
if (inOrderBy && flatColumns.length > 1)
1021-
throw new Error(`"${getFullName(leaf)}" can't be used in order by as it expands to multiple fields`)
1021+
throw new Error(`Structured element “${getFullName(leaf)}” expands to multiple fields and can't be used in order by`)
10221022
flatColumns.forEach(fc => {
10231023
if (col.nulls) fc.nulls = col.nulls
10241024
if (col.sort) fc.sort = col.sort
@@ -1182,7 +1182,7 @@ function cqn4sql(originalQuery, model) {
11821182
if (column.val || column.func || column.SELECT) return [column]
11831183

11841184
const structsAreUnfoldedAlready = model.meta.unfolded?.includes('structs')
1185-
let { baseName, columnAlias = column.as, tableAlias } = names
1185+
let { baseName, columnAlias = column.as, tableAlias } = names || {}
11861186
const { exclude, replace } = excludeAndReplace || {}
11871187
const { $refLinks, flatName, isJoinRelevant } = column
11881188
let firstNonJoinRelevantAssoc, stepAfterAssoc
@@ -1360,14 +1360,18 @@ function cqn4sql(originalQuery, model) {
13601360
* @param {object[]} tokenStream - The token stream to transform. Each token in the stream is an
13611361
* object representing a CQN construct such as a column, an operator,
13621362
* or a subquery.
1363-
* @param {object} [$baseLink=null] - The context in which the `ref`s in the token stream are resolvable.
1364-
* It serves as the reference point for resolving associations in
1365-
* statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1366-
* Here, the $baseLink for `anotherAssoc` would be `assoc`.
1363+
* @param {object} [context] - Optional context object.
1364+
* @param {object} [context.$baseLink] - The context in which the `ref`s in the token stream are resolvable.
1365+
* It serves as the reference point for resolving associations in
1366+
* statements like `{…} WHERE exists assoc[exists anotherAssoc]`.
1367+
* Here, the $baseLink for `anotherAssoc` would be `assoc`.
1368+
* @param {string} [context.prop] - The query property which holds the token stream which shall be
1369+
* transformed by this function, e.g. "where".
13671370
* @returns {object[]} - The transformed token stream.
13681371
*/
1369-
function getTransformedTokenStream(tokenStream, $baseLink = null) {
1372+
function getTransformedTokenStream(tokenStream, context = {}) {
13701373
const transformedTokenStream = []
1374+
const { $baseLink, /* prop */ } = context
13711375
for (let i = 0; i < tokenStream.length; i++) {
13721376
const token = tokenStream[i]
13731377
if (token === 'exists') {
@@ -1453,7 +1457,7 @@ function cqn4sql(originalQuery, model) {
14531457
if (list.every(e => e.val))
14541458
// no need for transformation
14551459
transformedTokenStream.push({ list })
1456-
else transformedTokenStream.push({ list: getTransformedTokenStream(list, $baseLink) })
1460+
else transformedTokenStream.push({ list: getTransformedTokenStream(list, { $baseLink, prop: 'list' }) })
14571461
}
14581462
} else if (tokenStream.length === 1 && token.val && $baseLink) {
14591463
// infix filter - OData variant w/o mentioning key --> flatten out and compare each leaf to token.val
@@ -1509,13 +1513,13 @@ function cqn4sql(originalQuery, model) {
15091513
i = indexRhs // jump to next relevant index
15101514
} else {
15111515
// reject associations in expression, except if we are in an infix filter -> $baseLink is set
1512-
assertNoStructInXpr(token, $baseLink)
1516+
assertNoStructInXpr(token, context)
15131517
// reject virtual elements in expressions as they will lead to a sql error down the line
15141518
if (lhsDef?.virtual) throw new Error(`Virtual elements are not allowed in expressions`)
15151519

15161520
let result = is_regexp(token?.val) ? token : copy(token) // REVISIT: too expensive! //
15171521
if (token.ref) {
1518-
const { definition } = token.$refLinks[token.$refLinks.length - 1]
1522+
const { definition } = token.$refLinks.at(-1)
15191523
// Add definition to result
15201524
setElementOnColumns(result, definition)
15211525
if (isCalculatedOnRead(definition)) {
@@ -1536,7 +1540,15 @@ function cqn4sql(originalQuery, model) {
15361540
const lastAssoc =
15371541
token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation)
15381542
const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink)
1539-
if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
1543+
if(isAssocOrStruct(definition)) {
1544+
const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias })
1545+
if(flat.length === 0)
1546+
throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`)
1547+
else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list`
1548+
throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`)
1549+
transformedTokenStream.push(...flat)
1550+
continue
1551+
} else if ((!$baseLink || lastAssoc) && token.isJoinRelevant) {
15401552
let name = calculateElementName(token)
15411553
result.ref = [tableAlias, name]
15421554
} else if (tableAlias) {
@@ -1549,7 +1561,7 @@ function cqn4sql(originalQuery, model) {
15491561
result = transformSubquery(token)
15501562
} else {
15511563
if (token.xpr) {
1552-
result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
1564+
result.xpr = getTransformedTokenStream(token.xpr, { $baseLink })
15531565
}
15541566
if (token.func && token.args) {
15551567
result.args = getTransformedFunctionArgs(token.args, $baseLink)
@@ -1661,11 +1673,15 @@ function cqn4sql(originalQuery, model) {
16611673
}
16621674
}
16631675

1664-
function assertNoStructInXpr(token, inInfixFilter = false) {
1665-
if (!inInfixFilter && token.$refLinks?.[token.$refLinks.length - 1].definition.target)
1666-
// REVISIT: let this through if not requested otherwise
1676+
function assertNoStructInXpr(token, context) {
1677+
const definition = token.$refLinks?.at(-1).definition
1678+
if(!definition) return
1679+
const rejectStructs = context && (context.prop in { where: 1, having: 1 })
1680+
// unmanaged is always forbidden
1681+
// expanding a ref in a `where`/`having` context
1682+
if ((rejectStructs && definition?.target) || definition?.on)
16671683
rejectAssocInExpression()
1668-
if (isStructured(token.$refLinks?.[token.$refLinks.length - 1].definition))
1684+
if (rejectStructs && isStructured(definition))
16691685
// REVISIT: let this through if not requested otherwise
16701686
rejectStructInExpression()
16711687

@@ -1771,7 +1787,7 @@ function cqn4sql(originalQuery, model) {
17711787

17721788
// only append infix filter to outer where if it is the leaf of the from ref
17731789
if (refReverse[0].where)
1774-
filterConditions.push(getTransformedTokenStream(refReverse[0].where, $refLinksReverse[0]))
1790+
filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] }))
17751791

17761792
if (existingWhere.length > 0) filterConditions.push(existingWhere)
17771793
if (whereExistsSubSelects.length > 0) {
@@ -2209,7 +2225,7 @@ function cqn4sql(originalQuery, model) {
22092225
}
22102226

22112227
if (customWhere) {
2212-
const filter = getTransformedTokenStream(customWhere, next)
2228+
const filter = getTransformedTokenStream(customWhere, { $baseLink: next })
22132229
const wrappedFilter = hasLogicalOr(filter) ? [asXpr(filter)] : filter
22142230
on.push('and', ...wrappedFilter)
22152231
}
@@ -2315,10 +2331,10 @@ function cqn4sql(originalQuery, model) {
23152331
function getTransformedFunctionArgs(args, $baseLink = null) {
23162332
let result = null
23172333
if (Array.isArray(args)) {
2318-
result = args.map(t => {
2334+
result = args.flatMap(t => {
23192335
if (!t.val)
23202336
// this must not be touched
2321-
return getTransformedTokenStream([t], $baseLink)[0]
2337+
return getTransformedTokenStream([t], { $baseLink })
23222338
return t
23232339
})
23242340
} else if (typeof args === 'object') {
@@ -2327,7 +2343,7 @@ function cqn4sql(originalQuery, model) {
23272343
const t = args[prop]
23282344
if (!t.val)
23292345
// this must not be touched
2330-
result[prop] = getTransformedTokenStream([t], $baseLink)[0]
2346+
result[prop] = getTransformedTokenStream([t], { $baseLink })[0]
23312347
else result[prop] = t
23322348
}
23332349
}

db-service/test/bookshop/db/schema.cds

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ entity Bar {
106106
x : Integer;
107107
};
108108
};
109+
empty: {
110+
// nothing
111+
}
109112
}
110113

111114
entity EStruc {

db-service/test/bookshop/db/search.cds

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,17 @@ entity BookShelf {
123123
books : Composition of many Books
124124
on books.shelf = $self;
125125
}
126+
127+
@cds.search: {
128+
toMulti
129+
}
130+
entity MultipleLeafAssocAsKey {
131+
key toMulti : Association to MultipleKeys;
132+
}
133+
134+
entity MultipleKeys {
135+
key ID1 : Integer;
136+
key ID2 : Integer;
137+
key ID3 : Integer;
138+
text: String;
139+
}

0 commit comments

Comments
 (0)