Skip to content

Commit

Permalink
feat: Add missing func cqn structures (#629)
Browse files Browse the repository at this point in the history
Not all possible defined combination where covered in regards to `func`
objects. Mostly that `args` can be an `object` and that `func` and `xpr`
can be combined for window functions.
[docs](https://pages.github.tools.sap/cap/docs/cds/cxn#function-calls)

---------

Co-authored-by: Patrice Bender <patrice.bender@sap.com>
Co-authored-by: Johannes Vogel <31311694+johannes-vogel@users.noreply.github.com>
  • Loading branch information
3 people authored May 8, 2024
1 parent a39fb65 commit 9d7539a
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 48 deletions.
31 changes: 27 additions & 4 deletions db-service/lib/cqn2sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -800,8 +800,8 @@ class CQN2SQLRenderer {
if (x.param) return wrap(this.param(x))
if ('ref' in x) return wrap(this.ref(x))
if ('val' in x) return wrap(this.val(x))
if ('xpr' in x) return wrap(this.xpr(x))
if ('func' in x) return wrap(this.func(x))
if ('xpr' in x) return wrap(this.xpr(x))
if ('list' in x) return wrap(this.list(x))
if ('SELECT' in x) return wrap(`(${this.SELECT(x)})`)
else throw cds.error`Unsupported expr: ${x}`
Expand Down Expand Up @@ -936,9 +936,32 @@ class CQN2SQLRenderer {
* @param {import('./infer/cqn').func} param0
* @returns {string} SQL
*/
func({ func, args }) {
args = (args || []).map(e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) }))
return this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
func({ func, args, xpr }) {
const wrap = e => (e === '*' ? e : { __proto__: e, toString: (x = e) => this.expr(x) })
args = args || []
if (Array.isArray(args)) {
args = args.map(wrap)
} else if (typeof args === 'object') {
const org = args
const wrapped = {
toString: () => {
const ret = []
for (const prop in org) {
ret.push(`${this.quote(prop)} => ${wrapped[prop]}`)
}
return ret.join(',')
}
}
for (const prop in args) {
wrapped[prop] = wrap(args[prop])
}
args = wrapped
} else {
cds.error`Invalid arguments provided for function '${func}' (${args})`
}
const fn = this.class.Functions[func]?.apply(this.class.Functions, args) || `${func}(${args})`
if (xpr) return `${fn} ${this.xpr({ xpr })}`
return fn
}

/**
Expand Down
66 changes: 44 additions & 22 deletions db-service/lib/cqn4sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -488,21 +488,23 @@ function cqn4sql(originalQuery, model) {
}

function getTransformedColumn(col) {
if (col.xpr) {
const xpr = { xpr: getTransformedTokenStream(col.xpr) }
if (col.cast) xpr.cast = col.cast
return xpr
} else if (col.func) {
const func = {
let ret
if (col.func) {
ret = {
func: col.func,
args: col.args && getTransformedTokenStream(col.args),
args: getTransformedFunctionArgs(col.args),
as: col.func, // may be overwritten by the explicit alias
}
if (col.cast) func.cast = col.cast
return func
} else {
return copy(col)
}
if (col.xpr) {
ret ??= {}
ret.xpr = getTransformedTokenStream(col.xpr)
}
if (ret) {
if (col.cast) ret.cast = col.cast
return ret
}
return copy(col)
}

function handleEmptyColumns(columns) {
Expand Down Expand Up @@ -537,7 +539,7 @@ function cqn4sql(originalQuery, model) {
} else if (val) {
res = { val }
} else if (func) {
res = { args: getTransformedTokenStream(value.args, baseLink), func: value.func }
res = { args: getTransformedFunctionArgs(value.args, baseLink), func: value.func }
}
if (!omitAlias) res.as = column.as || column.name || column.flatName
return res
Expand Down Expand Up @@ -931,7 +933,7 @@ function cqn4sql(originalQuery, model) {
let transformedColumn
if (col.SELECT) transformedColumn = transformSubquery(col)
else if (col.xpr) transformedColumn = { xpr: getTransformedTokenStream(col.xpr) }
else if (col.func) transformedColumn = { args: getTransformedTokenStream(col.args), func: col.func }
else if (col.func) transformedColumn = { args: getTransformedFunctionArgs(col.args), func: col.func }
// val
else transformedColumn = copy(col)
if (col.sort) transformedColumn.sort = col.sort
Expand Down Expand Up @@ -1427,15 +1429,13 @@ function cqn4sql(originalQuery, model) {
}
} else if (token.SELECT) {
result = transformSubquery(token)
} else if (token.xpr) {
result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
} else if (token.func && token.args) {
result.args = token.args.map(t => {
if (!t.val)
// this must not be touched
return getTransformedTokenStream([t], $baseLink)[0]
return t
})
} else {
if (token.xpr) {
result.xpr = getTransformedTokenStream(token.xpr, $baseLink)
}
if (token.func && token.args) {
result.args = getTransformedFunctionArgs(token.args, $baseLink)
}
}

transformedTokenStream.push(result)
Expand Down Expand Up @@ -2142,6 +2142,27 @@ function cqn4sql(originalQuery, model) {
return getLastStringSegment(inferred.$combinedElements[node.ref[0].id || node.ref[0]]?.[0].index)
}
}
function getTransformedFunctionArgs(args, $baseLink = null) {
let result = null
if (Array.isArray(args)) {
result = args.map(t => {
if (!t.val)
// this must not be touched
return getTransformedTokenStream([t], $baseLink)[0]
return t
})
} else if (typeof args === 'object') {
result = {}
for (const prop in args) {
const t = args[prop]
if (!t.val)
// this must not be touched
result[prop] = getTransformedTokenStream([t], $baseLink)[0]
else result[prop] = t
}
}
return result
}
}

module.exports = Object.assign(cqn4sql, {
Expand Down Expand Up @@ -2226,6 +2247,7 @@ function setElementOnColumns(col, element) {
writable: true,
})
}

const getName = col => col.as || col.ref?.at(-1)
const idOnly = ref => ref.id || ref
const is_regexp = x => x?.constructor?.name === 'RegExp' // NOTE: x instanceof RegExp doesn't work in repl
53 changes: 36 additions & 17 deletions db-service/lib/infer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ function infer(originalQuery, model) {
} else if (from.SELECT) {
const subqueryInFrom = infer(from, model) // we need the .elements in the sources
// if no explicit alias is provided, we make up one
const subqueryAlias = from.as || subqueryInFrom.joinTree.addNextAvailableTableAlias('__select__', subqueryInFrom.outerQueries)
const subqueryAlias =
from.as || subqueryInFrom.joinTree.addNextAvailableTableAlias('__select__', subqueryInFrom.outerQueries)
querySources[subqueryAlias] = { definition: from }
} else if (typeof from === 'string') {
// TODO: Create unique alias, what about duplicates?
Expand Down Expand Up @@ -165,7 +166,7 @@ function infer(originalQuery, model) {
function attachRefLinksToArg(arg, $baseLink = null, expandOrExists = false) {
const { ref, xpr, args, list } = arg
if (xpr) xpr.forEach(t => attachRefLinksToArg(t, $baseLink, expandOrExists))
if (args) args.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
if (args) applyToFunctionArgs(args, attachRefLinksToArg, [$baseLink, expandOrExists])
if (list) list.forEach(arg => attachRefLinksToArg(arg, $baseLink, expandOrExists))
if (!ref) return
init$refLinks(arg)
Expand Down Expand Up @@ -305,10 +306,15 @@ function infer(originalQuery, model) {
if (queryElements[as]) cds.error`Duplicate definition of element “${as}”`
if (col.xpr || col.SELECT) {
queryElements[as] = getElementForXprOrSubquery(col)
} else if (col.func) {
col.args?.forEach(arg => inferQueryElement(arg, false)) // {func}.args are optional
}
if (col.func) {
if (col.args) {
// {func}.args are optional
applyToFunctionArgs(col.args, inferQueryElement, [false])
}
queryElements[as] = getElementForCast(col)
} else {
}
if (!queryElements[as]) {
// either binding parameter (col.param) or value
queryElements[as] = col.cast ? getElementForCast(col) : getCdsTypeForVal(col.val)
}
Expand Down Expand Up @@ -494,7 +500,9 @@ function infer(originalQuery, model) {
function inferQueryElement(column, insertIntoQueryElements = true, $baseLink = null, context) {
const { inExists, inExpr, inCalcElement, baseColumn, inInfixFilter } = context || {}
if (column.param || column.SELECT) return // parameter references are only resolved into values on execution e.g. :val, :1 or ?
if (column.args) column.args.forEach(arg => inferQueryElement(arg, false, $baseLink, context)) // e.g. function in expression
if (column.args) {
applyToFunctionArgs(column.args, inferQueryElement, [false, $baseLink, context])
}
if (column.list) column.list.forEach(arg => inferQueryElement(arg, false, $baseLink, context))
if (column.xpr)
column.xpr.forEach(token => inferQueryElement(token, false, $baseLink, { ...context, inExpr: true })) // e.g. function in expression
Expand Down Expand Up @@ -631,13 +639,13 @@ function infer(originalQuery, model) {
inInfixFilter: true,
})
} else if (token.func) {
token.args?.forEach(arg =>
inferQueryElement(arg, false, column.$refLinks[i], {
inExists: skipJoinsForFilter,
inExpr: true,
inInfixFilter: true,
}),
)
if (token.args) {
applyToFunctionArgs(token.args, inferQueryElement, [
false,
column.$refLinks[i],
{ inExists: skipJoinsForFilter, inExpr: true, inInfixFilter: true },
])
}
}
})
}
Expand Down Expand Up @@ -885,7 +893,7 @@ function infer(originalQuery, model) {
const calcElement = column.$refLinks?.[column.$refLinks.length - 1].definition || column
if (alreadySeenCalcElements.has(calcElement)) return
else alreadySeenCalcElements.add(calcElement)
const { ref, xpr, func } = calcElement.value
const { ref, xpr } = calcElement.value
if (ref || xpr) {
baseLink = baseLink || { definition: calcElement.parent, target: calcElement.parent }
attachRefLinksToArg(calcElement.value, baseLink, true)
Expand All @@ -899,8 +907,9 @@ function infer(originalQuery, model) {
}
mergePathsIntoJoinTree(calcElement.value, basePath)
}
if (func)
calcElement.value.args?.forEach(arg => {

if (calcElement.value.args) {
const processArgument = (arg, calcElement, column) => {
inferQueryElement(
arg,
false,
Expand All @@ -912,7 +921,12 @@ function infer(originalQuery, model) {
? { $refLinks: column.$refLinks.slice(0, -1), ref: column.ref.slice(0, -1) }
: { $refLinks: [], ref: [] }
mergePathsIntoJoinTree(arg, basePath)
}) // {func}.args are optional
}

if (calcElement.value.args) {
applyToFunctionArgs(calcElement.value.args, processArgument, [calcElement, column])
}
}

/**
* Calculates all paths from a given ref and merges them into the join tree.
Expand Down Expand Up @@ -1202,4 +1216,9 @@ function isForeignKeyOf(e, assoc) {
}
const idOnly = ref => ref.id || ref

function applyToFunctionArgs(funcArgs, cb, cbArgs) {
if (Array.isArray(funcArgs)) funcArgs.forEach(arg => cb(arg, ...cbArgs))
else if (typeof funcArgs === 'object') Object.keys(funcArgs).forEach(prop => cb(funcArgs[prop], ...cbArgs))
}

module.exports = infer
1 change: 1 addition & 0 deletions db-service/test/bookshop/db/booksWithExpr.cds
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ entity Authors {
dateOfDeath : Date;

age: Integer = years_between(dateOfBirth, dateOfDeath);
ageNamedParams: Integer = years_between(DOB => dateOfBirth, DOD => dateOfDeath);

books : Association to many Books on books.author = $self;
address : Association to Addresses;
Expand Down
44 changes: 43 additions & 1 deletion db-service/test/cqn2sql/function.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const cds = require('@sap/cds/lib')
const _cqn2sql = require('../../lib/cqn2sql')
function cqn2sql(q, m = cds.model) {
return _cqn2sql(q, m)
}
}

beforeAll(async () => {
cds.model = await cds.load(__dirname + '/testModel').then(cds.linked)
Expand Down Expand Up @@ -211,4 +211,46 @@ describe('function', () => {
const { sql } = cqn2sql(cqn)
expect(sql).toEqual('SELECT Foo.ID,Foo.a,Foo.b,Foo.c,Foo.x FROM Foo as Foo WHERE current_date')
})

test('fn with named arguments', () => {
const func = {
func: 'convert_currency',
args: {
amount: { ref: ['a'] },
source_unit: { ref: ['b'] },
target_unit: { val: 'USD' },
}
}
const cqn = {
SELECT: {
columns: [func],
from: { ref: ['Foo'] },
where: [func],
},
}

const { sql, values } = cqn2sql(cqn)
expect({ sql, values }).toEqual({
sql: 'SELECT convert_currency(amount => Foo.a,source_unit => Foo.b,target_unit => ?) as convert_currency FROM Foo as Foo WHERE convert_currency(amount => Foo.a,source_unit => Foo.b,target_unit => ?)',
values: ['USD', 'USD'],
})
})

test('fn with xpr extension', () => {
const cqn = {
SELECT: {
from: { ref: ['Foo'] },
columns: [{
func: 'row_number',
args: [],
xpr: ['over', { xpr: ['partition', 'by', { ref: ['a'] }] }]
}]
},
}

const { sql } = cqn2sql(cqn)
expect({ sql }).toEqual({
sql: 'SELECT row_number() over (partition by Foo.a) as row_number FROM Foo as Foo',
})
})
})
13 changes: 9 additions & 4 deletions db-service/test/cqn4sql/calculated-elements.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ describe('Unfolding calculated elements in select list', () => {
}`
expect(query).to.deep.equal(expected)
})
it('in function with named param', () => {
let query = cqn4sql(CQL`SELECT from booksCalc.Authors { ID, ageNamedParams as f }`, model)
const expected = CQL`SELECT from booksCalc.Authors as Authors {
Authors.ID,
years_between(DOB => Authors.dateOfBirth, DOD => Authors.dateOfDeath) as f
}`
expect(query).to.deep.equal(expected)
})

it('calc elem is function', () => {
let query = cqn4sql(CQL`SELECT from booksCalc.Books { ID, ctitle }`, model)
Expand Down Expand Up @@ -510,10 +518,7 @@ describe('Unfolding calculated elements in select list', () => {
})

it('wildcard select from subquery', () => {
let query = cqn4sql(
CQL`SELECT from ( SELECT FROM booksCalc.Simple { * } )`,
model,
)
let query = cqn4sql(CQL`SELECT from ( SELECT FROM booksCalc.Simple { * } )`, model)
const expected = CQL`
SELECT from (
SELECT from booksCalc.Simple as Simple
Expand Down
Loading

0 comments on commit 9d7539a

Please sign in to comment.