diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index bcc9ccd4f..aa273d797 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -683,6 +683,9 @@ class CQN2SQLRenderer { if (z.args) { return _aliased(`${this.quote(this.name(z, q))}${this.from_args(z.args)}`) } + if (ref.length > 1) { + return `${this.from({ ref: [from.ref[0]], as })}, json_each((${this.Map_extract({ ref: [as, ...from.ref.slice(1)], as, _from: true })}))` + } return _aliased(this.quote(this.name(z, q))) } if (from.SELECT) return _aliased(`(${this.SELECT(from)})`) @@ -1257,14 +1260,51 @@ class CQN2SQLRenderer { * @param {import('./infer/cqn').ref} param0 * @returns {string} SQL */ - ref({ ref }) { + ref(org) { + const { ref, element, _from } = org switch (ref[0]) { case '$now': return this.func({ func: 'session_context', args: [{ val: '$now', param: false }] }) // REVISIT: why do we need param: false here? case '$user': return this.func({ func: 'session_context', args: [{ val: '$user.' + ref[1] || 'id', param: false }] }) // REVISIT: same here? - default: return ref.map(r => this.quote(r)).join('.') + default: return !element?.parent || ref.length > 2 + ? this.Map_extract(org) + : ref.map(r => this.quote(r)).join('.') } } + Map_extract({ ref, _from }) { + ref = [...ref] + let src = this._from_value || (this.cqn._target instanceof cds.builtin.classes.Map && !_from) + ? ['value'] + : ref.splice(0, 2) + + src = src.map(r => this.quote(r)).join('.') + + const level = (src, path, first) => { + let p = [] + let star = false + const sql = path.reduce((sql, cur, i, arr) => { + if (star) return sql + const id = cur.id || cur + if (id === '*') { + star = true + const where = cur.where + this._from_value = true + sql = `SELECT ${first + ? `json_group_array(${level('value', arr.slice(i + 1))})` + : level('value', arr.slice(i + 1))} FROM json_each((${src})->>${this.string(sql)})${where ? ` WHERE ${this.where(where)}` : ''}` + + this._from_value = undefined + return sql + } + return `${sql}.${JSON.stringify(id)}` + }, '$') + + return star ? sql : sql === '$' ? src : `(${src})->>${this.string(sql)}` + } + + return `(${level(src, ref, true)})` + } + /** * Renders a value into the correct SQL syntax or a placeholder for a prepared statement * @param {import('./infer/cqn').val} param0 diff --git a/db-service/lib/cqn4sql.js b/db-service/lib/cqn4sql.js index 84db6d700..0076d869f 100644 --- a/db-service/lib/cqn4sql.js +++ b/db-service/lib/cqn4sql.js @@ -458,7 +458,10 @@ function cqn4sql(originalQuery, model) { if (leaf.virtual === true) return let baseName - if (col.ref.length >= 2) { + if (leaf instanceof cds.builtin.classes.Map) { + baseName = col.ref[0] + col.as ??= col.ref.at(-1) + } else if (col.ref.length >= 2) { baseName = col.ref .map(idOnly) .slice(col.ref[0] === tableAlias ? 1 : 0, col.ref.length - 1) @@ -1209,7 +1212,7 @@ function cqn4sql(originalQuery, model) { columnAlias = column.as || column.ref.slice(0, -1).map(idOnly).join('_') } else baseName = getFullName(column.$refLinks[column.$refLinks.length - 1].definition) - if(column.element && !isAssocOrStruct(column.element)) { + if (column.element && !isAssocOrStruct(column.element)) { columnAlias = column.as || leafAssocIndex === -1 ? columnAlias : column.ref.slice(leafAssocIndex - 1).map(idOnly).join('_') const res = { ref: [tableAlias, calculateElementName(column)], as: columnAlias } setElementOnColumns(res, column.element) @@ -1336,7 +1339,9 @@ function cqn4sql(originalQuery, model) { }) return flatRefs } - const flatRef = tableAlias ? { ref: [tableAlias, baseName] } : { ref: [baseName] } + const flatRef = element instanceof cds.builtin.classes.Map + ? tableAlias ? { ref: [tableAlias, ...column.ref] } : { ref: [...column.ref] } + : tableAlias ? { ref: [tableAlias, baseName] } : { ref: [baseName] } if (column.cast) { flatRef.cast = column.cast if (!columnAlias) @@ -1345,7 +1350,7 @@ function cqn4sql(originalQuery, model) { } if (column.sort) flatRef.sort = column.sort if (columnAlias) flatRef.as = columnAlias - setElementOnColumns(flatRef, element) + setElementOnColumns(flatRef, column.element ?? element) defineProperty(flatRef, '_csnPath', csnPath) return [flatRef] @@ -1499,7 +1504,7 @@ function cqn4sql(originalQuery, model) { throw new Error(`The operator "${next}" can only be used with scalar operands`) const newTokens = expandComparison(token, ops, rhs, $baseLink) - if(newTokens.length === 0) + if (newTokens.length === 0) throw new Error(`Can't compare two empty structures`) const needXpr = Boolean(tokenStream[i - 1] || tokenStream[indexRhs + 1]) @@ -1534,9 +1539,9 @@ function cqn4sql(originalQuery, model) { const lastAssoc = token.isJoinRelevant && [...token.$refLinks].reverse().find(l => l.definition.isAssociation) const tableAlias = getTableAlias(token, (!lastAssoc?.onlyForeignKeyAccess && lastAssoc) || $baseLink) - if(isAssocOrStruct(definition)) { - const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias }) - if(flat.length === 0) + if (isAssocOrStruct(definition)) { + const flat = getFlatColumnsFor(token, { tableAlias: $baseLink?.alias || tableAlias }) + if (flat.length === 0) throw new Error(`Structured element “${getFullName(definition)}” expands to nothing and can't be used in expressions`) else if (flat.length > 1 && context.prop !== 'list') // only acceptable in `list` throw new Error(`Structured element “${getFullName(definition)}” expands to multiple fields and can't be used in expressions`) @@ -1654,7 +1659,7 @@ function cqn4sql(originalQuery, model) { const leaf = def.$refLinks[def.$refLinks.length - 1] const first = def.$refLinks[0] const tableAlias = getTableAlias(def, def.ref.length > 1 && first.definition.isAssociation ? first : $baseLink) - if (leaf.definition.parent.kind !== 'entity') + if (leaf.definition.parent?.kind !== 'entity') // we need the base name return getFlatColumnsFor(leaf.definition, { baseName: def.ref @@ -1669,7 +1674,7 @@ function cqn4sql(originalQuery, model) { function assertNoStructInXpr(token, context) { const definition = token.$refLinks?.at(-1).definition - if(!definition) return + if (!definition) return const rejectStructs = context && (context.prop in { where: 1, having: 1 }) // unmanaged is always forbidden // expanding a ref in a `where`/`having` context @@ -1748,6 +1753,7 @@ function cqn4sql(originalQuery, model) { let next = $refLinksReverse[i + 1] const nextStep = refReverse[i + 1] // only because we want the filter condition + if (!current) continue // TODO: ensure that it is happening inside a Map if (current.definition.target && next) { const { where, ...args } = nextStep if (isStructured(next.definition)) { @@ -1781,7 +1787,7 @@ function cqn4sql(originalQuery, model) { // OData variant w/o mentioning key if (refReverse[0].where?.length === 1 && refReverse[0].where[0].val) { - filterConditions.push(getTransformedTokenStream(refReverse[0].where,{ $baseLink: $refLinksReverse[0] })) + filterConditions.push(getTransformedTokenStream(refReverse[0].where, { $baseLink: $refLinksReverse[0] })) } if (existingWhere.length > 0) filterConditions.push(existingWhere) @@ -1814,7 +1820,9 @@ function cqn4sql(originalQuery, model) { const subquerySource = getDefinition(transformedFrom.$refLinks[0].definition.target) || transformedFrom.$refLinks[0].target const id = getLocalizedName(subquerySource) - transformedFrom.ref = [subquerySource.params ? { id, args: from.ref.at(-1).args || {} } : id] + transformedFrom.ref = transformedFrom.$refLinks[0].definition instanceof cds.builtin.classes.Map + ? [id, ...transformedFrom.ref.slice(1)] + : [subquerySource.params ? { id, args: from.ref.at(-1).args || {} } : id] return { transformedWhere, transformedFrom } } @@ -2227,19 +2235,19 @@ function cqn4sql(originalQuery, model) { return SELECT } -/** - * For a given search term calculate a search expression which can be used in a where clause. - * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match - * the search results of the subquery. - * - * @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source. - * @param {object} query - The FROM clause of the CQN statement. - * - * @returns {(Object|null)} returns either: - * - an expression of the form ` in (select from where search(, ))` - * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression. - * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself. - */ + /** + * For a given search term calculate a search expression which can be used in a where clause. + * The search function is pushed to a subquery and the primary key(s) of the entity is/are used to match + * the search results of the subquery. + * + * @param {object} searchTerm - The search expression which shall be applied to the searchable columns on the query source. + * @param {object} query - The FROM clause of the CQN statement. + * + * @returns {(Object|null)} returns either: + * - an expression of the form ` in (select from where search(, ))` + * - a function with two arguments: The first one being the list of searchable columns, the second argument holds the search expression. + * - or null, if no searchable columns are found in neither in `@cds.search` nor in the target entity itself. + */ function getSearch(searchTerm, query) { const entity = query.SELECT.from.SELECT ? query.SELECT.from : cds.infer.target(query) // REVISIT: we should reliably use inferred._target instead const searchIn = computeColumnsToBeSearched(inferred, entity) @@ -2255,13 +2263,13 @@ function cqn4sql(originalQuery, model) { // for aggregated queries / search on subqueries we do not do a subquery search if (inferred.SELECT.groupBy || entity.SELECT) return searchFunc - + const matchColumns = getPrimaryKey(entity) if (matchColumns.length === 0 || searchIn.every(r => r.ref.length === 1)) // keyless or not deep, fallback to old behavior return searchFunc - - const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc) - return { xpr: [ matchColumns.length === 1 ? matchColumns[0] : {list: matchColumns}, 'in', subquery] } + + const subquery = SELECT.from(entity).columns(...matchColumns).where(searchFunc) + return { xpr: [matchColumns.length === 1 ? matchColumns[0] : { list: matchColumns }, 'in', subquery] } } /** @@ -2278,7 +2286,7 @@ function cqn4sql(originalQuery, model) { if (!node || !node.$refLinks || !node.ref) { throw new Error('Invalid node') } - if(node.$refLinks[0].$main) { + if (node.$refLinks[0].$main) { if (node.isJoinRelevant) { return getJoinRelevantAlias(node) } @@ -2421,7 +2429,7 @@ function assignQueryModifiers(SELECT, modifiers) { else SELECT.having.push('and', ...val) } else if (key === 'where') { // ignore OData shortcut variant: `… bookshop.Orders:items[2]` - if(!val || val.length === 1 && val[0].val) continue + if (!val || val.length === 1 && val[0].val) continue if (!SELECT.where) SELECT.where = val // infix filter comes first in resulting where else SELECT.where = [...(hasLogicalOr(val) ? [asXpr(val)] : val), 'and', ...(hasLogicalOr(SELECT.where) ? [asXpr(SELECT.where)] : SELECT.where)] diff --git a/db-service/lib/infer/index.js b/db-service/lib/infer/index.js index 7a0c027d8..b61e8b5e3 100644 --- a/db-service/lib/infer/index.js +++ b/db-service/lib/infer/index.js @@ -106,11 +106,14 @@ function infer(originalQuery, model) { if (ref.length > 1) { target = from.ref.slice(1).reduce((d, r) => { const next = getDefinition(d.elements[r.id || r]?.target) || d.elements[r.id || r] - if (!next) throw new Error(`No association “${r.id || r}” in ${d.kind} “${d.name}”`) + if (!next) { + if (d instanceof cds.builtin.classes.Map) return d + throw new Error(`No association “${r.id || r}” in ${d.kind} “${d.name}”`) + } return next }, target) } - if (target.kind !== 'entity' && !target.isAssociation) + if (target.kind !== 'entity' && !target.isAssociation && !(target instanceof cds.builtin.classes.Map)) throw new Error('Query source must be a an entity or an association') inferArg(from, null, null, { inFrom: true, $mainLazyResolve }) @@ -121,7 +124,7 @@ function infer(originalQuery, model) { ? getImplicitAlias(first, useTechnicalAlias) : getImplicitAlias(ref.at(-1).id || ref.at(-1), useTechnicalAlias)) if (alias in querySources) throw new Error(`Duplicate alias "${alias}"`) - querySources[alias] = { definition: getDefinition(target.name), args } + querySources[alias] = { definition: getDefinition(target.name) || target, args } const last = from.$refLinks.at(-1) last.alias = alias } else if (from.args) { @@ -515,6 +518,13 @@ function infer(originalQuery, model) { definition: getDefinitionFromSources(sources, id), target: getDefinitionFromSources(sources, id), }) + } else if (Object.keys($combinedElements).length === 0) { + const definition = new cds.builtin.classes.Map() + definition.type = definition._type + definition.ref = arg.ref + const $refLink = { definition, target: sources[Object.keys(sources)[0]] } + arg.$refLinks.push($refLink) + nameSegments.push(id) } else { stepNotFoundInCombinedElements(id) // REVISIT: fails with {__proto__:elements) } @@ -531,6 +541,9 @@ function infer(originalQuery, model) { ) } + // It is not possible to know what is inside a Map column + if(definition instanceof cds.builtin.classes.Map) break + const target = getDefinition(definition.target) || arg.$refLinks[i - 1].target if (element) { if ($baseLink && inInfixFilter) { @@ -961,6 +974,7 @@ function infer(originalQuery, model) { for (let i = 0; i < column.ref.length; i++) { const ref = column.ref[i] const link = column.$refLinks[i] + if (link.definition instanceof cds.builtin.classes.Map) break if (link.definition.on && link.definition.isAssociation) { if (!column.ref[i + 1]) { if (column.expand && assoc) return true @@ -1088,7 +1102,7 @@ function infer(originalQuery, model) { function getElementForCast(thing) { const { cast, $refLinks } = thing if (!cast) return {} - if ($refLinks?.[$refLinks.length - 1].definition.elements) + if ($refLinks?.[$refLinks.length - 1].definition.elements && !($refLinks?.[$refLinks.length - 1].definition instanceof cds.builtin.classes.Map)) // no cast on structure cds.error`Structured elements can't be cast to a different type` thing.cast = cdsTypes[cast.type] || cast diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index fbf2c0173..1e762c545 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -241,6 +241,55 @@ describe('SELECT', () => { assert.strictEqual(res[0].static.length, 1) }) + test('path expression into Map column', async () => { + const { map } = cds.entities('basic.literals') + await INSERT([ + { map: { a: { b: [{ c: true }, { d: false }] } } }, + { map: { a: { b: [{ c: 1 }, { d: 0 }] } } }, + ]).into(map) + + const obj = await cds.ql`SELECT map.a as extract FROM ${map}` + expect(obj).deep.eq([ + { extract: { b: [{ c: true }, { d: false }] } }, + { extract: { b: [{ c: 1 }, { d: 0 }] } }, + ]) + const arr = await cds.ql`SELECT map.a.b as extract FROM ${map}` + expect(arr).deep.eq([ + { extract: [{ c: true }, { d: false }] }, + { extract: [{ c: 1 }, { d: 0 }] }, + ]) + + // REVISIT: can't cast "d" into :Boolean as the result type is :array + const star = await cds.ql`SELECT map.a.b.![*].d as extract FROM ${map}` + expect(star).deep.eq([ + { extract: [null, 0] }, + { extract: [null, 0] }, + ]) + const infix = await cds.ql`SELECT map.a.b.![*][d != null].d as extract FROM ${map}` + expect(infix).deep.eq([ + { extract: [0] }, + { extract: [0] }, + ]) + + const table = await cds.ql`SELECT c:Boolean, d:Boolean FROM ${map}:map.a.b` + expect(table).deep.eq([ + { c: true, d: null }, + { c: null, d: false }, + { c: true, d: null }, + { c: null, d: false }, + ]) + const where = await cds.ql`SELECT c:Boolean, d:Boolean FROM ${map}:map.a.b WHERE d != null` + expect(where).deep.eq([ + { c: null, d: false }, + { c: null, d: false }, + ]) + const tblfix = await cds.ql`SELECT c:Boolean, d:Boolean FROM ${map}:map.a.b.![*][c != null]` + expect(tblfix).deep.eq([ + { c: true, d: null }, + { c: true, d: null }, + ]) + }) + test.skip('invalid cast (wrong)', async () => { const { globals } = cds.entities('basic.projection') const cqn = cds.ql`SELECT 'String' as ![string] : cds.DoEsNoTeXiSt FROM ${globals}`