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

fix: decimal format #852

Closed
wants to merge 46 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
099bf57
support defaults for upsert
SamuelBrucksch Jan 24, 2024
25ab37f
fix test
SamuelBrucksch Jan 24, 2024
5c4e0d9
Add default values test to compliance suite
BobdenOs Jan 24, 2024
18ec794
Make sqlite upsert work with insert and default values
BobdenOs Jan 29, 2024
ed6c0d1
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Feb 20, 2024
50d02c8
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Feb 20, 2024
c5d6886
Unify managed for all services
BobdenOs Feb 21, 2024
d2ab1bf
Adjust compliance model to possible defaults
BobdenOs Feb 21, 2024
79d6bd5
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Feb 21, 2024
1000b7c
Update snapshots
BobdenOs Feb 21, 2024
52f95fe
Ignore decimal precision in input converters
BobdenOs Feb 21, 2024
096c21c
Merge branch 'main' into upsert-defaults
BobdenOs Mar 12, 2024
a5a48e1
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Jun 14, 2024
fd8be9b
Update default expectations and create validation
BobdenOs Jun 14, 2024
3afb724
Remove linting errors
BobdenOs Jun 14, 2024
cbcf722
Align with main state
BobdenOs Jun 14, 2024
8a71a1a
Update snapshots
BobdenOs Jun 14, 2024
bc9ac10
Merge branch 'main' into upsert-defaults
BobdenOs Jun 17, 2024
e9be968
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Aug 5, 2024
d0f414e
Merge branch 'upsert-defaults' of https://github.com/cap-js/cds-dbs i…
BobdenOs Aug 5, 2024
d2d0a81
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Sep 2, 2024
4a4ab23
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Sep 5, 2024
72f161e
Consider default values for key columns for UPSERT join
BobdenOs Sep 6, 2024
1ee2b29
Update snapshot for UPSERT with rows
BobdenOs Sep 6, 2024
726640d
Missed typo
BobdenOs Sep 6, 2024
0c8088f
Improve values/row for INSERT and UPSERT
BobdenOs Sep 9, 2024
84ba9f6
Ensure entries has higher priority then rows and values
BobdenOs Sep 9, 2024
0cc62e8
Update snapshot for upsert rows
BobdenOs Sep 9, 2024
41e9ac5
Include HANA default values for UPSERT existance join
BobdenOs Sep 9, 2024
fe2576b
Include keywords test into compliance index
BobdenOs Sep 9, 2024
0f12a17
move cds.test into descibe as it should be
BobdenOs Sep 9, 2024
64e9768
Fix required field for UPSERT.rows
BobdenOs Sep 9, 2024
a92b67f
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Oct 1, 2024
f01c8e3
Attempt to only skip default key upsert test
BobdenOs Oct 1, 2024
3beddbb
Merge branch 'main' into upsert-defaults
BobdenOs Oct 7, 2024
0b84a49
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Oct 15, 2024
7e9661a
Allow input converters to determine whether they should apply to plac…
BobdenOs Oct 15, 2024
0647e3f
Change default decimal value to remove rounding confusion
BobdenOs Oct 15, 2024
4f69e6e
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Oct 15, 2024
f9763ec
Completely skip Spatial types from compliance suite
BobdenOs Oct 15, 2024
4049dba
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Oct 15, 2024
d349fe6
add test
johannes-vogel Oct 18, 2024
124d8b7
Experimental native deep insert
BobdenOs Oct 18, 2024
edc0b55
Merge branch 'fix-decimals' of https://github.com/cap-js/cds-dbs into…
BobdenOs Oct 18, 2024
0704a64
Adjust HANA formatter to always have a whole number
BobdenOs Oct 18, 2024
a128494
Revert "Experimental native deep insert"
BobdenOs Oct 18, 2024
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
202 changes: 129 additions & 73 deletions db-service/lib/cqn2sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,15 @@ class CQN2SQLRenderer {
*/
CREATE_elements(elements) {
let sql = ''
let keys = ''
for (let e in elements) {
const definition = elements[e]
if (definition.isAssociation) continue
if (definition.key) keys = `${keys}, ${this.quote(definition.name)}`
const s = this.CREATE_element(definition)
if (s) sql += `${s}, `
if (s) sql += `, ${s}`
}
return sql.slice(0, -2)
return `${sql.slice(2)}${keys && `, PRIMARY KEY(${keys.slice(2)})`}`
}

/**
Expand Down Expand Up @@ -491,8 +493,6 @@ class CQN2SQLRenderer {
*/
INSERT_entries(q) {
const { INSERT } = q
const entity = this.name(q.target?.name || INSERT.into.ref[0])
const alias = INSERT.into.as
const elements = q.elements || q.target?.elements
if (!elements && !INSERT.entries?.length) {
return // REVISIT: mtx sends an insert statement without entries and no reference entity
Expand All @@ -504,19 +504,14 @@ class CQN2SQLRenderer {
/** @type {string[]} */
this.columns = columns

const alias = INSERT.into.as
const entity = this.name(q.target?.name || INSERT.into.ref[0])
if (!elements) {
this.entries = INSERT.entries.map(e => columns.map(c => e[c]))
const param = this.param.bind(this, { ref: ['?'] })
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))}) VALUES (${columns.map(param)})`)
}

const extractions = this.managed(
columns.map(c => ({ name: c })),
elements,
!!q.UPSERT,
)
const extraction = extractions.map(c => c.sql)

// Include this.values for placeholders
/** @type {unknown[][]} */
this.entries = []
Expand All @@ -530,8 +525,9 @@ class CQN2SQLRenderer {
this.entries = [[...this.values, stream]]
}

const extractions = this._managed = this.managed(columns.map(c => ({ name: c })), elements)
return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
}) SELECT ${extraction} FROM json_each(?)`)
}) SELECT ${extractions.map(c => c.insert)} FROM json_each(?)`)
}

async *INSERT_entries_stream(entries, binaryEncoding = 'base64') {
Expand Down Expand Up @@ -646,18 +642,7 @@ class CQN2SQLRenderer {
const entity = this.name(q.target?.name || INSERT.into.ref[0])
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`

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

this.columns = columns
const columns = this.columns = INSERT.columns || cds.error`Cannot insert rows without columns or elements`

if (!elements) {
this.entries = INSERT.rows
Expand All @@ -675,6 +660,10 @@ class CQN2SQLRenderer {
this.entries = [[...this.values, stream]]
}

const extraction = (this._managed = this.managed(columns.map(c => ({ name: c })), elements))
.slice(0, columns.length)
.map(c => c.converter(c.extract))

return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
}) SELECT ${extraction} FROM json_each(?)`)
}
Expand All @@ -686,7 +675,7 @@ class CQN2SQLRenderer {
*/
INSERT_values(q) {
let { columns, values } = q.INSERT
return this.INSERT_rows({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
return this.render({ __proto__: q, INSERT: { __proto__: q.INSERT, columns, rows: [values] } })
}

/**
Expand Down Expand Up @@ -737,14 +726,37 @@ class CQN2SQLRenderer {
*/
UPSERT(q) {
const { UPSERT } = q
const elements = q.target?.elements || {}

let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
let keys = q.target?.keys
if (!keys) return this.sql = sql
keys = Object.keys(keys).filter(k => !keys[k].isAssociation && !keys[k].virtual)
if (!q.target?.keys) return sql
const keys = []
for (const k of ObjectKeys(q.target?.keys)) {
const element = q.target.keys[k]
if (element.isAssociation || element.virtual) continue
keys.push(k)
}

const elements = q.target?.elements || {}
// temporal data
for (const k of ObjectKeys(elements)) {
if (elements[k]['@cds.valid.from']) keys.push(k)
}

let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
updateColumns = updateColumns.filter(c => {
const keyCompare = keys
.map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
.join(' AND ')

const columns = this.columns // this.columns is computed as part of this.INSERT
const managed = this._managed.slice(0, columns.length)

const extractkeys = managed
.filter(c => keys.includes(c.name))
.map(c => `${c.onInsert || c.sql} as ${this.quote(c.name)}`)

const entity = this.name(q.target?.name || UPSERT.into.ref[0])
sql = `SELECT ${managed.map(c => c.upsert)} FROM (SELECT value, ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`

const updateColumns = columns.filter(c => {
if (keys.includes(c)) return false //> keys go into ON CONFLICT clause
let e = elements[c]
if (!e) return true //> pass through to native SQL columns not in CDS model
Expand All @@ -754,14 +766,8 @@ class CQN2SQLRenderer {
else return true
}).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`)

// temporal data
keys.push(...Object.values(q.target.elements).filter(e => e['@cds.valid.from']).map(e => e.name))

keys = keys.map(k => this.quote(k))
const conflict = updateColumns.length
? `ON CONFLICT(${keys}) DO UPDATE SET ` + updateColumns
: `ON CONFLICT(${keys}) DO NOTHING`
return (this.sql = `${sql} WHERE true ${conflict}`)
return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(c))}) ${sql
} WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`)
}

// UPDATE Statements ------------------------------------------------
Expand Down Expand Up @@ -790,7 +796,9 @@ class CQN2SQLRenderer {
}
}

const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
const extraction = this.managed(columns, elements)
.filter((c, i) => columns[i] || c.onUpdate)
.map((c, i) => `${this.quote(c.name)}=${!columns[i] ? c.onUpdate : c.sql}`)

sql += ` SET ${extraction}`
if (where) sql += ` WHERE ${this.where(where)}`
Expand Down Expand Up @@ -1042,56 +1050,104 @@ class CQN2SQLRenderer {
}

/**
* Convers the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
* Converts the columns array into an array of SQL expressions that extract the correct value from inserted JSON data
* @param {object[]} columns
* @param {import('./infer/cqn').elements} elements
* @param {Boolean} isUpdate
* @returns {string[]} Array of SQL expressions for processing input JSON data
*/
managed(columns, elements, isUpdate = false) {
const annotation = isUpdate ? '@cds.on.update' : '@cds.on.insert'
managed(columns, elements) {
const cdsOnInsert = '@cds.on.insert'
const cdsOnUpdate = '@cds.on.update'

const { _convertInput } = this.class
// Ensure that missing managed columns are added
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),
)
: ObjectKeys(elements)
.filter(e => {
const element = elements[e]
// Actual mandatory check
if (!(element.default || element[cdsOnInsert] || element[cdsOnUpdate])) return false
// Physical column check
if (!element || element.virtual || element.isAssociation) return false
// Existence check
if (columns.find(c => c.name === e)) return false
return true
})
.map(name => ({ name, sql: 'NULL' }))

const keys = ObjectKeys(elements).filter(e => elements[e].key)
const keyZero = keys[0] && this.quote(keys[0])

return [...columns, ...requiredColumns].map(({ name, sql }) => {
let element = elements?.[name] || {}
if (!sql) sql = `value->>'$."${name}"'`

let converter = element[_convertInput]
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, 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)`
}
const element = elements?.[name] || {}

const converter = a => element[_convertInput]?.(a, element) || a
let extract
if (!sql) {
({ sql, extract } = this.managed_extract(name, element, converter))
} else {
extract = sql = converter(sql)
}
// if (sql[0] !== '$') sql = converter(sql, element)

let onInsert = this.managed_session_context(element[cdsOnInsert]?.['='])
|| this.managed_session_context(element.default?.ref?.[0])
|| (element.default?.val !== undefined && { val: element.default.val, param: false })
let onUpdate = this.managed_session_context(element[cdsOnUpdate]?.['='])

if (onInsert) onInsert = this.expr(onInsert)
if (onUpdate) onUpdate = this.expr(onUpdate)

const qname = this.quote(name)

const insert = onInsert ? this.managed_default(name, converter(onInsert), sql) : sql
const update = onUpdate ? this.managed_default(name, converter(onUpdate), sql) : sql
const upsert = keyZero && (
// upsert requires the keys to be provided for the existance join (default values optional)
element.key
// If both insert and update have the same managed definition exclude the old value check
|| (onInsert && onUpdate && insert === update)
? `${insert} as ${qname}`
: `CASE WHEN OLD.${keyZero} IS NULL THEN ${
// If key of old is null execute insert
insert
} ELSE ${
// Else execute managed update or keep old if no new data if provided
onUpdate ? update : this.managed_default(name, `OLD.${qname}`, update)
} END as ${qname}`
)

return { name, sql }
return {
name, // Element name
sql, // Reference SQL
extract, // Source SQL
converter, // Converter logic
// action specific full logic
insert, update, upsert,
// action specific isolated logic
onInsert, onUpdate
}
})
}

/**
* Returns the default value
* @param {string} defaultValue
* @returns {string}
*/
// REVISIT: This is a strange method, also overridden inconsistently in postgres
defaultValue(defaultValue = this.context.timestamp.toISOString()) {
return typeof defaultValue === 'string' ? this.string(defaultValue) : defaultValue
managed_extract(name, element, converter) {
const { UPSERT, INSERT } = this.cqn
const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows)
? `value->>'$[${this.columns.indexOf(name)}]'`
: `value->>'$."${name.replace(/"/g, '""')}"'`
const sql = converter?.(extract) || extract
return { extract, sql }
}

managed_session_context(src) {
const val = _managed[src]
return val && { func: 'session_context', args: [{ val, param: false }] }
}

managed_default(name, managed, src) {
return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)`
}
}

Expand Down
Loading
Loading