diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 837cfb929..5daf5d82c 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -67,6 +67,7 @@ class SQLService extends DatabaseService { this.on(['DELETE'], this.onDELETE) this.on(['CREATE ENTITY', 'DROP ENTITY'], this.onSIMPLE) this.on(['BEGIN', 'COMMIT', 'ROLLBACK'], this.onEVENT) + this.before(['COMMIT'], this.onASSERT) this.on(['*'], this.onPlainSQL) return super.init() } @@ -299,6 +300,26 @@ class SQLService extends DatabaseService { return await this.exec(event) } + /** + * Handler before COMMIT to validate the new database state + * @type {Handler} + */ + async onASSERT(req) { + const query = require('./assert.js')(this) + if (!query) return + const service = cds.context.tx.name + // Filter errors by transaction service as leaking error from other entities could leak information + // The database service is responsible for all entities therefor doesn't filter the targets + const errors = await this.run(query, [service === 'db' ? '' : service]) + if (errors.length) for (const error of errors) { + const e = {} + try { Object.assign(e, JSON.parse(error.message)) } catch { e.message = e.message } + // REVISIT: UI5 doesn't respect the full path target + e.target = 'in/' + error.target.slice(error.target.lastIndexOf('/') + 1) + req.error(e) + } + } + /** * Handler for SQL statements which don't have any CQN * @type {Handler} diff --git a/db-service/lib/assert.js b/db-service/lib/assert.js new file mode 100644 index 000000000..da6c6937f --- /dev/null +++ b/db-service/lib/assert.js @@ -0,0 +1,39 @@ +const cds = require('@sap/cds') + +module.exports = function (db) { + const { model } = db + const asserts = [] + for (const entity of model.each(cds.builtin.classes.entity)) { + const target = !entity.keys + ? { val: null, param: false, as: 'target' } + : { + func: 'concat', + as: 'target', + args: [ + { val: entity.name, param: false }, + { val: '(' }, ...Object.keys(entity.keys).filter(k => !entity.keys[k].virtual && !entity.keys[k].isAssociation).map((k, i) => [{ val: (i ? ',' : '') + k + '=', param: false }, { ref: [k] }]).flat(), { val: ')' } + ] + } + for (const element of entity.elements) { + if (!element['@assert']) continue + asserts.push( + cds.ql.SELECT([ + { __proto__: element['@assert'], as: 'message' }, + { ...target, args: [...target.args, { val: '/' + element.name }] }, + ]).from(entity) + ) + } + } + if (asserts.length === 0) return + + const sqls = [] + const CQN2SQL = db.class.CQN2SQL + for (const query of asserts) { + const q = db.cqn4sql(query) + const renderer = new CQN2SQL(q) + renderer.SELECT(q) + sqls.push(renderer.sql) + } + + return `SELECT * FROM (${sqls.join(' UNION ALL ')}) WHERE message IS NOT NULL AND target like (? || '%')` +} diff --git a/db-service/lib/cql-functions.js b/db-service/lib/cql-functions.js index b70d33d8e..3c7d9fd40 100644 --- a/db-service/lib/cql-functions.js +++ b/db-service/lib/cql-functions.js @@ -4,6 +4,57 @@ const cds = require('@sap/cds') // OData: https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_CanonicalFunctions const StandardFunctions = { + + /** + * Generates SQL statement that produces an runtime compatible error object + * @param {string|object} code - The i18n code or message of the error object + * @param {Array} args - The arguments to apply to the i18n string + * @return {string} - SQL statement + */ + error: function (code, ...args) { + let targets, message + if (typeof code === 'object') { + args = code.args?.list + targets = code.targets?.list + message = code.message + code = code.code + } + + return `(${this.SELECT({ + SELECT: { + expand: 'root', + columns: [ + args ? { + func: 'json_array', + args: args, + as: 'args', + element: cds.builtin.types.Map, + } : { val: null, as: 'args' }, + targets ? { + func: 'json_array', + args: targets, + as: 'targets', + element: cds.builtin.types.Map, + } : { val: null, as: 'targets' }, + { + __proto__: (message || { val: null }), + as: 'message', + }, + { + __proto__: (code || { val: null }), + as: 'code', + }, + ] + }, + elements: { + args: cds.builtin.types.Map, + targets: cds.builtin.types.Map, + message: cds.builtin.types.String, + code: cds.builtin.types.String, + } + })})` + }, + /** * Generates SQL statement that produces a boolean value indicating whether the search term is contained in the given columns * @param {string} ref - The reference object containing column information diff --git a/db-service/lib/cqn2sql.js b/db-service/lib/cqn2sql.js index c72285f6b..48a7dfe2a 100644 --- a/db-service/lib/cqn2sql.js +++ b/db-service/lib/cqn2sql.js @@ -1320,7 +1320,7 @@ class CQN2SQLRenderer { } else { cds.error`Invalid arguments provided for function '${func}' (${args})` } - const fn = this.class.Functions[func]?.apply(this, args) || `${func}(${args})` + const fn = this.class.Functions[func]?.apply(this, Array.isArray(args) ? args: [args]) || `${func}(${args})` if (xpr) return `${fn} ${this.xpr({ xpr })}` return fn }