diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 17e1e8f4b..a78690a6c 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -122,7 +122,7 @@ class SQLService extends DatabaseService { } _buffer(val) { - if (val === null) return null + if (val == null) return null return Buffer.from(val, 'base64') } @@ -184,25 +184,16 @@ class SQLService extends DatabaseService { * Handler for INSERT * @type {Handler} */ - async onINSERT({ query, data }) { - const { sql, entries, cqn } = this.cqn2sql(query, data) - if (!sql) return // Do nothing when there is nothing to be done // REVISIT: fix within mtxs - const ps = await this.prepare(sql) - const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run() - return new this.class.InsertResults(cqn, results) + async onINSERT(req) { + return this.onSIMPLE(req) } /** * Handler for UPSERT * @type {Handler} */ - async onUPSERT({ query, data }) { - const { sql, entries } = this.cqn2sql(query, data) - if (!sql) return // Do nothing when there is nothing to be done // REVISIT: When does this happen? - const ps = await this.prepare(sql) - const results = entries ? await Promise.all(entries.map(e => ps.run(e))) : await ps.run() - // REVISIT: results isn't an array, when no entries -> how could that work? when do we have no entries? - return results.reduce((total, affectedRows) => total + affectedRows.changes, 0) + async onUPSERT(req) { + return this.onSIMPLE(req) } /** @@ -225,9 +216,18 @@ class SQLService extends DatabaseService { * @type {Handler} */ async onSIMPLE({ query, data }) { - const { sql, values } = this.cqn2sql(query, data) + const { sql, values, entries, cqn } = this.cqn2sql(query, data) let ps = await this.prepare(sql) - return (await ps.run(values)).changes + const run = cqn.kind === 'SELECT' || cqn[cqn.kind]?.returning + ? e => ps.all(e) + : e => ps.run(e) + let results = entries + ? (await Promise.all(entries.map(run))) + .reduce((l, c) => 'changes' in c ? (l.affected += c.changes) && l : [...l, ...c], Object.assign([], { affected: 0 })) + : await run(values) + if (!Array.isArray(results)) results = Object.assign([], { affected: results.changes }) + results.affected = results.length || results.affected // use || to ignore length:0 + return results } get onDELETE() { @@ -300,7 +300,7 @@ class SQLService extends DatabaseService { * @type {Handler} */ async onEVENT({ event }) { - if(DEBUG._debug) DEBUG.debug(event) // in the other cases above DEBUG happens in cqn2sql + if (DEBUG._debug) DEBUG.debug(event) // in the other cases above DEBUG happens in cqn2sql return await this.exec(event) } @@ -310,7 +310,7 @@ class SQLService extends DatabaseService { */ async onPlainSQL({ query, data }, next) { if (typeof query === 'string') { - if(DEBUG._debug) DEBUG.debug(query, data) + if (DEBUG._debug) DEBUG.debug(query, data) const ps = await this.prepare(query) const exec = this.hasResults(query) ? d => ps.all(d) : d => ps.run(d) if (Array.isArray(data) && Array.isArray(data[0])) return await Promise.all(data.map(exec)) @@ -415,7 +415,7 @@ class SQLService extends DatabaseService { * @param {import('@sap/cds/apis/cqn').Query} q * @returns {import('./infer/cqn').Query} */ - cqn4sql(q, useTechnicalAlias=true) { + cqn4sql(q, useTechnicalAlias = true) { if ( !cds.env.features.db_strict && !q.SELECT?.from?.join && @@ -517,7 +517,7 @@ const DEBUG_PQL = cds.log('pql') if (DEBUG_PQL._debug || cds.repl) { // Add helper method to convert CQN to PQL, used below... - SQLService.prototype.cqn2pql = function cqn2pql (query, values) { + SQLService.prototype.cqn2pql = function cqn2pql(query, values) { const CQN2PQL = cqn2pql.renderer ??= require('./cqn2pql') return new CQN2PQL(this).render(query, values) } @@ -568,7 +568,7 @@ if (DEBUG_PQL._debug || cds.repl) { * if no real SQL service is available yet through cds.db. */ class db extends SQLService { - /** @returns {SQLService} */ + /** @returns {SQLService} */ static get srv() { return cds.db || (this.singleton ??= new this) } get factory() { return null } get model() { return cds.model } diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index a678b2159..61d6299d0 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -920,8 +920,9 @@ class CQN2SQLRenderer { } 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(transitions.mapping.get(c)?.ref?.[0] || c)) - }) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`) + + return (this.sql = this.returning(`INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c)) + }) SELECT ${extractions.slice(0, columns.length).map(c => c.insert)} FROM json_each(?)`, INSERT.returning, q)) } async *INSERT_entries_stream(entries, binaryEncoding = 'base64') { @@ -1053,8 +1054,8 @@ class CQN2SQLRenderer { .map(c => c.converter(c.extract)) const transitions = this.srv.resolve.transitions(q) - return (this.sql = `INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c)) - }) SELECT ${extraction} FROM json_each(?)`) + return (this.sql = this.returning(`INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c)) + }) SELECT ${extraction} FROM json_each(?)`, INSERT.returning, q)) } /** @@ -1091,7 +1092,7 @@ class CQN2SQLRenderer { ? `SELECT ${extractions.map(c => `${c.insert} 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}` + this.sql = this.returning(`INSERT INTO ${this.quote(entity)}${alias ? ' as ' + this.quote(alias) : ''} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql}`, INSERT.returning, q) this.entries = [this.values] return this.sql } @@ -1182,8 +1183,9 @@ class CQN2SQLRenderer { }).map(c => `${this.quote(c)} = excluded.${this.quote(c)}`) const transitions = this.srv.resolve.transitions(q) - return (this.sql = `INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql - } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING'}`) + return (this.sql = this.returning(`INSERT INTO ${this.quote(entity)} (${columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c))}) ${sql + } WHERE TRUE ON CONFLICT(${keys.map(c => this.quote(c))}) DO ${updateColumns.length ? `UPDATE SET ${updateColumns}` : 'NOTHING' + }`, UPSERT.returning, q)) } // UPDATE Statements ------------------------------------------------ @@ -1194,7 +1196,7 @@ class CQN2SQLRenderer { * @returns {string} SQL */ UPDATE(q) { - const { entity, with: _with, data, where } = q.UPDATE + const { entity, with: _with, data, where, returning } = q.UPDATE const transitions = this.srv.resolve.transitions(q) const elements = q._target?.elements let sql = `UPDATE ${this.quote(this.table_name(q))}` @@ -1225,6 +1227,7 @@ class CQN2SQLRenderer { sql += ` SET ${extraction}` if (where) sql += ` WHERE ${this.where_resolved(entity.as, where, q)}` + if (returning) sql = this.returning(sql, returning, q) return (this.sql = sql) } @@ -1236,10 +1239,11 @@ class CQN2SQLRenderer { * @returns {string} SQL */ DELETE(q) { - const { DELETE: { where, from } } = q + const { DELETE: { where, from, returning } } = q let sql = `DELETE FROM ${this.quote(this.table_name(q))}` if (from.as) sql += ` AS ${this.quote(from.as)}` if (where) sql += ` WHERE ${this.where(where)}` + if (returning) sql = this.returning(sql, returning, q) return (this.sql = sql) } @@ -1485,6 +1489,11 @@ class CQN2SQLRenderer { return s } + returning(sql, cols, q) { + if (cols && cols.length) return `${sql} RETURNING ${cols.map(c => this.column_expr(c, q))}` + return sql + } + /** * Converts the columns array into an array of SQL expressions that extract the correct value from inserted JSON data * @param {object[]} columns diff --git a/hana/lib/HANAService.js b/hana/lib/HANAService.js index 557e19af1..78fd22aa6 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 }) @@ -162,7 +162,7 @@ class HANAService extends SQLService { let sqlScript = isLockQuery || isSimple ? sql : this.wrapTemporary(temporary, withclause, blobs) const { hints } = query.SELECT if (hints) sqlScript += ` WITH HINT (${hints.join(',')})` - + let rows if (values?.length || blobs.length > 0 || isStream) { const ps = await this.prepare(sqlScript, blobs.length) @@ -176,20 +176,20 @@ class HANAService extends SQLService { const resultQuery = query.clone() resultQuery.SELECT.forUpdate = undefined resultQuery.SELECT.forShareLock = undefined - + const keys = Object.keys(req.target?.keys || {}) - + if (keys.length && query.SELECT.forUpdate?.ignoreLocked) { // Exit early when no row was found in the inital query if (rows.length === 0) return isOne ? undefined : [] - + // Filter for those rows that were locked by the initial query const left = { list: keys.map(k => ({ ref: [k] })) } const right = { list: rows.map(r => ({ list: keys.map(k => ({ val: r[k.toUpperCase()] })) })) } resultQuery.SELECT.limit = undefined resultQuery.SELECT.where = [left, 'in', right] } - + return this.onSELECT({ query: resultQuery, __proto__: req }) } @@ -208,12 +208,14 @@ class HANAService extends SQLService { if (!sql) return // Do nothing when there is nothing to be done const ps = await this.prepare(sql) // HANA driver supports batch execution - const results = await (entries + let results = await (entries ? this.server.major <= 2 ? entries.reduce((l, c) => l.then(() => this.ensureDBC() && ps.run(c)), Promise.resolve(0)) : entries.length > 1 ? this.ensureDBC() && await ps.runBatch(entries) : this.ensureDBC() && await ps.run(entries[0]) : this.ensureDBC() && ps.run()) - return new this.class.InsertResults(cqn, results) + if (Array.isArray(results.changes) && Array.isArray(results.changes[1])) results = Object.assign(results.changes[1], { affected: results.changes[1].length }) + else results = Object.assign([], { affected: results.changes }) + return results } async onNOTFOUND(req, next) { @@ -603,7 +605,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 => { @@ -817,9 +819,10 @@ class HANAService extends SQLService { // With the buffer table approach the data is processed in chunks of a configurable size // Which allows even smaller HANA systems to process large datasets // But the chunk size determines the maximum size of a single row - return (this.sql = `INSERT INTO ${this.quote(entity)} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c)) + this._new = `JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON ERROR)` + return (this.sql = this.returning(`INSERT INTO ${this.quote(entity)} (${this.columns.map(c => this.quote(transitions.mapping.get(c)?.ref?.[0] || c)) }) WITH SRC AS (SELECT ? AS JSON FROM DUMMY UNION ALL SELECT TO_NCLOB(NULL) AS JSON FROM DUMMY) - SELECT ${converter} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON ERROR) AS NEW`) + SELECT ${converter} FROM ${this._new} AS NEW`, INSERT.returning, q)) } INSERT_rows(q) { @@ -888,8 +891,9 @@ class HANAService extends SQLService { let sql if (UPSERT.entries || UPSERT.rows || UPSERT.values) { + this._old = `JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON ERROR) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` sql = `WITH SRC AS (SELECT ? AS JSON FROM DUMMY UNION ALL SELECT TO_NCLOB(NULL) AS JSON FROM DUMMY) -SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON ERROR) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` +SELECT ${mixing} FROM ${this._old}` } else { const src = this.cqn4sql(UPSERT.from || UPSERT.as) if (this.values) this.values = [] @@ -898,7 +902,8 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON E .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}` + this._old = `(${this.SELECT(aliasedQuery)}) AS NEW LEFT JOIN ${this.quote(entity)} AS OLD ON ${keyCompare}` + sql = `SELECT ${mixing} FROM ${this._old}` this.entries = [this.values] } @@ -1222,6 +1227,34 @@ SELECT ${mixing} FROM JSON_TABLE(SRC.JSON, '$' COLUMNS(${extraction}) ERROR ON E this.values = [...prefix.map(p => p.values).flat(), ...values] } + returning(sql, cols, q) { + if (cols && cols.length) { + // INSERT.from NEW has to be snapshotted before doing the INSERT + // Has to be joined back with actual entity (impossible for entities without keys) + // INSERT.entries NEW should be snapshotted before doing the INSERT + // So that the actual INSERT can use the snapshot + // As the snapshot can then also be joined back with the actual entity (also impossible for entities without keys) + + + // INSERT only NEW + // DELETE only OLD + // UPSERT, UPDATE both NEW and OLD + // TODO: ensure that OLD and NEW are generated based upon the data targeted by the query + const _q = q[q.kind] + // this._old ??= this.SELECT(cds.ql({ SELECT: { __proto__: _q, from: _q.entity } })) + this._new ??= `(${this.SELECT(q.INSERT.from)})` + return `DO (${q.INSERT?.entries ? 'IN JSON NCLOB => ?' : ''}) BEGIN + -- OLD = ${this._old}; + NEW = SELECT * FROM ${this._new}; + ${sql}; + SELECT ${cols.map(c => this.column_expr(c, q))} FROM :NEW; +END` + .replace(/WITH SRC AS.*/, '') + .replaceAll('JSON_TABLE(SRC.JSON', 'JSON_TABLE(:JSON') + } + return sql + } + // Loads a static result from the query `SELECT * FROM RESERVED_KEYWORDS` static ReservedWords = { ...super.ReservedWords, ...hanaKeywords } diff --git a/sqlite/lib/SQLiteService.js b/sqlite/lib/SQLiteService.js index 3e2ab7ac6..38c0bef29 100644 --- a/sqlite/lib/SQLiteService.js +++ b/sqlite/lib/SQLiteService.js @@ -87,10 +87,10 @@ class SQLiteService extends SQLService { try { const stmt = this.dbc.prepare(sql) return { - run: (..._) => this._run(stmt, ..._), - get: (..._) => stmt.get(..._), - all: (..._) => stmt.all(..._), - stream: (..._) => this._allStream(stmt, ..._), + run: async (_) => stmt.run(await this._streams(_)), + get: async (_) => stmt.get(await this._streams(_)), + all: async (_) => stmt.all(await this._streams(_)), + stream: async (args, one, objectMode) => this._allStream(stmt, args, one, objectMode), } } catch (e) { e.message += ' in:\n' + (e.query = sql) @@ -98,17 +98,19 @@ class SQLiteService extends SQLService { } } - async _run(stmt, binding_params) { + async _streams(binding_params) { + if (!binding_params.length) return [{}] for (let i = 0; i < binding_params.length; i++) { const val = binding_params[i] if (val instanceof Readable) { + if (val.type !== 'json') val.setEncoding('base64') binding_params[i] = await convStrm[val.type === 'json' ? 'text' : 'buffer'](val) } if (Buffer.isBuffer(val)) { binding_params[i] = Buffer.from(val.toString('base64')) } } - return stmt.run(binding_params) + return binding_params } async *_iteratorRaw(rs, one) { @@ -169,23 +171,6 @@ class SQLiteService extends SQLService { return this.dbc.exec(sql) } - _prepareStreams(values) { - let any - values.forEach((v, i) => { - if (v instanceof Readable) { - any = values[i] = convStrm.buffer(v) - } - }) - return any ? Promise.all(values) : values - } - - async onSIMPLE({ query, data }) { - const { sql, values } = this.cqn2sql(query, data) - let ps = await this.prepare(sql) - const vals = await this._prepareStreams(values) - return (await ps.run(vals)).changes - } - onPlainSQL({ query, data }, next) { if (typeof query === 'string') { // REVISIT: this is a hack the target of $now might not be a timestamp or date time @@ -272,9 +257,9 @@ class SQLiteService extends SQLService { // Reading decimal as string to not loose precision Decimal: cds.env.features.ieee754compatible ? (expr, elem) => - elem?.scale - ? `CASE WHEN ${expr} IS NULL THEN NULL ELSE format('%.${elem.scale}f', ${expr}) END` - : `CASE WHEN ${expr} IS NULL THEN NULL ELSE rtrim(rtrim(format('%.999f', ${expr}), '0'), '.') END` + elem?.scale + ? `CASE WHEN ${expr} IS NULL THEN NULL ELSE format('%.${elem.scale}f', ${expr}) END` + : `CASE WHEN ${expr} IS NULL THEN NULL ELSE rtrim(rtrim(format('%.999f', ${expr}), '0'), '.') END` : undefined, // Binary is not allowed in json objects Binary: expr => `${expr} || ''`, diff --git a/sqlite/test/general/insert-entries-select.test.js b/sqlite/test/general/insert-entries-select.test.js deleted file mode 100644 index 45b56a63a..000000000 --- a/sqlite/test/general/insert-entries-select.test.js +++ /dev/null @@ -1,24 +0,0 @@ -const cds = require('../../../test/cds.js') -const assert = require('assert') - -describe('insert from select', () => { - cds.test(__dirname, 'testModel.cds') - - test('make sure that the placeholder values of the prepared statement are passed to the database', async () => { - // fill other table first - await cds.run(INSERT({ ID: 42, name: 'Foo2' }).into('Foo2')) - const insert = INSERT.into('Foo') - .columns(['ID', 'a']) - .from( - SELECT.from('Foo2') - .columns(['ID', 'name']) - .where({ ref: ['name'] }, '=', { val: 'Foo2' }), - ) - // insert from select - const insertRes = await cds.run(insert) - assert.strictEqual(insertRes.affectedRows, 1, 'One row should have been inserted') - // select the inserted column - const selectRes = await cds.run(SELECT.from('Foo').where({ ref: ['ID'] }, '=', { val: 42 })) - assert.strictEqual(selectRes.length, 1, 'One row should have been inserted') - }) -}) diff --git a/sqlite/test/general/insert-result.test.js b/sqlite/test/general/insert-result.test.js deleted file mode 100644 index de478847f..000000000 --- a/sqlite/test/general/insert-result.test.js +++ /dev/null @@ -1,28 +0,0 @@ -const cds = require('../../../test/cds.js') -const assert = require('assert') - -describe('insert from select', () => { - cds.test(__dirname, 'testModel.cds') - - before(() => { - return cds.run([INSERT.into('Foo2').entries({ ID: 11, name: 'test' })]) - }) - - test('insert result works for single entries', async () => { - const insertResult = await cds.run(INSERT({ name: 'Foo' }).into('Foo2')) - assert.strictEqual(insertResult.affectedRows, 1, 'One row should have been inserted') - assert.equal(insertResult, 1, 'Lose equality should work for InsertResult') - assert.deepStrictEqual([...insertResult],[{ID: 12}], 'The iterator should resolve the generated keys') - }) - - test('insert result works for batch entries', async () => { - const {maxID} = await cds.run(SELECT.one.from('Foo2').columns('max(ID) as maxID')) - - const entries = Array(10).fill().map((_, idx) => ({ name: `Foo${maxID + 1 + idx}` })) - const insertResult = await cds.run(INSERT(entries).into('Foo2')) - assert.strictEqual(insertResult.affectedRows, 10, 'One row should have been inserted') - assert.equal(insertResult, 10, 'Lose equality should work for InsertResult') - const expected = Array(10).fill().map((_, idx) => ({ ID: maxID + 1 + idx })) - assert.deepStrictEqual([...insertResult],expected, 'The iterator should resolve the generated keys') - }) -}) \ No newline at end of file diff --git a/sqlite/test/general/stream.test.js b/sqlite/test/general/stream.test.js index 3d19b2786..f7b0b53c0 100644 --- a/sqlite/test/general/stream.test.js +++ b/sqlite/test/general/stream.test.js @@ -85,7 +85,7 @@ describe('streaming', () => { { ID: ID1, data: stream1, data2: stream2 }, { ID: ID2, data: stream3, data2: stream4 }, { ID: ID3, data: stream5, data2: stream6 } - ] = await SELECT.from(Images).columns(['ID', 'data', 'data2']) + ] = await SELECT.from(Images).columns(['ID', 'data', 'data2']).orderBy`ID` await checkSize(stream1) await checkSize(stream2) expect(stream3).to.be.null @@ -107,7 +107,7 @@ describe('streaming', () => { test('READ multiple entries ignore stream properties if columns = all', async () => cds.tx(async () => { const { Images } = cds.entities('test') - const result = await SELECT.from(Images) + const result = await SELECT.from(Images).orderBy`ID` expect(result[0].ID).equals(1) expect(result[0].data).to.be.undefined expect(result[0].data2).to.be.undefined @@ -160,7 +160,7 @@ describe('streaming', () => { const stream = fs.createReadStream(path.join(__dirname, 'samples/test.jpg')) const changes = await UPDATE(Images).with({ data2: stream }).where({ ID: 3 }) - expect(changes).to.equal(1) + expect(changes.affected).to.equal(1) const [{ data2: stream_ }] = await SELECT.from(Images).columns('data2').where({ ID: 3 }) await checkSize(stream_) @@ -172,7 +172,7 @@ describe('streaming', () => { const stream2 = fs.createReadStream(path.join(__dirname, 'samples/test.jpg')) const changes = await UPDATE(Images).with({ data: stream1, data2: stream2 }).where({ ID: 4 }) - expect(changes).to.equal(1) + expect(changes.affected).to.equal(1) const [{ data: stream1_, data2: stream2_ @@ -189,7 +189,7 @@ describe('streaming', () => { const insert = async () => { const changes = await UPDATE(Images).with({ data2: stream }).where({ ID: 3 }) - expect(changes).to.equal(1) + expect(changes.affected).to.equal(1) } if(cds.db.pools._factory.options.max > 1) await cds.tx(insert) // Stream over multiple transaction for `hdb` limitation else await insert() @@ -204,7 +204,7 @@ describe('streaming', () => { const blob2 = fs.readFileSync(path.join(__dirname, 'samples/test.jpg')) const changes = await UPDATE(Images).with({ data: blob1, data2: blob2 }).where({ ID: 4 }) - expect(changes).to.equal(1) + expect(changes.affected).to.equal(1) const [{ data: stream1_, @@ -221,7 +221,7 @@ describe('streaming', () => { const stream = fs.createReadStream(path.join(__dirname, 'samples/test.jpg')) const changes = await UPDATE(ImagesView).with({ renamedData: stream }).where({ ID: 1 }) - expect(changes).to.equal(1) + expect(changes.affected).to.equal(1) const [{ renamedData: stream_ }] = await SELECT.from(ImagesView).columns('renamedData').where({ ID: 1 }) await checkSize(stream_) @@ -238,7 +238,7 @@ describe('streaming', () => { const changes = await INSERT.into(Images).entries(json) try { - expect(changes).toEqual(2) + expect(changes.affected).toEqual(2) } catch { // @sap/hana-client does not allow for returning the number of affected rows } diff --git a/test/compliance/DELETE.test.js b/test/compliance/DELETE.test.js index f5ce0cd46..955388e24 100644 --- a/test/compliance/DELETE.test.js +++ b/test/compliance/DELETE.test.js @@ -44,12 +44,12 @@ describe('DELETE', () => { ]), ] const insertsResp = await cds.run(inserts) - expect(insertsResp[0].affectedRows).to.be.eq(1) + expect(insertsResp[0].affected).to.be.eq(1) }) test('on root with keys', async () => { const deepDelete = await cds.run(DELETE.from(RootPWithKeys).where({ ID: 5 })) - expect(deepDelete).to.be.eq(1) + expect(deepDelete.affected).to.be.eq(1) const root = await cds.run(SELECT.one.from(Root).where({ ID: 5 })) expect(root).to.not.exist @@ -64,7 +64,7 @@ describe('DELETE', () => { test('on child with where', async () => { // only delete entries where fooChild = 'bar' const deepDelete = await cds.run(DELETE.from(ChildPWithWhere)) - expect(deepDelete).to.be.eq(1) + expect(deepDelete.affected).to.be.eq(1) const child = await cds.run(SELECT.from(Child).where({ ID: 6, or: { ID: 7 } })) expect(child[0].ID).to.be.eq(7) @@ -78,7 +78,7 @@ describe('DELETE', () => { const { Authors } = cds.entities('complex.associations') await INSERT.into(Authors).entries(new Array(9).fill().map((e, i) => ({ ID: 100 + i, name: 'name' + i }))) const changes = await cds.run(DELETE.from(Authors)) - expect(changes | 0).to.be.eq(10, 'Ensure that all rows are affected') // 1 from csv, 9 newly added + expect(changes.affected).to.be.eq(10, 'Ensure that all rows are affected') // 1 from csv, 9 newly added }) }) @@ -89,7 +89,7 @@ describe('DELETE', () => { }) test('affected rows', async () => { - const affectedRows = await DELETE.from('complex.associations.Books').where('ID = 4712') - expect(affectedRows).to.be.eq(0) + const del = await DELETE.from('complex.associations.Books').where('ID = 4712') + expect(del.affected).to.be.eq(0) }) }) diff --git a/test/compliance/INSERT.test.js b/test/compliance/INSERT.test.js index dc8aa1923..8e63c444c 100644 --- a/test/compliance/INSERT.test.js +++ b/test/compliance/INSERT.test.js @@ -166,17 +166,18 @@ describe('INSERT', () => { test('transform', async () => { const { cuid, keys } = cds.entities('basic.common') // fill other table first - await cds.run(INSERT([ + const changes = await cds.run(INSERT([ { id: 1 }, { id: 1, default: 'overwritten' }, ]).into(keys)) + const subselect = await cds.ql`SELECT id || '-' || default as ID FROM ${keys} WHERE id = ${1}` await INSERT.into(cuid) .columns(['ID']) .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' }, ]) }) @@ -196,13 +197,38 @@ describe('INSERT', () => { }) }) + describe('returning', () => { + let genCount = 0 + const gen = function* () { + for (var i = 0; i < 100; i++) + yield { uuid: cds.utils.uuid() } + genCount += i + } + + test('array', async () => { + const { uuid } = cds.entities('basic.literals') + + const cqn = INSERT([...gen()]).into(uuid).returning`uuid` + // cqn.INSERT.returning = cds.ql.columns`uuid` + const result = await cqn + expect(result.length).to.eq(genCount) + }) + + test('from', async () => { + const { uuid } = cds.entities('basic.literals') + + const [{ count }] = await cds.ql`SELECT count(1) FROM ${uuid}` + const cqn = INSERT(cds.ql`SELECT current_timestamp as uuid FROM ${uuid}`).into(uuid).returning`uuid` + // cqn.INSERT.returning = cds.ql.columns`uuid` + const result = await cqn + expect(result.length).to.eq(count) + }) + }) + test('InsertResult', async () => { const insert = INSERT.into('complex.associations.Books').entries({ ID: 5 }) - const affectedRows = await cds.db.run(insert) - // affectedRows is an InsertResult, so we need to do lose comparison here, as strict will not work due to InsertResult - expect(affectedRows == 1).to.be.eq(true) - // InsertResult - expect(affectedRows).not.to.include({ _affectedRows: 1 }) // lastInsertRowid not available on postgres + const results = await cds.db.run(insert) + expect(results.affected).to.be.eq(1) }) test('default $now adds current tx timestamp in correct format', async () => { diff --git a/test/compliance/SELECT.test.js b/test/compliance/SELECT.test.js index f9c881734..b8a22aebf 100644 --- a/test/compliance/SELECT.test.js +++ b/test/compliance/SELECT.test.js @@ -1178,7 +1178,11 @@ describe('SELECT', () => { describe('foreach', () => { const process = function (row) { - for (const prop in row) if (row[prop] != null) (this[prop] ??= []).push(row[prop]) + try { + for (const prop in row) if (row[prop] != null) (this[prop] ??= []).push(row[prop]) + } catch (err) { + debugger + } } test('cds/srv.foreach', () => cds.tx(async () => { diff --git a/test/compliance/UPDATE.test.js b/test/compliance/UPDATE.test.js index a41326a60..077b09640 100644 --- a/test/compliance/UPDATE.test.js +++ b/test/compliance/UPDATE.test.js @@ -127,14 +127,14 @@ describe('UPDATE', () => { { ID: 6, title: 'bar' }, ]), ) - expect(insert.affectedRows).to.equal(2) + expect(insert.affected).to.equal(2) const update = await cds.run( UPDATE.entity(Books) .set({ title: 'foo' }) .where({ ID: 5, or: { ID: 6 } }), ) - expect(update).to.equal(2) + expect(update.affected).to.equal(2) }) }) @@ -174,7 +174,7 @@ describe('UPDATE', () => { test('affected rows', async () => { const { count } = await SELECT.one`count(*)`.from('complex.associations.Books') - const affectedRows = await UPDATE.entity('complex.associations.Books').data({ title: 'Book' }) - expect(affectedRows).to.be.eq(count) + const upd = await UPDATE.entity('complex.associations.Books').data({ title: 'Book' }) + expect(upd.affected).to.be.eq(count) }) }) diff --git a/test/compliance/UPSERT.test.js b/test/compliance/UPSERT.test.js index fb53640f7..1a8301828 100644 --- a/test/compliance/UPSERT.test.js +++ b/test/compliance/UPSERT.test.js @@ -91,7 +91,7 @@ describe('UPSERT', () => { }) test('affected row', async () => { - const affectedRows = await UPSERT.into('complex.associations.Books').entries({ ID: 9999999, title: 'Book' }) - expect(affectedRows).to.be.eq(1) + const ups = await UPSERT.into('complex.associations.Books').entries({ ID: 9999999, title: 'Book' }) + expect(ups.affected).to.be.eq(1) }) }) diff --git a/test/scenarios/bookshop/delete.test.js b/test/scenarios/bookshop/delete.test.js index 36b63f9d4..8059e4f5e 100644 --- a/test/scenarios/bookshop/delete.test.js +++ b/test/scenarios/bookshop/delete.test.js @@ -6,8 +6,8 @@ describe('Bookshop - Delete', () => { test('Deep delete works for queries with multiple where clauses', async () => { const del = DELETE.from('sap.capire.bookshop.Genres[ID = 4711]').where('ID = 4712') - const affectedRows = await cds.db.run(del) - expect(affectedRows).to.be.eq(0) + const res = await cds.db.run(del) + expect(res.affected).to.be.eq(0) }) test(`Deep delete rejects transitive circular dependencies`, async () => { diff --git a/test/scenarios/bookshop/insert.test.js b/test/scenarios/bookshop/insert.test.js index 60c345b49..0125f07ca 100644 --- a/test/scenarios/bookshop/insert.test.js +++ b/test/scenarios/bookshop/insert.test.js @@ -17,13 +17,13 @@ describe('Bookshop - Insert', () => { test('insert with undefined value works', async () => { const { Books } = cds.entities('sap.capire.bookshop') const resp = await cds.run(INSERT({ stock: undefined, ID: 223, title: 'Harry Potter' }).into(Books)) - expect(resp | 0).to.be.eq(1) + expect(resp.affected).to.be.eq(1) }) test('mass insert on unknown entities', async () => { - if(cds.env.sql.names === 'quoted') return 'skipped' + if (cds.env.sql.names === 'quoted') return 'skipped' const books = 'sap_capire_bookshop_Books' - let affectedRows = await INSERT.into(books) + let ins = await INSERT.into(books) .entries([{ ID: 4711, createdAt: (new Date()).toISOString(), @@ -31,7 +31,7 @@ describe('Bookshop - Insert', () => { ID: 4712, createdAt: (new Date()).toISOString(), }]) - expect(affectedRows | 0).to.be.eq(2) + expect(ins.affected).to.be.eq(2) const res = await SELECT.from('sap.capire.bookshop.Books').where('ID in', [4711, 4712]) expect(res).to.have.length(2) @@ -40,13 +40,13 @@ describe('Bookshop - Insert', () => { test('insert with arrayed elements', async () => { const { Books } = cds.entities('sap.capire.bookshop') const resp = await cds.run(INSERT({ footnotes: ['first', 'second'], ID: 121, title: 'Guiness Book of World Records' }).into(Books)) - expect(resp | 0).to.be.eq(1) + expect(resp.affected).to.be.eq(1) }) test('insert with assoc default', async () => { const { Books } = cds.entities('sap.capire.bookshop') await cds.run(INSERT({ ID: 344, title: 'Faust. Eine Tragödie' }).into(Books)) - const res = await SELECT.from(Books, {ID: 344}) + const res = await SELECT.from(Books, { ID: 344 }) expect(res.genre_ID).to.be.eq(10) }) }) diff --git a/test/scenarios/bookshop/update.test.js b/test/scenarios/bookshop/update.test.js index 8237ef379..bcac361b8 100644 --- a/test/scenarios/bookshop/update.test.js +++ b/test/scenarios/bookshop/update.test.js @@ -55,7 +55,7 @@ describe('Bookshop - Update', () => { }) test('programmatic insert/upsert/update/select/delete with unknown entity', async () => { - if(cds.env.sql.names === 'quoted') return 'skipped' + if (cds.env.sql.names === 'quoted') return 'skipped' const books = 'sap_capire_bookshop_Books' const ID = 999 let affectedRows = await INSERT.into(books) @@ -63,25 +63,25 @@ describe('Bookshop - Update', () => { ID, createdAt: (new Date()).toISOString(), }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) affectedRows = await DELETE(books) .where({ ID }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) affectedRows = await INSERT.into(books) .columns(['ID', 'createdAt']) .values([ID, (new Date()).toISOString()]) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) affectedRows = await UPDATE(books) .with({ modifiedAt: (new Date()).toISOString() }) .where({ ID }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) affectedRows = await DELETE(books) .where({ ID }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) // UPSERT fallback to an INSERT affectedRows = await UPSERT.into(books) @@ -89,7 +89,7 @@ describe('Bookshop - Update', () => { ID, createdAt: (new Date()).toISOString(), }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) // UPSERT fallback to an INSERT (throws on secondary call) affectedRows = UPSERT.into(books) @@ -105,16 +105,16 @@ describe('Bookshop - Update', () => { affectedRows = await DELETE(books) .where({ ID }) - expect(affectedRows | 0).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) }) test('programmatic update without body incl. managed', async () => { const { Books } = cds.entities('sap.capire.bookshop') const { modifiedAt } = await SELECT.from(Books, { ID: 251 }) const affectedRows = await UPDATE(Books, { ID: 251 }) - expect(affectedRows).to.be.eq(1) + expect(affectedRows.affected).to.be.eq(1) const { modifiedAt: newModifiedAt } = await SELECT.from(Books, { ID: 251 }) - expect(newModifiedAt).not.to.be.eq(modifiedAt) + expect(newModifiedAt.affected).not.to.be.eq(modifiedAt) }) test('programmatic update without body excl. managed', async () => { @@ -184,7 +184,7 @@ describe('Bookshop - Update', () => { test('Upsert draft enabled entity', async () => { const res = await UPSERT.into('DraftService.DraftEnabledBooks').entries({ ID: 42, title: 'Foo' }) - expect(res).to.equal(1) + expect(res.affected).to.equal(1) }) test('with path expressions on draft enabled service entity', async () => { @@ -192,8 +192,8 @@ describe('Bookshop - Update', () => { // as it is a virtual const { MoreDraftEnabledBooks } = cds.entities('DraftService') const updateRichardsBooks = UPDATE.entity(MoreDraftEnabledBooks) - .where(`author.name = 'Richard Carpenter'`) - .set('ID = 42') + .where(`author.name = 'Richard Carpenter'`) + .set('ID = 42') const selectRichardsBooks = cds.ql`SELECT * FROM ${MoreDraftEnabledBooks} where author.name = 'Richard Carpenter'` await cds.run(updateRichardsBooks) diff --git a/test/scenarios/bookshop/upsert.test.js b/test/scenarios/bookshop/upsert.test.js index ba65ea179..93348bd84 100644 --- a/test/scenarios/bookshop/upsert.test.js +++ b/test/scenarios/bookshop/upsert.test.js @@ -10,7 +10,7 @@ describe('Bookshop - Upsert', () => { const { Values } = cds.entities const upsert = UPSERT({ ID: 201, value: 42 }).into(Values) const res = await upsert; - expect(res).to.eql(1) + expect(res.affected).to.eql(1) }) })