From 19e1bbd6782dea14a961706ee37c961af785e0f1 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 9 Apr 2026 15:57:30 +0200 Subject: [PATCH 1/4] fix: INSERT.from(SELECT) generates invalid SQL referencing undefined `value` column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `managed_default` unconditionally emitted `json_type(value, ...)` to check whether a column value was provided. This only works in `json_each()` contexts used by `INSERT.entries()`/`INSERT.rows()`. For `INSERT.from(SELECT)`, there is no `json_each()` — the source is a subquery aliased as `NEW`. Now `managed_default` checks the INSERT context: for `INSERT.from(SELECT)`, it uses `src IS NULL` instead of `json_type(value, ...)`. Closes #1559 --- db-service/lib/cqn2sql.js | 2 ++ sqlite/test/general/managed.test.js | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 1b77aa24f..e30ba5db9 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -1584,6 +1584,8 @@ class CQN2SQLRenderer { } managed_default(name, managed, src) { + if (this.cqn.INSERT?.from) + return `(CASE WHEN ${src} IS NULL THEN ${managed} ELSE ${src} END)` return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)` } } diff --git a/sqlite/test/general/managed.test.js b/sqlite/test/general/managed.test.js index 5a3f2d959..76fb6b096 100644 --- a/sqlite/test/general/managed.test.js +++ b/sqlite/test/general/managed.test.js @@ -125,6 +125,26 @@ describe('Managed thingies', () => { expect(updateTime).to.be.greaterThan(insertTime) }) + test('INSERT.into().from(SELECT) applies managed and default values', async () => { + await INSERT.into('test.foo').entries({ ID: 99, defaultValue: 50 }) + + // INSERT.from(SELECT) with only a subset of columns + await INSERT.into('test.foo').from( + cds.ql`SELECT ID + 900 as ID from test.foo where ID = 99`, + ) + + const result = await SELECT.from('test.foo').where({ ID: 999 }) + expect(result).to.have.length(1) + expect(result[0]).to.containSubset({ + ID: 999, + defaultValue: 100, // default applied, not carried over from source + createdBy: 'anonymous', + modifiedBy: 'anonymous', + }) + expect(result[0].createdAt).to.be.a('string') + expect(result[0].modifiedAt).to.be.a('string') + }) + test('managed attributes are shared within a transaction', async () => { const db = await cds.connect.to('db') const tx = db.tx({ user: { id: 'tom' } }) From 61bbdcb87f2317c57853165150006ad6d595b9f7 Mon Sep 17 00:00:00 2001 From: Patrice Bender Date: Thu, 9 Apr 2026 20:57:51 +0200 Subject: [PATCH 2/4] fix: INSERT.from(SELECT) generates invalid SQL referencing undefined `value` column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `managed_default` unconditionally emitted `json_type(value, ...)` to check whether a column value was provided. This only works in `json_each()` contexts used by `INSERT.entries()`/`INSERT.rows()`. For `INSERT.from(SELECT)`, there is no `json_each()` — the source is a subquery aliased as `NEW`. For columns not provided by the SELECT (added via `requiredColumns`), the managed default is now applied directly. Columns that are in the SELECT pass through verbatim — NULL is a valid provided value and must not be overridden. Closes #1559 --- db-service/lib/cqn2sql.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index e30ba5db9..22d60d368 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -1515,6 +1515,7 @@ class CQN2SQLRenderer { return [...columns, ...requiredColumns].map(({ name, sql }) => { const element = elements?.[name] || {} + const notInSelect = this.cqn.INSERT?.from && sql === 'NULL' const converter = a => element[_convertInput]?.(a, element) || a let extract @@ -1535,8 +1536,8 @@ class CQN2SQLRenderer { 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 insert = onInsert ? (notInSelect ? converter(onInsert) : this.managed_default(name, converter(onInsert), sql)) : sql + const update = onUpdate ? (notInSelect ? converter(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 @@ -1584,8 +1585,6 @@ class CQN2SQLRenderer { } managed_default(name, managed, src) { - if (this.cqn.INSERT?.from) - return `(CASE WHEN ${src} IS NULL THEN ${managed} ELSE ${src} END)` return `(CASE WHEN json_type(value,${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)` } } From 8e2310d00530ab4b1332fbee1cadc57f6a8d1736 Mon Sep 17 00:00:00 2001 From: Bob den Os Date: Fri, 10 Apr 2026 13:45:32 +0200 Subject: [PATCH 3/4] mixing managed fields into INSERT + UPSERT from SELECT queries --- db-service/lib/cqn2sql.js | 31 ++++++++++++---------- hana/lib/HANAService.js | 22 ++++++++-------- test/compliance/INSERT.test.js | 47 ++++++++++++++++++++++++++++++++-- test/compliance/UPSERT.test.js | 36 ++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index 22d60d368..a4f4436ff 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -1085,7 +1085,7 @@ class CQN2SQLRenderer { const extractions = this._managed = this.managed(columns.map(c => ({ name: c, sql: `NEW.${this.quote(c)}` })), elements) const sql = extractions.length > columns.length - ? `SELECT ${extractions.map(c => `${c.insert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW` + ? `SELECT ${extractions.map((c, i) => `${i < columns.length ? c.insert : c.onInsert} AS ${this.quote(c.name)}`)} FROM (${this.SELECT(src)}) AS NEW` : this.SELECT(src) if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name) this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql}` @@ -1158,12 +1158,12 @@ class CQN2SQLRenderer { const extractions = this._managed if (this.values) this.values = [] // Clear previously computed values const src = this.cqn4sql(UPSERT.from || UPSERT.as) - const aliasedQuery = cds.ql.SELECT - .columns(src.SELECT.columns - .map((c, i) => ({ ref: [this.column_name(c)], as: this.columns[i] })) - ) - .from(src) - sql = `SELECT ${extractions.map(c => `${c.upsert}`)} FROM (${this.SELECT(aliasedQuery)}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` + const aliasedQuery = `SELECT ${[ + ...src.SELECT.columns.map((c, i) => this.column_expr({ ref: [this.column_name(c)], as: this.columns[i] })), + ...extractions.slice(src.SELECT.columns.length).map(c => `${elements[c.name].key ? c.onInsert : 'NULL'} AS ${this.quote(c.name)}`), // fill in missing default values + ]} FROM (${this.SELECT(src)})` + + sql = `SELECT ${extractions.map(c => elements[c.name].key ? `NEW.${this.quote(c.name)}` : c.upsert)} FROM(${aliasedQuery}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare} ` if (extractions.length > columns.length) columns = this.columns = extractions.map(c => c.name) this.entries = [this.values] } @@ -1515,7 +1515,6 @@ class CQN2SQLRenderer { return [...columns, ...requiredColumns].map(({ name, sql }) => { const element = elements?.[name] || {} - const notInSelect = this.cqn.INSERT?.from && sql === 'NULL' const converter = a => element[_convertInput]?.(a, element) || a let extract @@ -1536,8 +1535,8 @@ class CQN2SQLRenderer { const qname = this.quote(name) - const insert = onInsert ? (notInSelect ? converter(onInsert) : this.managed_default(name, converter(onInsert), sql)) : sql - const update = onUpdate ? (notInSelect ? converter(onUpdate) : this.managed_default(name, converter(onUpdate), sql)) : sql + 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 @@ -1572,9 +1571,11 @@ class CQN2SQLRenderer { managed_extract(name, element, converter) { const { UPSERT, INSERT } = this.cqn - const extract = !(INSERT?.entries || UPSERT?.entries) && (INSERT?.rows || UPSERT?.rows) - ? `value->>${this.string(`$[${this.columns.indexOf(name)}]`)}` - : `value->>${this.string(`$.${JSON.stringify(name)}`)}` + const extract = (INSERT?.entries || UPSERT?.entries) + ? `value->>${this.string(`$.${JSON.stringify(name)}`)}` + : (INSERT?.rows || UPSERT?.rows) + ? `value->>${this.string(`$[${this.columns.indexOf(name)}]`)}` + : `NEW.${this.quote(name)}` // in case of (INSERT?.from || UPSERT?.from) const sql = converter?.(extract) || extract return { extract, sql } } @@ -1585,7 +1586,9 @@ class CQN2SQLRenderer { } 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)` + const { UPSERT, INSERT } = this.cqn + const isJson = INSERT?.entries || UPSERT?.entries || INSERT?.rows || UPSERT?.rows + return `(CASE WHEN ${isJson ? `json_type(value,${this.managed_extract(name).extract.slice(8)})` : `NEW.${this.quote(name)}`} IS NULL THEN ${managed} ELSE ${src} END)` } } diff --git a/hana/lib/HANAService.js b/hana/lib/HANAService.js index ef908e408..9a6244f2d 100644 --- a/hana/lib/HANAService.js +++ b/hana/lib/HANAService.js @@ -73,8 +73,8 @@ class HANAService extends SQLService { try { await require('@sap/cds-mtxs/lib').xt.serviceManager.get(tenant, { disableCache: true, invalidCredentials: credentials, retryUntil: deadline }) } catch (smErr) { - smErr.cause = err - throw new Error(`Failed connecting to pool - could not get valid credentials from Service Manager`, { cause: smErr }) + smErr.cause = err + throw new Error(`Failed connecting to pool - could not get valid credentials from Service Manager`, { cause: smErr }) } if (Date.now() < deadline) return create(tenant, start) else throw new Error(`Pool exceeded for '${tenant}' within ${acquireTimeoutMillis}ms`, { cause: err }) @@ -599,7 +599,7 @@ class HANAService extends SQLService { // if (col.ref?.length === 1) { col.ref.unshift(parent.as) } if (col.ref?.length > 1) { const colName = this.column_name(col) - + const isSource = from => { if (from.as === col.ref[0]) return true return from.args?.some(a => { @@ -889,12 +889,12 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON E } else { const src = this.cqn4sql(UPSERT.from || UPSERT.as) if (this.values) this.values = [] - const aliasedQuery = cds.ql.SELECT - .columns(src.SELECT.columns - .map((c, i) => ({ ref: [this.column_name(c)], as: this.columns[i] })) - ) - .from(src) - sql = `SELECT ${mixing} FROM (${this.SELECT(aliasedQuery)}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` + const aliasedQuery = `SELECT ${[ + ...src.SELECT.columns.map((c, i) => this.column_expr({ ref: [this.column_name(c)], as: this.columns[i] })), + ...managed.slice(src.SELECT.columns.length).map(c => `${elements[c.name].key ? c.onInsert : 'NULL'} AS ${this.quote(c.name)}`), // fill in missing default values + ]} FROM (${this.SELECT(src)})` + + sql = `SELECT ${mixing} FROM (${aliasedQuery}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` this.entries = [this.values] } @@ -1202,7 +1202,9 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON E } managed_default(name, managed, src) { - return `(CASE WHEN ${this.quote('$.' + name)} IS NULL THEN ${managed} ELSE ${src} END)` + const { UPSERT, INSERT } = this.cqn + const isJson = INSERT?.entries || UPSERT?.entries || INSERT?.rows || UPSERT?.rows + return `(CASE WHEN ${isJson ? this.quote('$.' + name) : `NEW.${this.quote(name)}`} IS NULL THEN ${managed} ELSE ${src} END)` } render_with() { diff --git a/test/compliance/INSERT.test.js b/test/compliance/INSERT.test.js index dc8aa1923..17728fd99 100644 --- a/test/compliance/INSERT.test.js +++ b/test/compliance/INSERT.test.js @@ -175,8 +175,8 @@ describe('INSERT', () => { .from(cds.ql`SELECT id || '-' || default as ID FROM ${keys} WHERE id = ${1}`) const select = await SELECT.from(cuid).orderBy('ID') expect(select).deep.eq([ - {ID:'1-defaulted'}, - {ID:'1-overwritten'}, + { ID: '1-defaulted' }, + { ID: '1-overwritten' }, ]) }) @@ -194,6 +194,49 @@ describe('INSERT', () => { const select = await SELECT.from(Alter).where('number = 42') expect(select[0]).to.eql({ ID: 1, number: 42, order_ID: null }) }) + + test('defaults', async () => { + const { cuid, keys, default: _default } = cds.entities('basic.common') + + // fill other table first + const ID = '1234' + await cds.run(INSERT([{ ID }]).into(cuid)) + await INSERT.into(keys).from(cds.ql`SELECT ID as id FROM ${cuid} WHERE ID = ${ID}`) + await INSERT.into(keys).from(cds.ql`SELECT ID as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) + + // default key column + const select = await SELECT.from(keys).where`id = ${ID}`.orderBy('default') + expect(select).deep.eq([ + { id: 1234, default: 'defaulted', data: null }, + { id: 1234, default: 'overwritten', data: null }, + ]) + + // default column + await INSERT.into(_default).from(cds.ql`SELECT ID FROM ${cuid} WHERE ID = ${ID}`) + const defaultInsert = await SELECT.from(_default).where`ID = ${ID}` + expect(defaultInsert).deep.eq([{ + ID, + uuidDflt: '00000000-0000-0000-4000-000000000000', + bool: false, + integer8: 8, + integer16: 9, + integer32: 10, + integer64: '11', + double: 1.1, + float: '1.1', + decimal: '1.1111', + string: 'default', + char: 'd', + short: 'default', + medium: 'default', + large: 'default', + date: '1970-01-01', + date_lit: '2021-05-05', + time: '01:02:03', + dateTime: '1970-01-01T01:02:03Z', + timestamp: '1970-01-01T01:02:03.123Z', + }]) + }) }) test('InsertResult', async () => { diff --git a/test/compliance/UPSERT.test.js b/test/compliance/UPSERT.test.js index fb53640f7..8f634efc4 100644 --- a/test/compliance/UPSERT.test.js +++ b/test/compliance/UPSERT.test.js @@ -88,6 +88,42 @@ describe('UPSERT', () => { { ID: '1-overwritten' }, ]) }) + + test('defaults', async () => { + const { cuid, keys, default: _default } = cds.entities('basic.common') + + const ID = '1234' + // fill other table first + await cds.run(UPSERT([{ ID }]).into(cuid)) + + // default key column + await UPSERT.into(keys).from(cds.ql`SELECT ID as id FROM ${cuid} WHERE ID = ${ID}`) + await UPSERT.into(keys).from(cds.ql`SELECT ID as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) + + const keysAfter = await SELECT.from(keys).where`id = ${ID}`.orderBy('default') + expect(keysAfter).deep.eq([ + { id: 1234, default: 'defaulted', data: null }, + { id: 1234, default: 'overwritten', data: null }, + ]) + + // default column + const unchangedQuery = UPSERT.into(_default).from(cds.ql`SELECT ID FROM ${cuid} WHERE ID = ${ID}`) + const changedQuery = UPSERT.into(_default).from(cds.ql`SELECT ID, 'c' as char FROM ${cuid} WHERE ID = ${ID}`) + + await unchangedQuery.clone() + const defaultInsert = await SELECT.from(_default).where`ID = ${ID}` + await unchangedQuery.clone() + const defaultUnchanged = await SELECT.from(_default).where`ID = ${ID}` + expect(defaultUnchanged).deep.eq(defaultInsert) + + await changedQuery.clone() + const defaultChanged = await SELECT.from(_default).where`ID = ${ID}` + expect(defaultChanged).property(0).property('char').eq('c') + + await unchangedQuery.clone() + const defaultUnchangedChanged = await SELECT.from(_default).where`ID = ${ID}` + expect(defaultChanged).deep.eq(defaultUnchangedChanged) + }) }) test('affected row', async () => { From e09771a8e053128c86d07e084f1a85f21a848696 Mon Sep 17 00:00:00 2001 From: Bob den Os Date: Fri, 10 Apr 2026 13:53:45 +0200 Subject: [PATCH 4/4] fix postgres and cast types properly in tests --- postgres/lib/PostgresService.js | 4 +++- test/compliance/INSERT.test.js | 4 ++-- test/compliance/UPSERT.test.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/postgres/lib/PostgresService.js b/postgres/lib/PostgresService.js index d25a408cd..b41ba5bfa 100644 --- a/postgres/lib/PostgresService.js +++ b/postgres/lib/PostgresService.js @@ -459,7 +459,9 @@ GROUP BY k } managed_default(name, managed, src) { - return `(CASE WHEN json_typeof(value->${this.managed_extract(name).extract.slice(8)}) IS NULL THEN ${managed} ELSE ${src} END)` + const { UPSERT, INSERT } = this.cqn + const isJson = INSERT?.entries || UPSERT?.entries || INSERT?.rows || UPSERT?.rows + return `(CASE WHEN ${isJson ? `json_typeof(value->${this.managed_extract(name).extract.slice(8)})` : `NEW.${this.quote(name)}`} IS NULL THEN ${managed} ELSE ${src} END)` } param({ ref }) { diff --git a/test/compliance/INSERT.test.js b/test/compliance/INSERT.test.js index 17728fd99..1ab16c629 100644 --- a/test/compliance/INSERT.test.js +++ b/test/compliance/INSERT.test.js @@ -201,8 +201,8 @@ describe('INSERT', () => { // fill other table first const ID = '1234' await cds.run(INSERT([{ ID }]).into(cuid)) - await INSERT.into(keys).from(cds.ql`SELECT ID as id FROM ${cuid} WHERE ID = ${ID}`) - await INSERT.into(keys).from(cds.ql`SELECT ID as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) + await INSERT.into(keys).from(cds.ql`SELECT cast(ID as Integer) as id FROM ${cuid} WHERE ID = ${ID}`) + await INSERT.into(keys).from(cds.ql`SELECT cast(ID as Integer) as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) // default key column const select = await SELECT.from(keys).where`id = ${ID}`.orderBy('default') diff --git a/test/compliance/UPSERT.test.js b/test/compliance/UPSERT.test.js index 8f634efc4..2218b1092 100644 --- a/test/compliance/UPSERT.test.js +++ b/test/compliance/UPSERT.test.js @@ -97,8 +97,8 @@ describe('UPSERT', () => { await cds.run(UPSERT([{ ID }]).into(cuid)) // default key column - await UPSERT.into(keys).from(cds.ql`SELECT ID as id FROM ${cuid} WHERE ID = ${ID}`) - await UPSERT.into(keys).from(cds.ql`SELECT ID as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) + await UPSERT.into(keys).from(cds.ql`SELECT cast(ID as Integer) as id FROM ${cuid} WHERE ID = ${ID}`) + await UPSERT.into(keys).from(cds.ql`SELECT cast(ID as Integer) as id, 'overwritten' as default FROM ${cuid} WHERE ID = ${ID}`) const keysAfter = await SELECT.from(keys).where`id = ${ID}`.orderBy('default') expect(keysAfter).deep.eq([