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: properly support default, cds.on.insert and cds.on.update for UPSERT queries #425

Merged
merged 47 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
47 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
fcc34a1
Add check for keys to not be associations
BobdenOs Oct 21, 2024
2e960dd
Merge branch 'main' of https://github.com/cap-js/cds-dbs into upsert-…
BobdenOs Oct 21, 2024
f32f3cf
fix element parameter name
BobdenOs Oct 21, 2024
f23aec0
Merge branch 'main' into upsert-defaults
johannes-vogel Oct 22, 2024
98e28b2
fix tests for jest based pipelines
BobdenOs Oct 22, 2024
9025058
Merge branch 'upsert-defaults' of https://github.com/cap-js/cds-dbs i…
BobdenOs Oct 22, 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
187 changes: 135 additions & 52 deletions db-service/lib/cqn2sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,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 @@ -492,12 +494,25 @@ class CQN2SQLRenderer {
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)
const extractions = this.managed(columns.map(c => ({ name: c })), elements)
const extraction = extractions
.map(c => {
const element = elements?.[c.name]
if (element?.['@cds.extension']) {
return false
}
if (c.name === 'extensions__') {
const merges = extractions.filter(c => elements?.[c.name]?.['@cds.extension'])
if (merges.length) {
c.sql = `json_set(ifnull(${c.sql},'{}'),${merges.map(
c => this.string('$."' + c.name + '"') + ',' + c.sql,
)})`
}
}
return c
})
.filter(a => a)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this is for the UIFlex thing which we removed in cds8, isn't it? → why do we still need that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code was put in here to make mtxs pass its tests. My understanding is that in the mean time someone looked into the root cause of this arriving at the database service. Therefor it should be possible to remove this logic from the database now.

After applying all review comments the internal tests will be run with this PR to verify this.

.map(c => c.insert)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before this PR c.insert was c.sql?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR changes the re-use function managed to better serve INSERT, UPSERT and UPDATE. As UPSERT requires both the INSERT and UPDATE behavior it now computes both inside managed and exposes all scenarios with their respective names.

This makes it also so that managed always generates the same results for any entity. Which makes it cacheable, but is not currently done.


// Include this.values for placeholders
/** @type {unknown[][]} */
Expand Down Expand Up @@ -719,14 +734,36 @@ class CQN2SQLRenderer {
*/
UPSERT(q) {
const { UPSERT } = q
const entity = this.name(q.target?.name || UPSERT.into.ref[0])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why q.target?.name ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My question would rather be the other way around. Why it still has the fallback to the into as now infer should always put the target onto the query with the correct name even when the target is flagged as unresolved. As q.target should always be properly inferred allow UPSERT to be called on path expressions.

Suggested change
const entity = this.name(q.target?.name || UPSERT.into.ref[0])
const entity = this.name(q.target.name)

const alias = UPSERT.into.as
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move that line down to where it is required → principle of locality

Copy link
Contributor

@BobdenOs BobdenOs Sep 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been bothering me for a while now. Is it really necessary to respect the alias for INSERT, UPSERT and UPDATE queries ? My understanding is that these aliases don't do anything except for making some unit test pass.

These two queries would do the exact same. It is not even clear to me where this alias could possibly be consumed.

INSERT INTO sap_capire_bookshop_Books as Books (<columns>) <data>

INSERT INTO sap_capire_bookshop_Books (<columns>) <data>

If you agree I will take this opportunity to just ignore these root modifying query aliases.

const elements = q.target?.elements || {}
let sql = this.INSERT({ __proto__: q, INSERT: UPSERT })
const insert = this.INSERT({ __proto__: q, INSERT: UPSERT })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we adhere to using variable name sql for generated SQL?


let keys = q.target?.keys
if (!keys) return this.sql = sql
if (!keys) return insert
keys = Object.keys(keys).filter(k => !keys[k].isAssociation && !keys[k].virtual)

let updateColumns = q.UPSERT.entries ? Object.keys(q.UPSERT.entries[0]) : this.columns
updateColumns = updateColumns.filter(c => {
// temporal data
keys.push(...ObjectKeys(q.target.elements).filter(e => q.target.elements[e]['@cds.valid.from']))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use for..in loop instead


const keyCompare = keys
.map(k => `NEW.${this.quote(k)}=OLD.${this.quote(k)}`)
.join(' AND ')

const managed = this.managed(
this.columns.map(c => ({ name: c })),
elements
)

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

const mixing = managed.map(c => c.upsert)

const sql = `SELECT ${mixing} FROM (SELECT value, ${extractkeys} from json_each(?)) as NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}`

const updateColumns = this.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 @@ -736,14 +773,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)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(c))
}) ${sql} WHERE TRUE ON CONFLICT(${keys}) DO UPDATE SET ${updateColumns}`)
}

// UPDATE Statements ------------------------------------------------
Expand Down Expand Up @@ -772,7 +803,17 @@ class CQN2SQLRenderer {
}
}

const extraction = this.managed(columns, elements, true).map(c => `${this.quote(c.name)}=${c.sql}`)
columns = columns.map(c => {
if (q.elements?.[c.name]?.['@cds.extension']) return {
name: 'extensions__',
sql: `jsonb_set(extensions__,${this.string('$."' + c.name + '"')},${c.sql})`,
}
return c
})

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 @@ -1030,50 +1071,92 @@ class CQN2SQLRenderer {
* @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]
// Physical column check
if (!element || element.virtual || element.isAssociation) return false
// Existance check
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Existance check
// Existence check

if (columns.find(c => c.name === e)) return false
// Actual mandatory check
if (element.default || element[cdsOnInsert] || element[cdsOnUpdate]) return true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch the order of those two checks, property lookup is faster

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(by negating the second if)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect of trying to make the code readable. Will be adjusted 👍

return false
})
.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 qname = this.quote(name)

const converter = a => element[_convertInput]?.(a, element) || a
let extract
if (!sql) {
({ sql, extract } = this.managed_extract(name, element, converter))
}
// 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]?.['='])

onInsert = onInsert && this.expr(onInsert)
onUpdate = onUpdate && this.expr(onUpdate)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
onInsert = onInsert && this.expr(onInsert)
onUpdate = onUpdate && this.expr(onUpdate)
if (onInsert) onInsert = this.expr(onInsert)
if (onUpdate) onUpdate = this.expr(onUpdate)


const insert = onInsert ? this.managed_default(name, onInsert, sql) : sql
const update = onUpdate ? this.managed_default(name, onUpdate, sql) : sql
const upsert = keyZero && (
// If both insert and update have the same managed definition exclude the old value check
(onInsert && onUpdate && insert === update)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(onInsert && onUpdate && insert === update)
(element.key || onInsert && onUpdate && insert === update)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? insert
: `(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 ret = converter(`value->>'$."${name.replace(/"/g, '""')}"'`)
return {
extract: ret,
sql: ret,
}
}

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,'$."${name.replace(/"/g, '""')}"') IS NULL THEN ${managed} ELSE ${src} END)`
}
}

Expand Down
2 changes: 1 addition & 1 deletion db-service/test/cqn2sql/__snapshots__/create.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

exports[`create with select statements Generate SQL from CREATE stmt with entity name 1`] = `
{
"sql": "CREATE TABLE Foo ( ID INT, a NVARCHAR(5000), b NVARCHAR(5000), c NVARCHAR(5000), x INT )",
"sql": "CREATE TABLE Foo ( ID INT, a NVARCHAR(5000), b NVARCHAR(5000), c NVARCHAR(5000), x INT, PRIMARY KEY(ID) )",
}
`;
6 changes: 3 additions & 3 deletions db-service/test/cqn2sql/__snapshots__/upsert.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ exports[`upsert test with entries 1`] = `
"[{"ID":1,"name":null,"a":2},{"ID":null,"name":"'asd'","a":6}]",
],
],
"sql": "INSERT INTO Foo2 (ID,name,a) SELECT value->>'$."ID"',value->>'$."name"',value->>'$."a"' FROM json_each(?) WHERE true ON CONFLICT(ID) DO UPDATE SET name = excluded.name,a = excluded.a",
"sql": "INSERT INTO Foo2 (ID,name,a) SELECT (CASE WHEN OLD.ID IS NULL THEN value->>'$."ID"' ELSE (CASE WHEN json_type(value,'$."ID"') IS NULL THEN OLD.ID ELSE value->>'$."ID"' END) END) as ID,(CASE WHEN OLD.ID IS NULL THEN value->>'$."name"' ELSE (CASE WHEN json_type(value,'$."name"') IS NULL THEN OLD.name ELSE value->>'$."name"' END) END) as name,(CASE WHEN OLD.ID IS NULL THEN value->>'$."a"' ELSE (CASE WHEN json_type(value,'$."a"') IS NULL THEN OLD.a ELSE value->>'$."a"' END) END) as a FROM (SELECT value, value->>'$."ID"' as ID from json_each(?)) as NEW LEFT JOIN Foo2 AS OLD ON NEW.ID=OLD.ID WHERE TRUE ON CONFLICT(ID) DO UPDATE SET name = excluded.name,a = excluded.a",
}
`;

Expand All @@ -18,7 +18,7 @@ exports[`upsert test with keys only 1`] = `
"[[1],[9]]",
],
],
"sql": "INSERT INTO Foo2 (ID) SELECT value->>'$[0]' FROM json_each(?) WHERE true ON CONFLICT(ID) DO NOTHING",
"sql": "INSERT INTO Foo2 (ID) SELECT (CASE WHEN OLD.ID IS NULL THEN value->>'$."ID"' ELSE (CASE WHEN json_type(value,'$."ID"') IS NULL THEN OLD.ID ELSE value->>'$."ID"' END) END) as ID FROM (SELECT value, value->>'$."ID"' as ID from json_each(?)) as NEW LEFT JOIN Foo2 AS OLD ON NEW.ID=OLD.ID WHERE TRUE ON CONFLICT(ID) DO UPDATE SET ",
}
`;

Expand All @@ -29,6 +29,6 @@ exports[`upsert test with rows (quoted) 1`] = `
"[[1,null,2]]",
],
],
"sql": "INSERT INTO """Foo2Quoted""" ("""ID""","""name""","""a""") SELECT value->>'$[0]',value->>'$[1]',value->>'$[2]' FROM json_each(?) WHERE true ON CONFLICT("""ID""") DO UPDATE SET """name""" = excluded."""name""","""a""" = excluded."""a"""",
"sql": "INSERT INTO """Foo2Quoted""" ("""ID""","""name""","""a""") SELECT (CASE WHEN OLD."""ID""" IS NULL THEN value->>'$."""ID"""' ELSE (CASE WHEN json_type(value,'$."""ID"""') IS NULL THEN OLD."""ID""" ELSE value->>'$."""ID"""' END) END) as """ID""",(CASE WHEN OLD."""ID""" IS NULL THEN value->>'$."""name"""' ELSE (CASE WHEN json_type(value,'$."""name"""') IS NULL THEN OLD."""name""" ELSE value->>'$."""name"""' END) END) as """name""",(CASE WHEN OLD."""ID""" IS NULL THEN value->>'$."""a"""' ELSE (CASE WHEN json_type(value,'$."""a"""') IS NULL THEN OLD."""a""" ELSE value->>'$."""a"""' END) END) as """a""" FROM (SELECT value, value->>'$."""ID"""' as """ID""" from json_each(?)) as NEW LEFT JOIN """Foo2Quoted""" AS OLD ON NEW."""ID"""=OLD."""ID""" WHERE TRUE ON CONFLICT("ID") DO UPDATE SET """name""" = excluded."""name""","""a""" = excluded."""a"""",
}
`;
Loading