From efc5b9f400a1dd2ca8928f21b395ccc9de7a51ca Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Wed, 20 Aug 2025 07:57:27 +0200 Subject: [PATCH 1/7] Experiments for dynamic constraints --- bookshop | 2 +- bookstore | 2 +- common | 2 +- orders | 2 +- reviews | 2 +- shared-db | 2 +- support | 2 +- tests/dynamic-constraints/readme.md | 30 +++++++ tests/dynamic-constraints/server.js | 17 ++++ .../dynamic-constraints/srv/admin-service.cds | 14 ++++ .../srv/etc/validation-aspects.cds | 36 +++++++++ .../dynamic-constraints/srv/field-control.cds | 10 +++ tests/dynamic-constraints/srv/validation.cds | 80 +++++++++++++++++++ tests/dynamic-constraints/validate.js | 34 ++++++++ 14 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 tests/dynamic-constraints/readme.md create mode 100644 tests/dynamic-constraints/server.js create mode 100644 tests/dynamic-constraints/srv/admin-service.cds create mode 100644 tests/dynamic-constraints/srv/etc/validation-aspects.cds create mode 100644 tests/dynamic-constraints/srv/field-control.cds create mode 100644 tests/dynamic-constraints/srv/validation.cds create mode 100644 tests/dynamic-constraints/validate.js diff --git a/bookshop b/bookshop index b63c702..b97669d 160000 --- a/bookshop +++ b/bookshop @@ -1 +1 @@ -Subproject commit b63c7026912924b1e3b80550f1f5545efc22d93b +Subproject commit b97669df334a4013a1302636ea12a931415eb61b diff --git a/bookstore b/bookstore index aeecd4c..05a9e65 160000 --- a/bookstore +++ b/bookstore @@ -1 +1 @@ -Subproject commit aeecd4c85582f49064d19c5195fab91376fdde0c +Subproject commit 05a9e65870c317d4cc7c87964052b306fd9b5e19 diff --git a/common b/common index 6fe89fb..56a29d5 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit 6fe89fb51a6b06cf65d18f6d66ef880545a9054f +Subproject commit 56a29d5370271f99b7aace6d562ab868193b792e diff --git a/orders b/orders index 726431b..9cfe5d5 160000 --- a/orders +++ b/orders @@ -1 +1 @@ -Subproject commit 726431bf3788d335b011b57914270b9c8770fd79 +Subproject commit 9cfe5d50b0b24840f2b5349d4c862ff03fd84c58 diff --git a/reviews b/reviews index 9a8a608..f2d1db2 160000 --- a/reviews +++ b/reviews @@ -1 +1 @@ -Subproject commit 9a8a608a4005b41d45e1169951c89ec08e2abe0b +Subproject commit f2d1db225e54438d6055f5d8bfefeeb02aa73f3b diff --git a/shared-db b/shared-db index a8ea11b..c5c923b 160000 --- a/shared-db +++ b/shared-db @@ -1 +1 @@ -Subproject commit a8ea11b774d1a453d5b85cb9959f479fcc804c53 +Subproject commit c5c923b23ce9d93455343c02fe236f7b2b904327 diff --git a/support b/support index f325340..23b495f 160000 --- a/support +++ b/support @@ -1 +1 @@ -Subproject commit f32534054ff4e30c977c6dbef6785ca3d296a62f +Subproject commit 23b495f94c41663c49b1da1e4598c3d1eedbc2f5 diff --git a/tests/dynamic-constraints/readme.md b/tests/dynamic-constraints/readme.md new file mode 100644 index 0000000..4f27918 --- /dev/null +++ b/tests/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/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 +```` + +```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/tests/dynamic-constraints/server.js b/tests/dynamic-constraints/server.js new file mode 100644 index 0000000..a2c7d7b --- /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'], (result,req) => cds.validate (req.subject, result)) +}) + + + +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..a30130b --- /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/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/field-control.cds b/tests/dynamic-constraints/srv/field-control.cds new file mode 100644 index 0000000..3b48b7a --- /dev/null +++ b/tests/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/tests/dynamic-constraints/srv/validation.cds b/tests/dynamic-constraints/srv/validation.cds new file mode 100644 index 0000000..b3f340b --- /dev/null +++ b/tests/dynamic-constraints/srv/validation.cds @@ -0,0 +1,80 @@ +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 + */ + @validation view 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, + + // 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 + */ + @validation 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, + + } +} + + +annotate AdminService.Books.constraints with @cds.api.ignore @odata.draft.enabled: false; diff --git a/tests/dynamic-constraints/validate.js b/tests/dynamic-constraints/validate.js new file mode 100644 index 0000000..2290700 --- /dev/null +++ b/tests/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' From dde55a9d558b373f3a335fca42c89ac1557f4232 Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Wed, 20 Aug 2025 07:59:15 +0200 Subject: [PATCH 2/7] Adjusted branch --- tests/dynamic-constraints/readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/dynamic-constraints/readme.md b/tests/dynamic-constraints/readme.md index 4f27918..cb7f2de 100644 --- a/tests/dynamic-constraints/readme.md +++ b/tests/dynamic-constraints/readme.md @@ -5,10 +5,10 @@ This example demonstrates how to use dynamic constraints in a CAP application. I ### Prerequisites -You've setup the [_cap/samples_](https://github.com/sap-samples/cloud-cap-samples) like so: +You've setup the [_cap/samples_](https://github.com/capire/samples) like so: ```sh -git clone -q https://github.com/capire/samples cap/samples +git clone -b dynamic-constraints -q https://github.com/capire/samples cap/samples cd cap/samples npm install ``` From d12dd76c182d2c299f57fa25839dcfabf22a0bd1 Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Wed, 20 Aug 2025 09:04:33 +0200 Subject: [PATCH 3/7] Showcase field control --- tests/dynamic-constraints/readme.md | 9 +- .../dynamic-constraints/srv/field-control.cds | 11 +- tests/dynamic-constraints/srv/validation.cds | 145 +++++++++--------- 3 files changed, 85 insertions(+), 80 deletions(-) diff --git a/tests/dynamic-constraints/readme.md b/tests/dynamic-constraints/readme.md index cb7f2de..baf76ad 100644 --- a/tests/dynamic-constraints/readme.md +++ b/tests/dynamic-constraints/readme.md @@ -21,10 +21,17 @@ Test like that in `cds.repl` from _cap/samples_ root: cds repl --run tests/dynamic-constraints ```` -```javascript +```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/srv/field-control.cds b/tests/dynamic-constraints/srv/field-control.cds index 3b48b7a..37d3af9 100644 --- a/tests/dynamic-constraints/srv/field-control.cds +++ b/tests/dynamic-constraints/srv/field-control.cds @@ -1,10 +1,11 @@ -namespace sap.capire.bookshop; -using from './admin-service'; +using { AdminService } from './admin-service'; -view Books.field.control as select from Books { ID, +@fieldcontrol view AdminService.Books.field.control as select from AdminService.Books { ID, genre.name == 'Drama' ? 'readonly' : null as price } -extend Books with { - fc : Association to Books.field.control on fc.ID = $self.ID + +// 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 index b3f340b..cef8e47 100644 --- a/tests/dynamic-constraints/srv/validation.cds +++ b/tests/dynamic-constraints/srv/validation.cds @@ -1,79 +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 - */ - @validation view 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, - - // 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 - */ - @validation 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, - - } +// 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, + + // 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; // 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, + } From 49df06544b635669efbcd5b5ed0bd3cda8b15d48 Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Sun, 24 Aug 2025 14:45:56 +0200 Subject: [PATCH 4/7] ... --- .../dynamic-constraints/fts/some-feature.cds | 5 ++ tests/dynamic-constraints/server.js | 2 +- .../dynamic-constraints/srv/admin-service.cds | 2 +- tests/dynamic-constraints/srv/etc/catalog.cds | 20 +++++ .../srv/etc/requisition.cds | 31 +++++++ .../srv/etc/some-body-else.cds | 16 ++++ .../srv/etc/validation-asserts.cds | 47 +++++++++++ .../srv/etc/validation-views.cds | 80 +++++++++++++++++++ tests/dynamic-constraints/srv/validation.cds | 78 +----------------- tests/dynamic-constraints/validate.js | 65 ++++++++++----- 10 files changed, 249 insertions(+), 97 deletions(-) create mode 100644 tests/dynamic-constraints/fts/some-feature.cds create mode 100644 tests/dynamic-constraints/srv/etc/catalog.cds create mode 100644 tests/dynamic-constraints/srv/etc/requisition.cds create mode 100644 tests/dynamic-constraints/srv/etc/some-body-else.cds create mode 100644 tests/dynamic-constraints/srv/etc/validation-asserts.cds create mode 100644 tests/dynamic-constraints/srv/etc/validation-views.cds 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/server.js b/tests/dynamic-constraints/server.js index a2c7d7b..c833857 100644 --- a/tests/dynamic-constraints/server.js +++ b/tests/dynamic-constraints/server.js @@ -6,7 +6,7 @@ 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)) + AdminService.after (['CREATE','UPDATE'], (_,req) => cds.validate (req)) }) diff --git a/tests/dynamic-constraints/srv/admin-service.cds b/tests/dynamic-constraints/srv/admin-service.cds index a30130b..25b1460 100644 --- a/tests/dynamic-constraints/srv/admin-service.cds +++ b/tests/dynamic-constraints/srv/admin-service.cds @@ -5,7 +5,7 @@ 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 + // null as books // to simulate the exclusion of books } // Should be provided by CAP ootb: 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-asserts.cds b/tests/dynamic-constraints/srv/etc/validation-asserts.cds new file mode 100644 index 0000000..c03836e --- /dev/null +++ b/tests/dynamic-constraints/srv/etc/validation-asserts.cds @@ -0,0 +1,47 @@ +using { sap.capire.bookshop.Books } from '@capire/bookshop'; + +// Following are invariant constraints declared on domain model entity +annotate Books with { + + // 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 be negative' + end); + + // range check + price @assert: (case + 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); + + // multiple constraints: mandatory + assert target + special + 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 { + + author @assert: (case + when sum(author.books.price) > 111 then author.name || ' already earned too much with his/her 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 @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/validation.cds b/tests/dynamic-constraints/srv/validation.cds index cef8e47..be0b242 100644 --- a/tests/dynamic-constraints/srv/validation.cds +++ b/tests/dynamic-constraints/srv/validation.cds @@ -1,77 +1 @@ -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, - - // 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; // 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; +using from './etc/validation-asserts'; diff --git a/tests/dynamic-constraints/validate.js b/tests/dynamic-constraints/validate.js index 2290700..4867dc4 100644 --- a/tests/dynamic-constraints/validate.js +++ b/tests/dynamic-constraints/validate.js @@ -5,30 +5,59 @@ const $super = { validate: cds.validate, skip(){} } /** * Quick and dirty implementation for cds.validate() using db-level constraints. */ -cds.validate = function (x, pk, ...columns) { +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) +} - // Delegate to base impl of cds.validate() for standard input validation - if (!_is_constraints(x)) return $super.skip (...arguments) +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) + } +} - // Support subject refs to base entities as arguments - if (x?.ref) [ x, pk ] = [ x.ref +'.constraints', pk.ID||pk ] +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 +} - // 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) +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 +} - // 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 +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 - })) + }) } - -// Helpers -const _is_constraints = x => x.ref || x.is_entity || typeof x === 'string' +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 From 069260426eea0a27bf81443fc42cfc73822b28eb Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Mon, 25 Aug 2025 14:03:28 +0200 Subject: [PATCH 5/7] . --- .../srv/etc/validation-asserts.cds | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/dynamic-constraints/srv/etc/validation-asserts.cds b/tests/dynamic-constraints/srv/etc/validation-asserts.cds index c03836e..d3bfb98 100644 --- a/tests/dynamic-constraints/srv/etc/validation-asserts.cds +++ b/tests/dynamic-constraints/srv/etc/validation-asserts.cds @@ -1,8 +1,21 @@ 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' @@ -11,7 +24,7 @@ annotate Books with { // range check stock @assert: (case - when stock < 0 then 'must not be negative' + when stock <= 0 then 'must not a positive number' end); // range check @@ -35,13 +48,16 @@ annotate Books with { annotate AdminService.Books with { author @assert: (case - when sum(author.books.price) > 111 then author.name || ' already earned too much with his/her books' + 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); + } From 68ddcd0557cbb2b37aa57e7cf1bba9d0d5bc355b Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Mon, 25 Aug 2025 14:46:01 +0200 Subject: [PATCH 6/7] . --- bookshop | 2 +- bookstore | 2 +- common | 2 +- orders | 2 +- reviews | 2 +- shared-db | 2 +- support | 2 +- .../srv/etc/validation-asserts.cds | 16 ++++++++++++---- 8 files changed, 19 insertions(+), 11 deletions(-) diff --git a/bookshop b/bookshop index b97669d..721a529 160000 --- a/bookshop +++ b/bookshop @@ -1 +1 @@ -Subproject commit b97669df334a4013a1302636ea12a931415eb61b +Subproject commit 721a52904521df772ace81fb468340575b54765a diff --git a/bookstore b/bookstore index 05a9e65..1fd04f2 160000 --- a/bookstore +++ b/bookstore @@ -1 +1 @@ -Subproject commit 05a9e65870c317d4cc7c87964052b306fd9b5e19 +Subproject commit 1fd04f29840c81a8cc3072589bc411af85c7c7f6 diff --git a/common b/common index 56a29d5..f58e886 160000 --- a/common +++ b/common @@ -1 +1 @@ -Subproject commit 56a29d5370271f99b7aace6d562ab868193b792e +Subproject commit f58e8861e359f773c69c0a4fb4292ec73ba26b15 diff --git a/orders b/orders index 9cfe5d5..af390da 160000 --- a/orders +++ b/orders @@ -1 +1 @@ -Subproject commit 9cfe5d50b0b24840f2b5349d4c862ff03fd84c58 +Subproject commit af390da8b74b4460f01f73c27373e0e16c3c73b2 diff --git a/reviews b/reviews index f2d1db2..207e086 160000 --- a/reviews +++ b/reviews @@ -1 +1 @@ -Subproject commit f2d1db225e54438d6055f5d8bfefeeb02aa73f3b +Subproject commit 207e0869f3d94564d863d7cce3e780a3d16633ef diff --git a/shared-db b/shared-db index c5c923b..af1ab4a 160000 --- a/shared-db +++ b/shared-db @@ -1 +1 @@ -Subproject commit c5c923b23ce9d93455343c02fe236f7b2b904327 +Subproject commit af1ab4a68d71c8b63e7e2c5b462f32be85f8eb67 diff --git a/support b/support index 23b495f..73ad0b8 160000 --- a/support +++ b/support @@ -1 +1 @@ -Subproject commit 23b495f94c41663c49b1da1e4598c3d1eedbc2f5 +Subproject commit 73ad0b814b0990677dda5569637f61425cfee2c5 diff --git a/tests/dynamic-constraints/srv/etc/validation-asserts.cds b/tests/dynamic-constraints/srv/etc/validation-asserts.cds index d3bfb98..7b697fe 100644 --- a/tests/dynamic-constraints/srv/etc/validation-asserts.cds +++ b/tests/dynamic-constraints/srv/etc/validation-asserts.cds @@ -29,24 +29,32 @@ annotate Books with { // 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); + 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 not null and not exists genre then 'does not exist' - end); + when genre is null then null // genre may be null + when not exists genre then 'does not exist' + end); - // multiple constraints: mandatory + assert target + special + // 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' From 9253f9289ca9516b8214d92524849243ee170621 Mon Sep 17 00:00:00 2001 From: Daniel Hutzel Date: Mon, 25 Aug 2025 14:53:19 +0200 Subject: [PATCH 7/7] . --- tests/dynamic-constraints/srv/etc/validation-asserts.cds | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/dynamic-constraints/srv/etc/validation-asserts.cds b/tests/dynamic-constraints/srv/etc/validation-asserts.cds index 7b697fe..c4eb087 100644 --- a/tests/dynamic-constraints/srv/etc/validation-asserts.cds +++ b/tests/dynamic-constraints/srv/etc/validation-asserts.cds @@ -2,7 +2,8 @@ using { sap.capire.bookshop.Books } from '@capire/bookshop'; // @mandatory // @readonly -// @hidden / visible / inapplicable +// @hidden @visible @inapplicable + // @assert.range // @assert.format // @assert.target