Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

perf: HANA list placeholder #380

Merged
merged 12 commits into from
Dec 18, 2023
89 changes: 43 additions & 46 deletions db-service/lib/cqn2sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class CQN2SQLRenderer {
this.class._init() // is a noop for subsequent calls
}

static _add_mixins (aspect, mixins) {
static _add_mixins(aspect, mixins) {
const fqn = this.name + aspect
const types = cds.builtin.types
for (let each in mixins) {
Expand All @@ -52,7 +52,7 @@ class CQN2SQLRenderer {
this.ReservedWords[each[0] + each.slice(1).toLowerCase()] = 1 // Order
this.ReservedWords[each.toLowerCase()] = 1 // order
}
this._init = () => {} // makes this a noop for subsequent calls
this._init = () => { } // makes this a noop for subsequent calls
}

/**
Expand Down Expand Up @@ -208,7 +208,7 @@ class CQN2SQLRenderer {
if (limit) sql += ` LIMIT ${this.limit(limit)}`
// Expand cannot work without an inferred query
if (expand) {
if ('elements' in q) sql = this.SELECT_expand (q,sql)
if ('elements' in q) sql = this.SELECT_expand(q, sql)
else cds.error`Query was not inferred and includes expand. For which the metadata is missing.`
}
return (this.sql = sql)
Expand Down Expand Up @@ -249,7 +249,7 @@ class CQN2SQLRenderer {
// Prevent SQLite from hitting function argument limit of 100
let obj = ''

if(cols.length < 50) obj = `json_object(${cols.slice(0, 50)})`
if (cols.length < 50) obj = `json_object(${cols.slice(0, 50)})`
else {
const chunks = []
for (let i = 0; i < cols.length; i += 50) {
Expand Down Expand Up @@ -342,9 +342,9 @@ class CQN2SQLRenderer {
return orderBy.map(
localized
? c =>
this.expr(c) +
(c.element?.[this.class._localized] ? ' COLLATE NOCASE' : '') +
(c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
this.expr(c) +
(c.element?.[this.class._localized] ? ' COLLATE NOCASE' : '') +
(c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC')
: c => this.expr(c) + (c.sort === 'desc' || c.sort === -1 ? ' DESC' : ' ASC'),
)
}
Expand Down Expand Up @@ -372,12 +372,12 @@ class CQN2SQLRenderer {
return INSERT.entries
? this.INSERT_entries(q)
: INSERT.rows
? this.INSERT_rows(q)
: INSERT.values
? this.INSERT_values(q)
: INSERT.as
? this.INSERT_select(q)
: cds.error`Missing .entries, .rows, or .values in ${q}`
? this.INSERT_rows(q)
: INSERT.values
? this.INSERT_values(q)
: INSERT.as
? this.INSERT_select(q)
: cds.error`Missing .entries, .rows, or .values in ${q}`
}

/**
Expand Down Expand Up @@ -427,9 +427,8 @@ class CQN2SQLRenderer {
// Include this.values for placeholders
/** @type {unknown[][]} */
this.entries = [[...this.values, JSON.stringify(INSERT.entries)]]
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
this.columns
}) SELECT ${extraction} FROM json_each(?)`)
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
}) SELECT ${extraction} FROM json_each(?)`)
}

/**
Expand All @@ -443,21 +442,20 @@ class CQN2SQLRenderer {
const alias = INSERT.into.as
const elements = q.elements || q.target?.elements
const columns = INSERT.columns
|| cds.error`Cannot insert rows without columns or elements`
|| cds.error`Cannot insert rows without columns or elements`

const inputConverter = this.class._convertInput
const extraction = columns.map((c,i) => {
const extraction = columns.map((c, i) => {
const extract = `value->>'$[${i}]'`
const element = elements?.[c]
const converter = element?.[inputConverter]
return converter?.(extract,element) || extract
return converter?.(extract, element) || extract
})

this.columns = columns.map(c => this.quote(c))
this.entries = [[JSON.stringify(INSERT.rows)]]
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${
this.columns
}) SELECT ${extraction} FROM json_each(?)`)
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns
}) SELECT ${extraction} FROM json_each(?)`)
}

/**
Expand Down Expand Up @@ -552,9 +550,9 @@ class CQN2SQLRenderer {
if (entity.as) sql += ` AS ${entity.as}`

let columns = []
if (data) _add (data, val => this.val({val}))
if (_with) _add (_with, x => this.expr(x))
function _add (data, sql4) {
if (data) _add(data, val => this.val({ val }))
if (_with) _add(_with, x => this.expr(x))
function _add(data, sql4) {
for (let c in data) {
if (!elements || (c in elements && !elements[c].virtual)) {
columns.push({ name: c, sql: sql4(data[c]) })
Expand Down Expand Up @@ -602,8 +600,8 @@ class CQN2SQLRenderer {
return STREAM.from
? this.STREAM_from(q)
: STREAM.into
? this.STREAM_into(q)
: cds.error`Missing .form or .into in ${q}`
? this.STREAM_into(q)
: cds.error`Missing .form or .into in ${q}`
}

/**
Expand Down Expand Up @@ -668,7 +666,7 @@ class CQN2SQLRenderer {
expr(x) {
const wrap = x.cast ? sql => `cast(${sql} as ${this.type4(x.cast)})` : sql => sql
if (typeof x === 'string') throw cds.error`Unsupported expr: ${x}`
if ('param' in x) return wrap(this.param(x))
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))
Expand Down Expand Up @@ -704,15 +702,15 @@ class CQN2SQLRenderer {
operator(x, i, xpr) {

// Translate = to IS NULL for rhs operand being NULL literal
if (x === '=') return xpr[i+1]?.val === null ? 'is' : '='
if (x === '=') return xpr[i + 1]?.val === null ? 'is' : '='

// Translate == to IS NOT NULL for rhs operand being NULL literal, otherwise ...
// Translate == to IS NOT DISTINCT FROM, unless both operands cannot be NULL
if (x === '==') return xpr[i+1]?.val === null ? 'is' : _not_null(i-1) && _not_null(i+1) ? '=' : this.is_not_distinct_from_
if (x === '==') return xpr[i + 1]?.val === null ? 'is' : _not_null(i - 1) && _not_null(i + 1) ? '=' : this.is_not_distinct_from_

// Translate != to IS NULL for rhs operand being NULL literal, otherwise...
// Translate != to IS DISTINCT FROM, unless both operands cannot be NULL
if (x === '!=') return xpr[i+1]?.val === null ? 'is not' : _not_null(i-1) && _not_null(i+1) ? '<>' : this.is_distinct_from_
if (x === '!=') return xpr[i + 1]?.val === null ? 'is not' : _not_null(i - 1) && _not_null(i + 1) ? '<>' : this.is_distinct_from_

else return x

Expand Down Expand Up @@ -749,9 +747,9 @@ class CQN2SQLRenderer {
*/
ref({ ref }) {
switch (ref[0]) {
case '$now': return this.func({ func: 'session_context', args: [{ val: '$now' }]})
case '$now': return this.func({ func: 'session_context', args: [{ val: '$now', param: false }] })
case '$user':
case '$user.id': return this.func({ func: 'session_context', args: [{ val: '$user.id' }]})
case '$user.id': return this.func({ func: 'session_context', args: [{ val: '$user.id', param: false }] })
default: return ref.map(r => this.quote(r)).join('.')
}
}
Expand All @@ -761,7 +759,7 @@ class CQN2SQLRenderer {
* @param {import('./infer/cqn').val} param0
* @returns {string} SQL
*/
val({ val }) {
val({ val, param }) {
switch (typeof val) {
case 'function': throw new Error('Function values not supported.')
case 'undefined': return 'NULL'
Expand All @@ -770,13 +768,13 @@ class CQN2SQLRenderer {
case 'object':
if (val === null) return 'NULL'
if (val instanceof Date) return `'${val.toISOString()}'`
if (val instanceof Readable) ; // go on with default below
if (val instanceof Readable); // go on with default below
else if (Buffer.isBuffer(val)) val = val.toString('base64')
else if (is_regexp(val)) val = val.source
else val = JSON.stringify(val)
case 'string': // eslint-disable-line no-fallthrough
}
if (!this.values) return this.string(val)
if (!this.values || param === false) return this.string(val)
else this.values.push(val)
return '?'
}
Expand Down Expand Up @@ -860,12 +858,12 @@ class CQN2SQLRenderer {
const requiredColumns = !elements
? []
: Object.keys(elements)
.filter(
e =>
(elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
!columns.find(c => c.name === e),
)
.map(name => ({ name, sql: 'NULL' }))
.filter(
e =>
(elements[e]?.[annotation] || (!isUpdate && elements[e]?.default && !elements[e].virtual && !elements[e].isAssociation)) &&
!columns.find(c => c.name === e),
)
.map(name => ({ name, sql: 'NULL' }))

return [...columns, ...requiredColumns].map(({ name, sql }) => {
let element = elements?.[name] || {}
Expand All @@ -875,14 +873,13 @@ class CQN2SQLRenderer {
if (converter && sql[0] !== '$') sql = converter(sql, element)

let val = _managed[element[annotation]?.['=']]
if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val }] })})`
if (val) sql = `coalesce(${sql}, ${this.func({ func: 'session_context', args: [{ val, param: false }] })})`
else if (!isUpdate && element.default) {
const d = element.default
if (d.val !== undefined || d.ref?.[0] === '$now') {
// REVISIT: d.ref is not used afterwards
sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${
this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
} ELSE ${sql} END)`
sql = `(CASE WHEN json_type(value,'$."${name}"') IS NULL THEN ${this.defaultValue(d.val) // REVISIT: this.defaultValue is a strange function
} ELSE ${sql} END)`
}
}

Expand Down
41 changes: 32 additions & 9 deletions hana/lib/HANAService.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const fs = require('fs')
const path = require('path')
const { Readable } = require('stream')

Check warning on line 3 in hana/lib/HANAService.js

View workflow job for this annotation

GitHub Actions / HANA Node.js 18

'Readable' is assigned a value but never used

Check warning on line 3 in hana/lib/HANAService.js

View workflow job for this annotation

GitHub Actions / Node.js 18

'Readable' is assigned a value but never used

const { SQLService } = require('@cap-js/db-service')
const drivers = require('./drivers')
Expand Down Expand Up @@ -102,10 +102,10 @@
// REVISIT: disable this for queries like (SELECT 1)
// Will return multiple rows with objects inside
query.SELECT.expand = 'root'
const { cqn, temporary, blobs } = this.cqn2sql(query, data)
const { cqn, temporary, blobs, values } = this.cqn2sql(query, data)
// REVISIT: add prepare options when param:true is used
const sqlScript = this.wrapTemporary(temporary, blobs)
let rows = await this.exec(sqlScript)
let rows = values?.length ? await (await this.prepare(sqlScript)).all(values) : await this.exec(sqlScript)
if (rows.length) {
rows = this.parseRows(rows)
}
Expand Down Expand Up @@ -264,17 +264,17 @@

SELECT(q) {
// Collect all queries and blob columns of all queries
this.temporary = this.temporary || []
this.blobs = this.blobs || []
this.temporary = this.temporary || []
this.temporaryValues = this.temporaryValues || []

const src = q

const { limit, one, orderBy, expand, columns, localized, count, from, parent } = q.SELECT

Check warning on line 273 in hana/lib/HANAService.js

View workflow job for this annotation

GitHub Actions / HANA Node.js 18

'from' is assigned a value but never used

Check warning on line 273 in hana/lib/HANAService.js

View workflow job for this annotation

GitHub Actions / Node.js 18

'from' is assigned a value but never used

// When one of these is defined wrap the query in a sub query
if (expand || (parent && (limit || one || orderBy))) {
const { element, elements } = q
if (expand === 'root') this.values = undefined

q = cds.ql.clone(q)
if (parent) {
Expand Down Expand Up @@ -330,11 +330,11 @@
? [
{
func: 'concat',
args: [{ ref: ['_path_'] }, { val: `].${q.element.name}[` }],
args: [{ ref: ['_path_'] }, { val: `].${q.element.name}[`, param: false }],
},
{ func: 'lpad', args: [{ ref: ['$$RN$$'] }, { val: 6 }, { val: '0' }] },
{ func: 'lpad', args: [{ ref: ['$$RN$$'] }, { val: 6, param: false }, { val: '0', param: false }] },
]
: [{ val: '$[' }, { func: 'lpad', args: [{ ref: ['$$RN$$'] }, { val: 6 }, { val: '0' }] }],
: [{ val: '$[', param: false }, { func: 'lpad', args: [{ ref: ['$$RN$$'] }, { val: 6, param: false }, { val: '0', param: false }] }],
},
],
as: '_path_',
Expand All @@ -345,7 +345,7 @@
// Apply row number limits
q.where(
one
? [{ ref: ['$$RN$$'] }, '=', { val: 1 }]
? [{ ref: ['$$RN$$'] }, '=', { val: 1, param: false }]
: limit.offset?.val
? [
{ ref: ['$$RN$$'] },
Expand Down Expand Up @@ -376,6 +376,10 @@
if (expand === 'root') {
this.cqn = q
this.temporary.unshift({ blobs: this._blobs, select: this.sql.substring(7) })
if (this.values) {
this.temporaryValues.unshift(this.values)
this.values = this.temporaryValues.flat()
}
}

return this.sql
Expand Down Expand Up @@ -416,8 +420,11 @@
x.SELECT.expand = 'root'
x.SELECT.parent = parent

const values = this.values
this.values = []
parent.SELECT.expand = true
this.SELECT(x)
this.values = values
return false
}
if (x.element?.type?.indexOf('Binary') > -1) {
Expand Down Expand Up @@ -764,6 +771,18 @@
else return x
}

list(list) {
const first = list.list[0]
// If the list only contains of lists it is replaced with a json function and a placeholder
if (this.values && first.list && !first.list.find(v => !v.val)) {
const extraction = first.list.map((v, i) => `"${i}" ${this.constructor.InsertTypeMap[typeof v.val]()} PATH '$.${i}'`)
this.values.push(JSON.stringify(list.list.map(l => l.list.reduce((l, c, i) => { l[i] = c.val; return l }, {}))))
return `(SELECT * FROM JSON_TABLE(?, '$' COLUMNS(${extraction})))`
}
// Call super for normal SQL behavior
return super.list(list)
}

quote(s) {
// REVISIT: casing in quotes when reading from entities it uppercase
// When returning columns from a query they should be case sensitive
Expand Down Expand Up @@ -811,7 +830,7 @@
const converter = (sql !== '?' && element[inputConverterKey]) || (e => e)
const val = _managed[element[annotation]?.['=']]
let managed
if (val) managed = this.func({ func: 'session_context', args: [{ val }] })
if (val) managed = this.func({ func: 'session_context', args: [{ val, param: false }] })
const type = this.insertType4(element)
let extract = sql ?? `${this.quote(name)} ${type} PATH '$.${name}'`
if (!isUpdate) {
Expand Down Expand Up @@ -868,6 +887,10 @@
LargeBinary: () => `NVARCHAR(2147483647)`,
Binary: () => `NVARCHAR(2147483647)`,
array: () => `NVARCHAR(2147483647)`,

// Javascript types
string: () => `NVARCHAR(2147483647)`,
number: () => `DOUBLE`
}

// HANA JSON_TABLE function does not support BOOLEAN types
Expand Down
8 changes: 8 additions & 0 deletions hana/lib/cql-functions.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
const isTime = /^\d{1,2}:\d{1,2}:\d{1,2}$/
const isVal = x => x && 'val' in x
const getTimeType = x => isTime.test(x.val) ? 'TIME' : 'TIMESTAMP'
const getTimeCast = x => isVal(x) ? `TO_${getTimeType(x)}(${x})` : x

const StandardFunctions = {
tolower: x => `lower(${x})`,
toupper: x => `upper(${x})`,
Expand Down Expand Up @@ -25,6 +30,9 @@ const StandardFunctions = {

// Date and Time Functions
day: x => `DAYOFMONTH(${x})`,
hour: x => `HOUR(${getTimeCast(x)})`,
minute: x => `MINUTE(${getTimeCast(x)})`,
second: x => `SECOND(${getTimeCast(x)})`
}

module.exports = StandardFunctions
Loading