-
Notifications
You must be signed in to change notification settings - Fork 14
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
Changes from 20 commits
099bf57
25ab37f
5c4e0d9
18ec794
ed6c0d1
50d02c8
c5d6886
d2ab1bf
79d6bd5
1000b7c
52f95fe
096c21c
a5a48e1
fd8be9b
3afb724
cbcf722
8a71a1a
bc9ac10
e9be968
d0f414e
d2d0a81
4a4ab23
72f161e
1ee2b29
726640d
0c8088f
84ba9f6
0cc62e8
41e9ac5
fe2576b
0f12a17
64e9768
a92b67f
f01c8e3
3beddbb
0b84a49
7e9661a
0647e3f
4f69e6e
f9763ec
4049dba
fcc34a1
2e960dd
f32f3cf
f23aec0
98e28b2
9025058
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -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)})`}` | ||||||||||
} | ||||||||||
|
||||||||||
/** | ||||||||||
|
@@ -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) | ||||||||||
.map(c => c.insert) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Before this PR There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This PR changes the re-use function This makes it also so that |
||||||||||
|
||||||||||
// Include this.values for placeholders | ||||||||||
/** @type {unknown[][]} */ | ||||||||||
|
@@ -719,14 +734,36 @@ class CQN2SQLRenderer { | |||||||||
*/ | ||||||||||
UPSERT(q) { | ||||||||||
const { UPSERT } = q | ||||||||||
const entity = this.name(q.target?.name || UPSERT.into.ref[0]) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggested change
|
||||||||||
const alias = UPSERT.into.as | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move that line down to where it is required → principle of locality There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 }) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we adhere to using variable name |
||||||||||
|
||||||||||
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'])) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use |
||||||||||
|
||||||||||
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 | ||||||||||
|
@@ -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 ------------------------------------------------ | ||||||||||
|
@@ -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)}` | ||||||||||
|
@@ -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) { | ||||||||||
BobdenOs marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
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 | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
if (columns.find(c => c.name === e)) return false | ||||||||||
// Actual mandatory check | ||||||||||
if (element.default || element[cdsOnInsert] || element[cdsOnUpdate]) return true | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Switch the order of those two checks, property lookup is faster There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (by negating the second if) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
|
||||||||||
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) | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)` | ||||||||||
} | ||||||||||
} | ||||||||||
|
||||||||||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.