diff --git a/bookshop/test/dynamic-constraints/readme.md b/bookshop/test/dynamic-constraints/readme.md new file mode 100644 index 00000000..6660d649 --- /dev/null +++ b/bookshop/test/dynamic-constraints/readme.md @@ -0,0 +1,30 @@ +## Experimental Dynamic Constraints + +This example demonstrates how to use dynamic constraints in a CAP application. It includes a service definition and a test setup to validate the constraints. + + +### Prerequisites + +You've setup the [_cap/samples_](https://github.com/sap-samples/cloud-cap-samples) like so: + +```sh +git clone -q https://github.com/sap-samples/cloud-cap-samples cap/samples +cd cap/samples +npm install +``` + +### Testing + +Test like that in `cds.repl` from _cap/samples_ root: + +```sh +cds repl --run bookshop/test/dynamic-constraints +```` + +```javascript +await AdminService.create ('Books', {}) +await AdminService.create ('Books', { title:' ', author_ID:150 }) +await AdminService.create ('Books', { title:'x' }) +await cds.validate (Books.constraints, 201) +await cds.validate (Books.constraints) +``` diff --git a/bookshop/test/dynamic-constraints/server.js b/bookshop/test/dynamic-constraints/server.js new file mode 100644 index 00000000..9c230857 --- /dev/null +++ b/bookshop/test/dynamic-constraints/server.js @@ -0,0 +1,17 @@ +// +// Quick and dirty implementation for cds.validate() +// using db-level constraints. +// + +const cds = require('@sap/cds'); require('./validate.js') +cds.on('served', ()=> { + const { AdminService } = cds.services + AdminService.after (['CREATE','UPDATE'], (result,req) => cds.validate (req.subject, result)) +}) + + + +Object.defineProperties (cds.entity.prototype, { + constraints: { get() { return cds.model.definitions[this.name+'.constraints'] }}, + fields: { get() { return cds.model.definitions[this.name+'.field.control'] }}, +}) diff --git a/bookshop/test/dynamic-constraints/srv/admin-service.cds b/bookshop/test/dynamic-constraints/srv/admin-service.cds new file mode 100644 index 00000000..fd771d06 --- /dev/null +++ b/bookshop/test/dynamic-constraints/srv/admin-service.cds @@ -0,0 +1,7 @@ +namespace AdminService; //> for cds.entities + +using { AdminService } from '../../../srv/admin-service'; +annotate AdminService with @requires: false; +extend AdminService.Authors with columns { + null as books // to simulate the exclusion of books +} diff --git a/bookshop/test/dynamic-constraints/srv/field-control.cds b/bookshop/test/dynamic-constraints/srv/field-control.cds new file mode 100644 index 00000000..3b48b7a3 --- /dev/null +++ b/bookshop/test/dynamic-constraints/srv/field-control.cds @@ -0,0 +1,10 @@ +namespace sap.capire.bookshop; +using from './admin-service'; + +view Books.field.control as select from Books { ID, + genre.name == 'Drama' ? 'readonly' : + null as price +} +extend Books with { + fc : Association to Books.field.control on fc.ID = $self.ID +} diff --git a/bookshop/test/dynamic-constraints/srv/validation.cds b/bookshop/test/dynamic-constraints/srv/validation.cds new file mode 100644 index 00000000..4d733947 --- /dev/null +++ b/bookshop/test/dynamic-constraints/srv/validation.cds @@ -0,0 +1,76 @@ +using { AdminService, sap.capire.bookshop as my } from './admin-service'; + +extend service AdminService with { + + // entity Books.drafts as projection on AdminService.Books; + // @cds.api.ignore view Books.drafts.constraints as select from AdminService.Books.drafts mixin { + // before: Association to my.Books on before.ID = $self.ID; + // base: Association to my.Books on base.ID = $self.ID; + // } into { ID, // FIXME: compiler should resolve Books without AdminService prefix + // case + // when title is null then 'is missing' + // when trim(title)='' then 'must not be empty' + // end as title, + // ... + // } + + /** + * Validation constraints for Books + */ + @cds.api.ignore view Books.constraints as select from AdminService.Books mixin { + base: Association to my.Books on base.ID = $self.ID; + } into { ID, // FIXME: compiler should resolve Books without AdminService prefix + + // two-step mandatory check + case + when title is null then 'is missing' + when trim(title)='' then 'must not be empty' + end as title, + // the above is equivalent to: + // title is null ? 'is missing' : trim(title)='' ? 'must not be empty' : + + // range check + stock < 0 ? 'must not be negative' : + null as stock, + + // range check + price < 0 ? 'must not be negative' : + null as price, + + // assert target check + genre.ID is not null and not exists genre ? 'does not exist' : + null as genre, + + // multiple constraints: mandatory + assert target + special + author.ID is null ? 'is missing' : // FIXME: 1) // TODO: 2) + not exists author ? 'Author does not exist: ' || author.ID : + count(base.author.books.ID) -1 > 1 ? author.name || ' already wrote too many books' : // TODO: 3) + null as author, + + } group by ID; + + // 1) FIXME: expected author.ID to refer to foreign key, + // apparently that is not the case -> move one line up + // and run test to see the erroneous impact. + + // 2) TODO: we should allow to write author is null instead of author.ID is null + + // 3) TODO: we should support count(author.books) + + + /** + * Validation constraints for Authors + */ + view Authors.constraints as select from AdminService.Authors { ID, // FIXME: compiler should resolve Authors without AdminService prefix + + // two-step mandatory check + name = null ? 'is missing' : trim(name)='' ? 'must not be empty' : + null as name, + + // constraint related to two fields + dateOfDeath < dateOfBirth ? 'we can''t die before we are born' : null as _born_before_death, + $self._born_before_death as dateOfBirth, + $self._born_before_death as dateOfDeath, + + } +} diff --git a/bookshop/test/dynamic-constraints/validate.js b/bookshop/test/dynamic-constraints/validate.js new file mode 100644 index 00000000..22907005 --- /dev/null +++ b/bookshop/test/dynamic-constraints/validate.js @@ -0,0 +1,34 @@ +const cds = require('@sap/cds') +const $super = { validate: cds.validate, skip(){} } + + +/** + * Quick and dirty implementation for cds.validate() using db-level constraints. + */ +cds.validate = function (x, pk, ...columns) { + + // Delegate to base impl of cds.validate() for standard input validation + if (!_is_constraints(x)) return $super.skip (...arguments) + + // Support subject refs to base entities as arguments + if (x?.ref) [ x, pk ] = [ x.ref +'.constraints', pk.ID||pk ] + + // Run the constraints check query + const constraints = typeof x === 'string' ? cds.model.definitions[x] || cds.error `No such constraints view: ${x}` : x + return SELECT.from (constraints, pk, columns.length && columns) + + // Collect and throw errors, if any + .then (rows => (rows.map ? rows : [rows]).map (checks => { + const failed = {}; for (let c in checks) { + if (c in constraints.keys) continue + if (c[0] == '_') continue + if (checks[c]) failed[c] = checks[c] + } + if (Object.keys(failed).length) throw cds.error `Invalid input: ${failed}` + return checks + })) +} + + +// Helpers +const _is_constraints = x => x.ref || x.is_entity || typeof x === 'string' diff --git a/inspectr/srv/data-service.js b/inspectr/srv/data-service.js index e0a5c776..a9bfc355 100644 --- a/inspectr/srv/data-service.js +++ b/inspectr/srv/data-service.js @@ -52,7 +52,7 @@ module.exports = { DataService } /** @returns {cds.Service} */ function findDataSource(dataSourceName, entityName) { - for (let srv of Object.values(cds.services)) { // all connected services + for (let srv of cds.service.providers) { // all connected services if (!srv.name) continue // FIXME intermediate/pending in cds.services ? if (dataSourceName === srv.name || entityName.startsWith(srv.name+'.')) { log._debug && log.debug(`using ${srv.name} as data source`)