diff --git a/tests/dynamic-constraints/fts/some-feature.cds b/tests/dynamic-constraints/fts/some-feature.cds new file mode 100644 index 0000000..263f572 --- /dev/null +++ b/tests/dynamic-constraints/fts/some-feature.cds @@ -0,0 +1,5 @@ +using { sap.ariba.buying.Requisitions } from '../srv/etc/requisition'; + +annotate Requisitions with { + buyer @assert: (buyer is null ? 'is missing' : null); +} diff --git a/tests/dynamic-constraints/readme.md b/tests/dynamic-constraints/readme.md new file mode 100644 index 0000000..baf76ad --- /dev/null +++ b/tests/dynamic-constraints/readme.md @@ -0,0 +1,37 @@ +## 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/capire/samples) like so: + +```sh +git clone -b dynamic-constraints -q https://github.com/capire/samples cap/samples +cd cap/samples +npm install +``` + +### Testing + +Test like that in `cds.repl` from _cap/samples_ root: + +```sh +cds repl --run tests/dynamic-constraints +```` + +```js +await AdminService.create ('Books', {}) +await AdminService.create ('Books', { title:' ', author_ID:150 }) +await AdminService.create ('Books', { title:'x' }) +``` + +```js +await cds.validate (Books.constraints, 201) +await cds.validate (Books.constraints) +``` + +```js +await AdminService.read `ID, title, price, fc.price from Books` +``` diff --git a/tests/dynamic-constraints/server.js b/tests/dynamic-constraints/server.js new file mode 100644 index 0000000..c833857 --- /dev/null +++ b/tests/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'], (_,req) => cds.validate (req)) +}) + + + +Object.defineProperties (cds.entity.prototype, { + constraints: { get() { return cds.model.definitions[this.name+'.constraints'] }}, + controls: { get() { return cds.model.definitions[this.name+'.field.control'] }}, +}) diff --git a/tests/dynamic-constraints/srv/admin-service.cds b/tests/dynamic-constraints/srv/admin-service.cds new file mode 100644 index 0000000..25b1460 --- /dev/null +++ b/tests/dynamic-constraints/srv/admin-service.cds @@ -0,0 +1,14 @@ +using { AdminService } from '@capire/bookshop'; +namespace AdminService; //> for cds.entities + +annotate AdminService with @odata.draft.enabled; +annotate AdminService with @requires: false; + +extend AdminService.Authors with columns { + // null as books // to simulate the exclusion of books +} + +// Should be provided by CAP ootb: +extend AdminService.Books with columns { + active : Association to AdminService.Books on active.ID = $self.ID, +} diff --git a/tests/dynamic-constraints/srv/etc/catalog.cds b/tests/dynamic-constraints/srv/etc/catalog.cds new file mode 100644 index 0000000..3a8cea2 --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/catalog.cds @@ -0,0 +1,20 @@ +using { cuid, Currency } from '@sap/cds/common'; + +context sap.ariba.catalog { + + entity Product : cuid { // = ProductDescription in Ariba CG + name : String(111); + descr : String(1111); + price : Decimal(10,2); + stock : Integer; + suppliers : Association to many Suppliers; + } + + entity Suppliers : cuid { + name : String(111); + contact : String(111); + address : String(1111); + currency : Currency; + } + +} diff --git a/tests/dynamic-constraints/srv/etc/requisition.cds b/tests/dynamic-constraints/srv/etc/requisition.cds new file mode 100644 index 0000000..30cb706 --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/requisition.cds @@ -0,0 +1,31 @@ + +using { cuid } from '@sap/cds/common'; +using { sap.ariba.catalog.Product, sap.ariba.catalog.Suppliers } from './catalog'; + +type Price : Decimal(10,2); + +context sap.ariba.buying { + + entity Requisitions : cuid { + buyer : String; + Items : Composition of many LineItems on Items.parent = $self; + totalPrice : Price; + } + + entity LineItems { + key parent : Association to Requisitions; + key pos : Integer; + product : Association to Product; + supplier : Association to Suppliers; // + quantity : Integer; + // supplierCurrency : Currency; + }; + + // entity Product : sap.ariba.catalog.Product { + // product : Association to Product; + // } + entity Suppliers : sap.ariba.catalog.Suppliers { + product : Association to Product; + } + +} diff --git a/tests/dynamic-constraints/srv/etc/some-body-else.cds b/tests/dynamic-constraints/srv/etc/some-body-else.cds new file mode 100644 index 0000000..6806745 --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/some-body-else.cds @@ -0,0 +1,16 @@ +using { sap.ariba.buying } from './requisition'; +context other { + + aspect managed { + createdBy: User @assert: (createdBy is not null ? null : 'is missing'); + createdAt: DateTime; + lastModifiedBy: User; + lastModifiedAt: DateTime; + } + + type User : String; + + extend buying.Requisitions with managed; + extend buying.Suppliers with managed; + +} diff --git a/tests/dynamic-constraints/srv/etc/validation-aspects.cds b/tests/dynamic-constraints/srv/etc/validation-aspects.cds new file mode 100644 index 0000000..5b93d5d --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/validation-aspects.cds @@ -0,0 +1,36 @@ +using { sap.capire.bookshop.Books } from '@capire/bookshop'; + + +/** + * Validation constraints for Books + */ +@validations aspect AdminService.Books.constraints.aspect : Books { + + // two-step mandatory check + check_title = case + when title is null then 'is missing' + when trim(title)='' then 'must not be empty' + end; + + check_title2 = ( + title is null ? 'is missing' : + trim(title)='' ? 'must not be empty' : null + ); + + // range check + check_stock = stock < 0 ? 'must not be negative' : null; + + // range check + check_price = price < 0 ? 'must not be negative' : null; + + // assert target check + // check_genre = genre is not null and not exists genre ? 'does not exist' : null; + + // multiple constraints: mandatory + assert target + special + check_author = case + when author.ID is null then 'is missing' // FIXME: 1) // TODO: 2) + // when not exists author then 'Author does not exist: ' || author.ID + when count(author.books.ID) -1 > 1 then author.name || ' already wrote too many books' // TODO: 3) + when /* exists */ author.books[genre.name like '%Noire%'] then 'Author has written a Noire book' + end +} diff --git a/tests/dynamic-constraints/srv/etc/validation-asserts.cds b/tests/dynamic-constraints/srv/etc/validation-asserts.cds new file mode 100644 index 0000000..c4eb087 --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/validation-asserts.cds @@ -0,0 +1,72 @@ +using { sap.capire.bookshop.Books } from '@capire/bookshop'; + +// @mandatory +// @readonly +// @hidden @visible @inapplicable + +// @assert.range +// @assert.format +// @assert.target + +// Following are invariant constraints declared on domain model entity +annotate Books with { + + // manual two-step mandatory constraint + // title @assert.constraint: { + // not_null: { condition: (title is not null), message: 'is missing' }, + // not_empty: { condition: (trim(title) != ''), message: 'must not be empty' }, + // }; + + // manual two-step mandatory constraint + title @assert: (case + when title is null then 'is missing' + when trim(title)='' then 'must not be empty' + end); + + // range check + stock @assert: (case + when stock <= 0 then 'must not a positive number' + end); + + // range check + price @assert: (case + // when price is not null and not price between 0 and 500 then 'must be between 0 and 500' + when price <= 0 or price > 500 then 'must be between 0 and 500' + end); + + // assert target check + // genre @assert: (case + // when genre is not null and not exists genre then 'does not exist' + // end); + + genre @assert: (case + when genre is null then null // genre may be null + when not exists genre then 'does not exist' + end); + + // multiple constraints: mandatory + assert target, ... + author @assert: (case + when author is null then 'is missing' + when not exists author then 'does not exist' + end); +} + + +// Following need to go on service-level entity, as rewriting would fail for CatalogService +annotate AdminService.Books with { + + // ... + special + author @assert: (case + when sum(author.books.price) > 111 then author.name || ' already earned too much with their books' + when count(author.books.ID) -1 > 1 then author.name || ' already wrote too many books' + // FIXME: ^^^^^^^^^^^^^^^^ cqn4sql doesn't support count(author.books) yet + end); + + price @mandatory: (exists author.books.genre[name = 'Drama']); + + price @assert: (case + when price is null and exists author.books.genre[name = 'Drama'] + then 'Price must be specified for books by drama queens' + end); + +} diff --git a/tests/dynamic-constraints/srv/etc/validation-views.cds b/tests/dynamic-constraints/srv/etc/validation-views.cds new file mode 100644 index 0000000..b390cee --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/validation-views.cds @@ -0,0 +1,80 @@ +using { AdminService, sap.capire.bookshop as my } from '../admin-service'; + +// 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 + */ +@validation view AdminService.Books.constraints as select from AdminService.Books mixin { + base: Association to my.Books on base.ID = $self.ID // Should be provided by CAP ootb +} into { + ID, + + // 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, + + genre.name as _genre, + + // multiple constraints: mandatory + assert target + special + case + when author.ID is null then 'is missing' // FIXME: 1) // TODO: 2) + when not exists author then 'Author does not exist: ' || author.ID + when sum(base.author.books.price) > 111 then author.name || ' already earned too much' // TODO: 3) + end as author, + +} group by ID; // because of the count(base.author.books) above + +// 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 + */ +@validation view AdminService.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, // reuse condition + $self._born_before_death as dateOfBirth, + $self._born_before_death as dateOfDeath, + +} + + +annotate AdminService.Books.constraints with @cds.api.ignore @odata.draft.enabled: false; diff --git a/tests/dynamic-constraints/srv/field-control.cds b/tests/dynamic-constraints/srv/field-control.cds new file mode 100644 index 0000000..37d3af9 --- /dev/null +++ b/tests/dynamic-constraints/srv/field-control.cds @@ -0,0 +1,11 @@ +using { AdminService } from './admin-service'; + +@fieldcontrol view AdminService.Books.field.control as select from AdminService.Books { ID, + genre.name == 'Drama' ? 'readonly' : + null as price +} + +// Make that available to Fiori clients +extend AdminService.Books with columns { + fc : Association to AdminService.Books.field.control on fc.ID = $self.ID +} diff --git a/tests/dynamic-constraints/srv/validation.cds b/tests/dynamic-constraints/srv/validation.cds new file mode 100644 index 0000000..be0b242 --- /dev/null +++ b/tests/dynamic-constraints/srv/validation.cds @@ -0,0 +1 @@ +using from './etc/validation-asserts'; diff --git a/tests/dynamic-constraints/validate.js b/tests/dynamic-constraints/validate.js new file mode 100644 index 0000000..4867dc4 --- /dev/null +++ b/tests/dynamic-constraints/validate.js @@ -0,0 +1,63 @@ +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 (req) { + if (req.is_entity) { + const asserts = _collect_asserts4 (req); if (!asserts.length) return + const vq = SELECT.from (req) .columns (asserts) + return vq .then (_handle_results) + } + if (req instanceof cds.Request === false) return // $super.validate (...arguments) + const vq = _validation_query4 (req) + return vq .then (_handle_results) +} + +function _validation_query4 (req) { + let pk = _key_from_data (req) + let constraints = cds.model.definitions [req.target.name + '.constraints'] + if (constraints) { + const asserts = constraints.query.columns .filter (c => c.as[0] !== '_' && !((c.as || c.ref[0]) in pk)) + return SELECT.from (constraints, pk) .columns (asserts) + } else { + const asserts = _collect_asserts4 (req.target) + return SELECT.from (req.target, pk) .columns (asserts) + } +} + +function _collect_asserts4 (entity) { + const cols = [] + for (let e of entity.elements) { + if (e.$struct) continue // skip structured elements + let xpr = _asserts4 (e) + if (xpr) cols.push({ xpr, as: e.name }) + } + return cols +} + +function _asserts4 (e) { + let xpr = e?.['@assert']?.xpr; if (!xpr) return + let inherited = _asserts4 (e.parent.__proto__.elements?.[e.name]) + if (inherited) xpr = [ ...inherited.slice(0,-1), ...xpr.slice(1) ] + return xpr +} + +function _handle_results (rows) { + if (!Array.isArray(rows)) rows = [rows] + return rows.map (checks => { + const failed = {}; for (let c in checks) + if (checks[c]) failed[c] = checks[c] + if (Object.keys(failed).length) throw cds.error `Invalid input: ${failed}` + return checks + }) +} + +function _key_from_data (req) { + const pk = {} + for (let k in req.target.keys) + if (k in req.data) pk[k] = req.data[k] + return pk +} \ No newline at end of file