Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions db-service/lib/cqn2sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,9 @@
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)})`)
Expand Down Expand Up @@ -1257,14 +1260,51 @@
* @param {import('./infer/cqn').ref} param0
* @returns {string} SQL
*/
ref({ ref }) {
ref(org) {
const { ref, element, _from } = org

Check warning on line 1264 in db-service/lib/cqn2sql.js

View workflow job for this annotation

GitHub Actions / Tests (22)

'_from' is assigned a value but never used
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 = []

Check warning on line 1283 in db-service/lib/cqn2sql.js

View workflow job for this annotation

GitHub Actions / Tests (22)

'p' is assigned a value but never used
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
Expand Down
70 changes: 39 additions & 31 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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]

Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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 `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
* - 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 `<primaryKey> in (select <primaryKey> from <entity> where search(<searchableColumns>, <searchTerm>))`
* - 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)
Expand All @@ -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] }
}

/**
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)]
Expand Down
22 changes: 18 additions & 4 deletions db-service/lib/infer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions test/compliance/SELECT.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>
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}`
Expand Down
Loading