diff --git a/db-service/lib/SQLService.js b/db-service/lib/SQLService.js index 837cfb929..7f4a2d25d 100644 --- a/db-service/lib/SQLService.js +++ b/db-service/lib/SQLService.js @@ -5,6 +5,7 @@ const { pipeline } = require('stream/promises') const { resolveView, getDBTable, getTransition } = require('@sap/cds/libx/_runtime/common/utils/resolveView') const DatabaseService = require('./common/DatabaseService') const cqn4sql = require('./cqn4sql') +const { attachConstraints, checkConstraints } = require('./assert-constraint') const BINARY_TYPES = { 'cds.Binary': 1, @@ -37,6 +38,8 @@ const _hasProps = (obj) => { class SQLService extends DatabaseService { init() { this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./fill-in-keys')) // REVISIT should be replaced by correct input processing eventually + this.after(['INSERT', 'UPSERT', 'UPDATE'], attachConstraints) + this.after(['INSERT', 'UPSERT', 'UPDATE'], checkConstraints) this.on(['INSERT', 'UPSERT', 'UPDATE'], require('./deep-queries').onDeep) if (cds.env.features.db_strict) { this.before(['INSERT', 'UPSERT', 'UPDATE'], ({ query }) => { diff --git a/db-service/lib/assert-constraint/index.js b/db-service/lib/assert-constraint/index.js new file mode 100644 index 000000000..5f6e8bf90 --- /dev/null +++ b/db-service/lib/assert-constraint/index.js @@ -0,0 +1,120 @@ +'use strict' + +const { getValidationQuery, buildMessage, getConstraintsByTarget } = require('./utils') + +const constraintStorage = require('./storage') + +/** + * + * “Before-hook” that gathers every `@assert.constraint` touched by the current + * request, optionally augments them with an extra filter (the + * `where` of an UPDATE/UPSERT), and merges the result into + * {@link constraintStorage} so it can be validated once per transaction in + * {@link checkConstraints}. + * + * 1. Skip CSV/multipart inserts. + * 2. Call `getConstraintsByTarget()` to obtain a `Map` + * `targetName → ConstraintDict`. + * 3. If the root entity is an UPDATE/UPSERT, retrieve its filter via + * `getWhereOfPatch(req)` and push it into the `where` array of every + * constraint that belongs to that root entity. + * 4. For each target entity, merge the (possibly augmented) constraints into + * the per-transaction storage with `constraintStorage.merge(this.tx, …)`. + * + * Notes + * ───────────────────────────────────────────────────────────────── + * • No validation queries are built here; we only stash **metadata**. + * • The function is idempotent within the same transaction because + * `constraintStorage.merge` deduplicates constraints and WHEREs. + * + * @this {import('@sap/cds').Service} CAP service or transaction object + * @param {*} _res (ignored — payload from previous hook) + * @param {import('@sap/cds').Request} req Current request being processed + */ +function attachConstraints(_res, req) { + if (Array.isArray(req.data?.[0])) return // ignore CSV / multipart + + const byTarget = getConstraintsByTarget(req.target, req.data) + if (!byTarget.size) return + + for (const [targetName, constraints] of byTarget) { + constraintStorage.merge(this.tx, targetName, constraints) + } +} + +/** + * Validate all pending constraints for the current transaction. + * + * 1. Collect constraints from `constraintStorage`. + * 2. Build **one** SQL query per target and run them in the current tx. + * 3. For each failed row: + * - Lazily fetch the message params **only when** the constraint is violated. + * - Build a message, and register it with `req.error(...)`, + * including all affected targets for the UI (`@Common.additionalTargets`). + * 4. Clear the constraint cache for this transaction. + * + * @param {import('@sap/cds').Request} req – CDS request that will hold any validation errors. + */ +async function checkConstraints(_res, req) { + const constraintsPerTarget = constraintStorage.get(this.tx) + if (!constraintsPerTarget.size) return + + // build exactly one query per bucket + const queries = [] + for (const [targetName, constraints] of constraintsPerTarget) { + if(targetName === req?.target.name) + queries.push(getValidationQuery(targetName, constraints, req)) + else // deep + queries.push(getValidationQuery(targetName, constraints)) + } + + const results = await this.run(queries) + + // key = message text value = array of targets (order preserved) + // this way messages are deduplicated + const messages = new Map() + + for (const [i, rows] of results.entries()) { + const constraints = queries[i].$constraints + const paramQuery = queries[i].$paramQuery + let params + + for (const [name, meta] of Object.entries(constraints)) { + const col = `${name}_constraint` + + // request params in separate query, because constraint has failed + if (paramQuery && rows.some(r => r[col] === false)) { + // use the same transaction as the query (or it would re-trigger the handler endlessly) + params = await this.tx().run(paramQuery) + } + + for (const [j, r] of rows.entries()) { + if (r[col]) continue // row satisfied the constraint → no error + + const row = params ? { ...r, ...params[j] } : r // merge only if needed + + const text = buildMessage(name, meta, row) + const targets = meta.targets?.length ? meta.targets : [{ ref: [meta.element.name] }] + + ;(messages.get(text) ?? []).push(...targets) + messages.set(text, targets) + } + } + } + + for (const [text, targetList] of messages) { + req.error({ + code: 400, + message: text, + target: targetList[0]?.ref.join('/'), + '@Common.additionalTargets': targetList.slice(1).map(t => t.ref.join('/')), + }) + } + + constraintStorage.clear(this.tx) +} + +module.exports = { + attachConstraints, + checkConstraints, +} diff --git a/db-service/lib/assert-constraint/storage.js b/db-service/lib/assert-constraint/storage.js new file mode 100644 index 000000000..34379b43e --- /dev/null +++ b/db-service/lib/assert-constraint/storage.js @@ -0,0 +1,66 @@ +'use strict' + +const _constraintStorage = new WeakMap() // tx → Map + +function dedupWhere(arr) { + const seen = new Set() + return arr.filter(x => { + const key = JSON.stringify(x) // keep track of expressions already seen + return !seen.has(key) && seen.add(key) + }) +} + +function mergeConstraint(a, b) { + const merged = { ...a, ...b } + + const where = [...(a.where ?? []), ...(b.where ?? [])] + merged.where = dedupWhere(where) + + return merged +} + +/** + * constraintStorage + * + * In-memory repository that collects assert-constraint metadata + * _per CDS transaction_ and _per target entity_. + * The data is stored in a `WeakMap`, so everything is garbage-collected + * automatically when the transaction object becomes unreachable at the end of + * the request. + * + * Storage structure + * ───────────────── + * WeakMap + * └─ key : cds.Transaction + * └─ value : Map + * └─ key : target entity name (e.g. 'bookshop.Books') + * └─ value : { [constraintName]: ConstraintMeta } + * + * `ConstraintMeta` is the object returned by `collectConstraints()` and may + * contain `condition`, `parameters`, `where`, … – see utils.js. + */ +module.exports = { + /** + * Merge a set of constraints into the bucket that belongs to this tx+entity. + * @param {import('@sap/cds').Transaction} tx + * @param {string} targetName + * @param {object} constraints result of utils.getConstraintsByTarget().get(targetName) + */ + merge(tx, targetName, constraints) { + const txMap = _constraintStorage.get(tx) ?? new Map() + const existingConstraintsForTarget = txMap.get(targetName) ?? {} + + for (const [name, c] of Object.entries(constraints)) { + existingConstraintsForTarget[name] = existingConstraintsForTarget[name] + ? mergeConstraint(existingConstraintsForTarget[name], c) + : { ...c } + } + + txMap.set(targetName, existingConstraintsForTarget) + _constraintStorage.set(tx, txMap) + }, + + /** @returns Map */ + get: tx => _constraintStorage.get(tx) ?? new Map(), + clear: tx => _constraintStorage.delete(tx), +} diff --git a/db-service/lib/assert-constraint/utils.js b/db-service/lib/assert-constraint/utils.js new file mode 100644 index 000000000..d31ec0b1e --- /dev/null +++ b/db-service/lib/assert-constraint/utils.js @@ -0,0 +1,294 @@ +'use strict' + +const cds = require('@sap/cds') + +function getValidationQuery(target, constraints, req = null) { + const columns = [] + const paramColumns = [] + const parameterAliases = new Set() // tracks every alias already added + + for (const [name, { condition, parameters, target }] of Object.entries(constraints)) { + // 1. first add text parameters of the constraint, if any + if (parameters?.length) { + for (const p of parameters) { + if (parameterAliases.has(p.as)) { + // one constraints parameters may shadow another constraints parameters + // in that case, the last one wins + const idx = paramColumns.findIndex(c => c.as === p.as) + paramColumns[idx] = p + } else { + parameterAliases.add(p.as) + paramColumns.push(p) + } + } + } + + const constraintAlias = `${name}_constraint` + // should not happen, but just in case + if (parameterAliases.has(constraintAlias)) + throw new Error( + `Can't evaluate constraint "${name}" in entity “${target.name}” because it's name collides with a parameter name`, + ) + + // 2. The actual constraint condition as another column + columns.push({ + xpr: wrapInNegatedCaseWhen([{ xpr: condition.xpr }]), + as: constraintAlias, + cast: { type: 'cds.Boolean' }, + }) + } + + // REVISIT: matchKeys for one entity should be the same for all constraints + // it should be more like { 'bookshop.Books' : { c1 : { ... }, c2: { ... } }, …, $matchKeys: [ ... ] } + const first = Object.values(constraints)[0] + const keyMatchingCondition = first.where.flatMap((matchKey, i) => (i > 0 ? ['or', ...matchKey] : matchKey)) + + const validationQuery = SELECT.from(req?.subject || {ref: [target]}).columns(columns).where(keyMatchingCondition) + // let validationQuery + // if (req) { + // validationQuery = SELECT.from(req.subject).columns(columns).where(keyMatchingCondition) + // const prop = req.query?.UPDATE || req.query?.UPSERT + // if(prop?.where) { + // validationQuery.SELECT.where = [...validationQuery.SELECT.where, 'or', ...prop.where] + // } + // } else { // hack for deep because req.subject != target + // validationQuery = SELECT.from({ref: [target]}).columns(columns).where(keyMatchingCondition) + // } + + // there will be a separate query for the params which will only + // be fired if the validation query returns any violated constraints + if (paramColumns.length) { + const paramQuery = SELECT.from(validationQuery.SELECT.from) + .columns(paramColumns) + .where([...keyMatchingCondition]) + + Object.defineProperty(validationQuery, '$paramQuery', { value: paramQuery }) + } + + Object.defineProperty(validationQuery, '$constraints', { value: constraints }) + return validationQuery +} + +/** + * Collect every @assert.constraint defined on an entity, its elements and any + * compositions that appear in the current payload. + */ +function collectConstraints(entity, data) { + /** All constraints discovered so far, keyed by constraint name */ + const constraints = { ...extractConstraints(entity) } + + // ────────── 1. scan elements ────────── + for (const el of Object.values(entity.elements ?? {})) { + Object.assign(constraints, extractConstraints(el, entity)) + } + + // ────────── 2. attach match keys and payload ────── + const { where } = matchKeys(entity, data) + for (const c of Object.values(constraints)) { + c.where = [...(c.matchKeys ?? []), ...where] + c.data = data // to check if constraint is relevant for the current payload + } + + // ────────── 3. recurse into compositions present in the payload ────────── + for (const [compKey, composition] of Object.entries(entity.compositions ?? {})) { + const payload = data?.[compKey] + if (!payload) continue // nothing sent for this comp + + const target = cds.model.definitions[composition.target] + const recurse = child => mergeConstraintSets(constraints, collectConstraints(target, child)) + + Array.isArray(payload) ? payload.forEach(recurse) : recurse(payload) + } + + return constraints +} + +/** Merge two constraint maps in‑place, concatenating any duplicate matchKeys (they are deduped later). */ +function mergeConstraintSets(base, incoming) { + for (const [name, inc] of Object.entries(incoming)) { + const existing = base[name] + if (existing && existing.element === inc.element) { + existing.where = [...existing.where, ...inc.where] + } else { + base[name] = inc + } + } + return base +} + +/** Collect @assert.constraint annotations from an entity or element. */ +function extractConstraints(obj, target = obj) { + const collected = {} + + for (const key in obj) { + if (!key.startsWith('@assert.constraint')) continue + const val = obj[key] ?? /* draft elements do not get annos propagated */ obj.__proto__[key] + + // strip prefix and leading dot + let [, remainder] = key.match(/^@assert\.constraint\.?(.*)$/) + const parts = remainder.split('.') + const constraintName = parts.length === 1 ? obj.name || parts[0] : parts[0] + const propertyName = parts.length === 1 ? parts[0] || (val.xpr ? 'condition' : undefined) : parts.slice(1).join('.') + + if (!propertyName) continue // nothing useful to store + + const entry = (collected[constraintName] ??= { element: obj, target }) + if (propertyName.startsWith('parameters')) { + const paramName = propertyName.slice('parameters.'.length) + if (paramName === '') { + // anonymous parameters, attach index as name + entry[propertyName] = val.map((p, i) => ({ ...p, as: `${i}` })) + } else { + const param = { ...val, as: paramName } + entry.parameters = [...(entry.parameters ?? []), param] + } + } else { + entry[propertyName] = val + } + } + return collected +} + +/** + * Constructs a condition which matches the primary keys of the entity for the given data. + * + * @param {CSN.entity} entity + * @param {object} data + * @returns {Array} conditions + */ +function matchKeys(entity, data) { + const primaryKeys = Object.keys(entity.keys || {}) + const dataEntries = Array.isArray(data) ? data : [data] // Ensure batch handling + + // construct {key:value} pairs holding information about the entry to check + return { + where: dataEntries + .map(entry => + primaryKeys.reduce((identifier, key) => { + const value = entry?.[key] + if (value !== undefined) { + if (identifier.length > 0) { + identifier.push('and') + } + identifier.push({ ref: [key] }, '=', { val: value }) + } + return identifier + }, []), + ) + .filter(e => e.length > 0), // remove empty entries + refs: primaryKeys.map(key => ({ ref: [key] })), + } +} + +function wrapInNegatedCaseWhen(xpr) { + return ['case', 'when', 'not', { xpr }, 'then', { val: false }, 'else', { val: true }, 'end'] +} + +/** + * Compose the final error text, including i18n look‑up + parameter injection. + */ +function buildMessage(name, { message, parameters = [] }, row) { + const msgParams = Object.fromEntries( + parameters + .map(p => { + const val = row[p.as] + return val === undefined ? null : [p.as, val] + }) + .filter(Boolean), + ) + + return message + ? cds.i18n.messages.for(message, msgParams) || message // translated or fallback + : `@assert.constraint “${name}” failed` +} + +/** + * Retrieves constraints grouped by their target from the provided target and data. + * + * @param {object} target - The target of the request. + * @param {object} data - The payload of the request. + * @returns {Map} A map where the keys are entity names and the values are + * objects containing constraints. + */ +function getConstraintsByTarget(target, data) { + const flatConstraints = collectConstraints(target, data) + const map = new Map() + for (const [name, c] of Object.entries(flatConstraints)) { + // only consider constraints that are relevant for the current payload + if (!isConstraintExecutionNeeded(c)) continue + const tgt = c.target.name + const bucket = map.get(tgt) ?? {} + bucket[name] = c + map.set(tgt, bucket) + } + return map +} + +/** + * Decides whether the constraint must be executed for the given data payload. + * + * @param {obj} constraint - constraint as extracted by `extractConstraints` + * @param {Record} data - payload to check (insert/update data) + * @returns {boolean} true if at least one referenced element occurs in data + */ +function isConstraintExecutionNeeded(constraint) { + const refs = collectRefsFromXpr(constraint.condition.xpr) + if (refs.size === 0) return false + + const matchesAnyRef = obj => { + if (!obj || typeof obj !== 'object') return false + for (const ref of refs) { + if (Object.prototype.hasOwnProperty.call(obj, ref)) return true + // calculated elements always lead to constraint execution + const element = constraint.target.query?._target.elements[ref] || constraint.target.elements[ref] + // element will be undefined e.g. for $now or $user + if (element?.value) return true + } + return false + } + + const { data } = constraint + if (Array.isArray(data)) { + // Batch + return data.some(matchesAnyRef) + } + + // Single row + return matchesAnyRef(data) +} + +/** + * Recursively collect the first segment (ref[0]) of all references + * found in the constraint condition expression. + * + * @param {Array} xpr - CDS expression as produced by the compiler (Array‑based AST) + * @param {Set} [seenRefs] - internal accumulator + * @returns {Set} the set of unique element names referenced + */ +function collectRefsFromXpr(xpr, seenRefs = new Set()) { + for (const token of xpr) { + if (token.ref) { + // Only consider the top‑level element name (ref[0]) + const id = token.ref[0].id || token.ref[0] + seenRefs.add(id) + if (token.ref[0].where) collectRefsFromXpr(token.ref[0].where, seenRefs) + } else if (token.xpr) { + collectRefsFromXpr(token.xpr, seenRefs) + } else if (token.args) { + collectRefsFromXpr(token.args, seenRefs) + } else if (token.list) { + token.list.forEach(item => { + collectRefsFromXpr(item, seenRefs) + }) + } + // literals, queries, operators etc. are ignored + } + return seenRefs +} + +module.exports = { + getValidationQuery, + getConstraintsByTarget, + collectConstraints, + buildMessage, +} diff --git a/test/bookshopWithConstraints/_i18n/messages.properties b/test/bookshopWithConstraints/_i18n/messages.properties new file mode 100644 index 000000000..7d1f1c95b --- /dev/null +++ b/test/bookshopWithConstraints/_i18n/messages.properties @@ -0,0 +1,8 @@ +ASSERT_RANGE = Value {0} is not in specified range [{1}, {2}] +STOCK_NOT_EMPTY = Stock for book "{title}" ({ID}) must not be a negative number +LIFE_BEFORE_DEATH = The Birthday "{0}" of author "{1}" must not be after the Deathday "{2}" +GENRE_NAME_TOO_LONG = Genre name "{0}" exceeds maximum length of 20 characters ({1}) +A_MUST_NOT_BE_42 = The value of C with ID: "{0}" must not be 42 +FOOTNOTE_TEXT_TOO_LONG = Footnote ({id}) text length ({footnoteLength}) on page {pageNumber} of book "{bookTitle}" exceeds the length of its page ({pageLength}) +PAGE_TEXT_TOO_SHORT = Text of page {pageNumber} for book "{bookTitle}" must not be an empty string +POTENTIAL_REVENUE_TOO_HIGH = Potential revenue of book "{title}" ({ID}) must not exceed 10.000$, but is {value}$ \ No newline at end of file diff --git a/test/bookshopWithConstraints/db/constraints.cds b/test/bookshopWithConstraints/db/constraints.cds new file mode 100644 index 000000000..16da693ef --- /dev/null +++ b/test/bookshopWithConstraints/db/constraints.cds @@ -0,0 +1,84 @@ +using {sap.capire.bookshop as my} from './schema'; + +annotate my.Pages with @( + assert.constraint.secondEditingConstraint: { + condition : (length(text) > 0), + parameters: { + pageLength: (length( /* $self. */ text)), + pageNumber: (number), + bookTitle : (book.title), + }, + message : 'PAGE_TEXT_TOO_SHORT', + }, + assert.constraint.thirdEditingConstraint : { + condition : (not exists footnotes[contains(text, 'FORBIDDEN PHRASE')]), + message : 'The phrase "FORBIDDEN PHRASE" is not allowed in footnotes', + } + +); + +annotate my.Pages.footnotes with @( + assert.constraint.firstEditingConstraint : { + condition : (length(text) < length(up_.text)), + parameters: { + id: (number), + footnoteLength: (length(text)), + pageLength : (length( /* $self. */ up_.text)), + pageNumber : (up_.number), + bookTitle : (up_.book.title), + }, + message : 'FOOTNOTE_TEXT_TOO_LONG', + }, +); + + +annotate my.Books with @( + assert.constraint.stockNotEmpty: { + condition : (stock >= 0), + message : 'STOCK_NOT_EMPTY', + parameters: { + title: (title), + ID : (ID) + } + }, + assert.constraint.withCalculatedElement: { + condition : (potentialRevenue <= 10000), + message : 'POTENTIAL_REVENUE_TOO_HIGH', + parameters: { + title: (title), + ID : (ID), + value: (potentialRevenue) + } + } +); + +annotate my.Authors with @( + assert.constraint.dates : { + condition: ( days_between(dateOfBirth, dateOfDeath) >= 0 ), + message: 'LIFE_BEFORE_DEATH', + parameters: [(dateOfBirth), (name), (dateOfDeath)], + }, + assert.constraint.dateOfBirthNotInTheFuture: { + condition: (dateOfBirth <= $now), + message: 'The authors date of birth must not be in the future', + } +); + + +annotate my.Genres : name with @( + assert.constraint.name: { + condition : (length(name) <= 25), + parameters: [ + (name), + (length(name)) + ], + message : 'GENRE_NAME_TOO_LONG' +} +); + + +annotate my.B with @(assert.constraint.foreign: { + condition: (A != 42), + message: 'A must not be 42', + } +); \ No newline at end of file diff --git a/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Authors.csv b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Authors.csv new file mode 100644 index 000000000..ac06562b7 --- /dev/null +++ b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Authors.csv @@ -0,0 +1,5 @@ +ID;name;dateOfBirth;placeOfBirth;dateOfDeath;placeOfDeath; city; street; +101;Emily Brontë;1818-07-30;Thornton, Yorkshire;1848-12-19;Haworth, Yorkshire; Bradford; 1 Main Street +107;Charlotte Brontë;1818-04-21;Thornton, Yorkshire;1855-03-31;Haworth, Yorkshire; Bradford; 2 Main Street +150;Edgar Allen Poe;1809-01-19;Boston, Massachusetts;1849-10-07;Baltimore, Maryland; Baltimore; 1 Main Street +170;Richard Carpenter;1929-08-14;King’s Lynn, Norfolk;2012-02-26;Hertfordshire, England; London; 1 Main Street diff --git a/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books.csv b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books.csv new file mode 100644 index 000000000..bfe13f2c3 --- /dev/null +++ b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books.csv @@ -0,0 +1,6 @@ +ID;title;descr;author_ID;stock;price;currency_code;genre_ID +201;Wuthering Heights;"Wuthering Heights, Emily Brontë's only novel, was published in 1847 under the pseudonym ""Ellis Bell"". It was written between October 1845 and June 1846. Wuthering Heights and Anne Brontë's Agnes Grey were accepted by publisher Thomas Newby before the success of their sister Charlotte's novel Jane Eyre. After Emily's death, Charlotte edited the manuscript of Wuthering Heights and arranged for the edited version to be published as a posthumous second edition in 1850.";101;12;11.11;GBP;11 +207;Jane Eyre;"Jane Eyre /ɛər/ (originally published as Jane Eyre: An Autobiography) is a novel by English writer Charlotte Brontë, published under the pen name ""Currer Bell"", on 16 October 1847, by Smith, Elder & Co. of London. The first American edition was published the following year by Harper & Brothers of New York. Primarily a bildungsroman, Jane Eyre follows the experiences of its eponymous heroine, including her growth to adulthood and her love for Mr. Rochester, the brooding master of Thornfield Hall. The novel revolutionised prose fiction in that the focus on Jane's moral and spiritual development is told through an intimate, first-person narrative, where actions and events are coloured by a psychological intensity. The book contains elements of social criticism, with a strong sense of Christian morality at its core and is considered by many to be ahead of its time because of Jane's individualistic character and how the novel approaches the topics of class, sexuality, religion and feminism.";107;11;12.34;GBP;11 +251;The Raven;"""The Raven"" is a narrative poem by American writer Edgar Allan Poe. First published in January 1845, the poem is often noted for its musicality, stylized language, and supernatural atmosphere. It tells of a talking raven's mysterious visit to a distraught lover, tracing the man's slow fall into madness. The lover, often identified as being a student, is lamenting the loss of his love, Lenore. Sitting on a bust of Pallas, the raven seems to further distress the protagonist with its constant repetition of the word ""Nevermore"". The poem makes use of folk, mythological, religious, and classical references.";150;333;13.13;USD;16 +252;Eleonora;"""Eleonora"" is a short story by Edgar Allan Poe, first published in 1842 in Philadelphia in the literary annual The Gift. It is often regarded as somewhat autobiographical and has a relatively ""happy"" ending.";150;555;14;USD;15 +271;Catweazle;Catweazle is a British fantasy television series, starring Geoffrey Bayldon in the title role, and created by Richard Carpenter for London Weekend Television. The first series, produced and directed by Quentin Lawrence, was screened in the UK on ITV in 1970. The second series, directed by David Reid and David Lane, was shown in 1971. Each series had thirteen episodes, most but not all written by Carpenter, who also published two books based on the scripts.;170;22;150;JPY;13 \ No newline at end of file diff --git a/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books_texts.csv b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books_texts.csv new file mode 100644 index 000000000..94fa7a183 --- /dev/null +++ b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Books_texts.csv @@ -0,0 +1,5 @@ +ID;locale;title;descr +201;de;Sturmhöhe;Sturmhöhe (Originaltitel: Wuthering Heights) ist der einzige Roman der englischen Schriftstellerin Emily Brontë (1818–1848). Der 1847 unter dem Pseudonym Ellis Bell veröffentlichte Roman wurde vom viktorianischen Publikum weitgehend abgelehnt, heute gilt er als ein Klassiker der britischen Romanliteratur des 19. Jahrhunderts. +201;fr;Les Hauts de Hurlevent;Les Hauts de Hurlevent (titre original : Wuthering Heights), parfois orthographié Les Hauts de Hurle-Vent, est l'unique roman d'Emily Brontë, publié pour la première fois en 1847 sous le pseudonyme d’Ellis Bell. Loin d'être un récit moralisateur, Emily Brontë achève néanmoins le roman dans une atmosphère sereine, suggérant le triomphe de la paix et du Bien sur la vengeance et le Mal. +207;de;Jane Eyre;Jane Eyre. Eine Autobiographie (Originaltitel: Jane Eyre. An Autobiography), erstmals erschienen im Jahr 1847 unter dem Pseudonym Currer Bell, ist der erste veröffentlichte Roman der britischen Autorin Charlotte Brontë und ein Klassiker der viktorianischen Romanliteratur des 19. Jahrhunderts. Der Roman erzählt in Form einer Ich-Erzählung die Lebensgeschichte von Jane Eyre (ausgesprochen /ˌdʒeɪn ˈɛə/), die nach einer schweren Kindheit eine Stelle als Gouvernante annimmt und sich in ihren Arbeitgeber verliebt, jedoch immer wieder um ihre Freiheit und Selbstbestimmung kämpfen muss. Als klein, dünn, blass, stets schlicht dunkel gekleidet und mit strengem Mittelscheitel beschrieben, gilt die Heldin des Romans Jane Eyre nicht zuletzt aufgrund der Kino- und Fernsehversionen der melodramatischen Romanvorlage als die bekannteste englische Gouvernante der Literaturgeschichte +252;de;Eleonora;“Eleonora” ist eine Erzählung von Edgar Allan Poe. Sie wurde 1841 erstveröffentlicht. In ihr geht es um das Paradox der Treue in der Treulosigkeit. \ No newline at end of file diff --git a/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Genres.csv b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Genres.csv new file mode 100644 index 000000000..1ea3793bb --- /dev/null +++ b/test/bookshopWithConstraints/db/data/sap.capire.bookshop-Genres.csv @@ -0,0 +1,16 @@ +ID;parent_ID;name +10;;Fiction +11;10;Drama +12;10;Poetry +13;10;Fantasy +14;10;Science Fiction +15;10;Romance +16;10;Mystery +17;10;Thriller +18;10;Dystopia +19;10;Fairy Tale +20;;Non-Fiction +21;20;Biography +22;21;Autobiography +23;20;Essay +24;20;Speech diff --git a/test/bookshopWithConstraints/db/init.js b/test/bookshopWithConstraints/db/init.js new file mode 100644 index 000000000..c30286585 --- /dev/null +++ b/test/bookshopWithConstraints/db/init.js @@ -0,0 +1,25 @@ +/** + * In order to keep basic bookshop sample as simple as possible, we don't add + * reuse dependencies. This db/init.js ensures we still have a minimum set of + * currencies, if not obtained through @capire/common. + */ + +module.exports = async tx => { + const has_common = tx.model.definitions['sap.common.Currencies']?.elements.numcode + if (has_common) return + + const already_filled = await tx.exists('sap.common.Currencies', { code: 'EUR' }) + if (already_filled) return + + await tx.run( + INSERT.into('sap.common.Currencies') + .columns(['code', 'symbol', 'name']) + .rows( + ['EUR', '€', 'Euro'], + ['USD', '$', 'US Dollar'], + ['GBP', '£', 'British Pound'], + ['ILS', '₪', 'Shekel'], + ['JPY', '¥', 'Yen'], + ), + ) +} diff --git a/test/bookshopWithConstraints/db/schema.cds b/test/bookshopWithConstraints/db/schema.cds new file mode 100644 index 000000000..d38f26ae3 --- /dev/null +++ b/test/bookshopWithConstraints/db/schema.cds @@ -0,0 +1,73 @@ +using { + Currency, + managed, + sap +} from '@sap/cds/common'; + +namespace sap.capire.bookshop; + +entity Books : managed { + key ID : Integer; + title : localized String(111); + descr : localized String(1111); + author : Association to Authors; + genre : Association to Genres default 10; + stock : Integer; + price : Decimal; + potentialRevenue: Decimal = price * stock; + currency : Currency; + image : LargeBinary @Core.MediaType: 'image/png'; + footnotes : array of String; + authorsAddress : String = author.address; + pages: Composition of many Pages on pages.book = $self; +} + +entity Pages : managed { + key number : Integer; + key book : Association to Books; + text : String(1111); + footnotes: Composition of many { + key number : Integer; + text : String(1111); + } +} + +entity Authors : managed { + key ID : Integer; + name : String(111); + dateOfBirth : Date; + dateOfDeath : Date; + placeOfBirth : String; + placeOfDeath : String; + books : Association to many Books + on books.author = $self; + + street : String; + city : String; + address : String = street || ', ' || city; +} + +/** Hierarchically organized Code List for Genres */ +entity Genres : sap.common.CodeList { + key ID : Integer; + parent : Association to Genres; + children : Composition of many Genres + on children.parent = $self; +} + + +entity A : managed { + key ID : Integer; + B : Integer; + toB : Composition of many B + on toB.ID = $self.B; +} + +entity B : managed { + key ID : Integer; + A : Integer; + toA : Composition of many A + on toA.ID = $self.A; +} + +entity BooksAnnotated as projection on Books; diff --git a/test/bookshopWithConstraints/package.json b/test/bookshopWithConstraints/package.json new file mode 100644 index 000000000..ee1181278 --- /dev/null +++ b/test/bookshopWithConstraints/package.json @@ -0,0 +1,27 @@ +{ + "name": "@capire/bookshop", + "version": "1.0.0", + "description": "A simple self-contained bookshop service.", + "files": [ + "app", + "srv", + "db", + "index.cds", + "index.js" + ], + "dependencies": { + "@cap-js/sqlite": "latest", + "@sap/cds": ">=5.9", + "express": "^4.17.1" + }, + "scripts": { + "genres": "cds serve test/genres.cds", + "start": "cds run", + "watch": "cds watch" + }, + "cds": { + "features": { + "ieee754compatible": true + } + } +} diff --git a/test/bookshopWithConstraints/srv/admin-service.cds b/test/bookshopWithConstraints/srv/admin-service.cds new file mode 100644 index 000000000..b361caf81 --- /dev/null +++ b/test/bookshopWithConstraints/srv/admin-service.cds @@ -0,0 +1,19 @@ +using { sap.capire.bookshop as my } from '../db/schema'; +service AdminService @(requires:'admin', path:'/admin') { + entity Books as projection on my.Books; + entity Authors as projection on my.Authors; + entity A as projection on my.A; + entity B as projection on my.B; + entity Genres as projection on my.Genres; + + @cds.redirection.target: false + entity RenameKeys as projection on my.Books { + key ID as foo, + author, + author.name, + stock, + potentialRevenue, + title as myTitle, + price, + } +} diff --git a/test/bookshopWithConstraints/srv/cat-service.cds b/test/bookshopWithConstraints/srv/cat-service.cds new file mode 100644 index 000000000..2441db25f --- /dev/null +++ b/test/bookshopWithConstraints/srv/cat-service.cds @@ -0,0 +1,16 @@ +using { sap.capire.bookshop as my } from '../db/schema'; +service CatalogService @(path:'/browse') { + + /** For displaying lists of Books */ + @readonly entity ListOfBooks as projection on Books + excluding { descr }; + + /** For display in details pages */ + @readonly entity Books as projection on my.Books { *, + author.name as author + } excluding { createdBy, modifiedBy }; + + @requires: 'authenticated-user' + action submitOrder ( book: Books:ID, quantity: Integer ) returns { stock: Integer }; + event OrderedBook : { book: Books:ID; quantity: Integer; buyer: String }; +} diff --git a/test/bookshopWithConstraints/srv/cat-service.js b/test/bookshopWithConstraints/srv/cat-service.js new file mode 100644 index 000000000..af5ad6d9c --- /dev/null +++ b/test/bookshopWithConstraints/srv/cat-service.js @@ -0,0 +1,30 @@ +const cds = require('../../cds.js') + +class CatalogService extends cds.ApplicationService { + init() { + const { Books } = cds.entities('sap.capire.bookshop') + const { ListOfBooks } = this.entities + + // Reduce stock of ordered books if available stock suffices + this.on('submitOrder', async req => { + const { book, quantity } = req.data + if (quantity < 1) return req.reject(400, `quantity has to be 1 or more`) + let b = await SELECT`stock`.from(Books, book) + if (!b) return req.error(404, `Book #${book} doesn't exist`) + let { stock } = b + // if (quantity > stock) return req.reject(409, `${quantity} exceeds stock for book #${book}`) + await UPDATE(Books, book).with({ stock: (stock -= quantity) }) + await this.emit('OrderedBook', { book, quantity, buyer: req.user.id }) + return { stock } + }) + + // Add some discount for overstocked books + this.after('READ', ListOfBooks, each => { + if (each.stock > 111) each.title += ` -- 11% discount!` + }) + + return super.init() + } +} + +module.exports = { CatalogService } diff --git a/test/bookshopWithConstraints/test/genres.cds b/test/bookshopWithConstraints/test/genres.cds new file mode 100644 index 000000000..aa6f789b4 --- /dev/null +++ b/test/bookshopWithConstraints/test/genres.cds @@ -0,0 +1,9 @@ +using { sap.capire.bookshop as my } from '../db/schema'; + +@path: '/test' +service TestService { + entity Genres as projection on my.Genres; + entity A as projection on my.A; +} + +annotate my.Genres:children with @depth: 5; diff --git a/test/scenarios/bookshop/assert-constraint.test.js b/test/scenarios/bookshop/assert-constraint.test.js new file mode 100644 index 000000000..579dfd05c --- /dev/null +++ b/test/scenarios/bookshop/assert-constraint.test.js @@ -0,0 +1,406 @@ +const cds = require('../../cds.js') +const bookshop = cds.utils.path.resolve(__dirname, '../../bookshopWithConstraints') + +describe('Bookshop - assertions', () => { + const { expect, POST } = cds.test(bookshop) + let adminService, catService, Books, Genres, Authors + + before('bootstrap the database', async () => { + Books = cds.entities('AdminService').Books + Genres = cds.entities('AdminService').Genres + Authors = cds.entities('AdminService').Authors + await INSERT({ ID: 42, title: 'Harry Potter and the Chamber of Secrets', stock: 15, price: 15 }).into(Books) + }) + + describe('UPDATE', () => { + test('simple assertion', async () => { + await expect(UPDATE(Books, '42').with({ stock: -1 })).to.be.rejectedWith( + 'Stock for book "Harry Potter and the Chamber of Secrets" (42) must not be a negative number', + ) + }) + + test.skip('at the end, everything is alright so dont complain right away', async () => { + adminService = await cds.connect.to('AdminService') + await expect( + adminService.tx({ user: 'alice' }, async () => { + // first invalid + await INSERT({ ID: 49, title: 'Harry Potter and the Deathly Hallows II', stock: -1 }).into(Books) + // now we make it valid + await UPDATE(Books, '49').with({ stock: 10 }) + }), + ).to.be.fulfilled + await DELETE.from(Books).where({ ID: 49 }) + }) + + // Note: there will be only one query against books and one against authors in the end. + // skipped because the first request already fails (non deferred constraint) + test.skip('multiple requests against the same entity should always result in exactly one query in the end', async () => { + adminService = await cds.connect.to('AdminService') + await expect( + adminService.tx({ user: 'alice' }, async () => { + // first invalid + await INSERT({ ID: 55, title: 'Dawnshard', stock: -1 }).into(Books) + await INSERT({ ID: 77, title: 'Edgedancer', stock: -1 }).into(Books) + await INSERT({ ID: 99, title: 'Horneater', stock: -1 }).into(Books) + + await INSERT({ ID: 100, name: 'Brandon Sanderdaughter', dateOfBirth: '1975-12-19' }).into(Authors) + + // the following entries have been touched before in the same transaction + await UPDATE(Books, 55).with({ stock: 10 }) + await UPDATE(Books, 77).with({ stock: 10 }) + await UPDATE(Books, 99).with({ stock: 10 }) + await UPDATE(Authors, 100).with({ name: 'Brandon Sanderson' }) + }), + ).to.be.fulfilled + await DELETE.from(Books).where({ ID: [55, 77, 99] }) + await DELETE.from(Authors).where({ ID: 100 }) + }) + + test('assertion via action', async () => { + catService = await cds.connect.to('CatalogService') + // try to withdraw more books than there are in stock + await expect( + catService.tx({ user: 'alice' }, async () => { + await catService.send('submitOrder', { book: 42, quantity: 16 }) + }), + ).to.be.rejectedWith( + 'Stock for book "Harry Potter and the Chamber of Secrets" (42) must not be a negative number', + ) + + // stock for harry potter should still be 15 + const book = await SELECT.one.from(Books).where({ ID: 42 }) + expect(book.stock).to.equal(15) + }) + + test('update fails because deeply nested child violates constraint', async () => { + await expect( + UPDATE(Genres) + .with({ + name: 'Non-Fiction Updated', + children: [ + { + ID: 21, + name: 'SUPER BIOGRAPHY', + children: [ + { + ID: 22, + name: 'We forbid genre names with more than 20 characters', + }, + ], + }, + ], + }) + .where(`name = 'Non-Fiction' and ID = 20`), + ).to.be.rejectedWith( + 'Genre name "We forbid genre names with more than 20 characters" exceeds maximum length of 20 characters', + ) + }) + + test('update fails because parent AND deeply nested child violates constraint', async () => { + try { + await UPDATE(Genres) + .with({ + name: 'Non-Fiction Updated with a waaaaaay to long name', + children: [ + { + ID: 21, + name: 'SUPER BIOGRAPHY', + children: [ + { + ID: 22, + name: 'We forbid genre names with more than 20 characters', + }, + ], + }, + ], + }) + .where(`name = 'Non-Fiction' and ID = 20`) + } catch (err) { + const { details } = err + expect(details).to.have.length(2) + const messages = details.map(detail => detail.message) + expect(messages).to.include( + 'Genre name "Non-Fiction Updated with a waaaaaay to long name" exceeds maximum length of 20 characters (48)', + ) + expect(messages).to.include( + 'Genre name "We forbid genre names with more than 20 characters" exceeds maximum length of 20 characters (50)', + ) + } + }) + test('via service entity, parameter used in validation message of original entity is renamed', async () => { + const { RenameKeys: Renamed } = cds.entities('AdminService') + await expect(UPDATE.entity(Renamed).where(`author.name LIKE 'Richard%'`).set('stock = -1')).to.be.rejectedWith( + 'Stock for book "Catweazle" (271) must not be a negative number', + ) + }) + test('navigation in condition and in parameters', async () => { + await INSERT.into(Books).entries([{ ID: 17, title: 'The Way of Kings', stock: 10 }]) + await expect( + UPDATE(Books, 17).with({ + title: 'The Way of Kings', + stock: 10, + pages: [ + { + number: 11, + text: 'a short page text', + footnotes: [ + { + number: 1, + text: 'The footnote text length exceeds the length of the text of the page', + }, + ], + }, + ], + }), + ).to.be.rejectedWith( + 'Footnote (1) text length (67) on page 11 of book "The Way of Kings" exceeds the length of its page (17)', + ) + }) + + test('multiple constraints on one entity', async () => { + await INSERT.into(Books).entries([{ ID: 18, title: 'Elantris', stock: 0 }]) + await expect( + UPDATE(Books, 18).with({ + stock: 1000, + pages: [ + { + number: 1, + text: '', + }, + ], + }), + ).to.be.rejectedWith('Text of page 1 for book "Elantris" must not be an empty string') + }) + + test('only execute constraint if some element of condition is part of payload', async () => { + // plain insert lets us avoid constraint violation + if(cds.env.sql.names === 'quoted') + await cds.db.run('INSERT INTO "sap.capire.bookshop.Books" ("ID", "title", "stock") VALUES (952, \'Elantris\', -1)') + else + await cds.db.run('INSERT INTO SAP_CAPIRE_BOOKSHOP_BOOKS (ID, TITLE, STOCK) VALUES (952, \'Elantris\', -1)') + + const elantris = await SELECT.one.from(Books).where({ ID: 952 }) + expect(elantris).to.exist.and.to.have.property('stock', -1) + // if we would check the constraint, we would get an error, but stock is not part of payload + await expect( + UPDATE(Books, 952).with({ + title: 'Elantris', + price: 10, + }), + ).to.be.fulfilled + // now we update stock to a positive number + await expect( + UPDATE(Books, 952).with({ + stock: 10, + }), + ).to.be.fulfilled + // now we update stock to a negative number + await expect( + UPDATE(Books, 952).with({ + stock: -1, + }), + ).to.be.rejectedWith('Stock for book "Elantris" (952) must not be a negative number') + }) + }) + + describe('INSERT', () => { + test('simple assertion, no negative stocks', async () => { + await expect( + INSERT({ ID: 43, title: 'Harry Potter and Prisoner of Azkaban', stock: -1 }).into(Books), + ).to.be.rejectedWith('Stock for book "Harry Potter and Prisoner of Azkaban" (43) must not be a negative number') + }) + + test('assertion in batch', async () => { + await expect( + INSERT.into(Books).entries([ + { ID: 44, title: 'Harry Potter and the Goblet of Fire', stock: 10 }, + { ID: 45, title: 'Harry Potter and the Order of the Phoenix', stock: -1 }, + ]), + ).to.be.rejectedWith( + 'Stock for book "Harry Potter and the Order of the Phoenix" (45) must not be a negative number', + ) + }) + + test('no stock is okay', async () => { + await INSERT({ ID: 48, title: 'Harry Potter and the Cursed Child', stock: null }).into(Books) + + const book = await SELECT.one.from(Books).where({ ID: 48 }) + expect(book).to.exist + }) + + test('deepInsert should not proceed after constraint violation in header', async () => { + await expect( + POST( + '/admin/Authors', + { + ID: 55, + name: 'Brandon Sanderson', + dateOfBirth: '2025-01-01', // mixed up date of birth and date of death + dateOfDeath: '1975-12-19', + books: [ + { + ID: 55, + title: 'The Way of Kings', + stock: 10, + price: 10, + }, + ], + }, + { auth: { username: 'alice' } }, + ), + ).to.be.rejectedWith( + 'The Birthday "2025-01-01" of author "Brandon Sanderson" must not be after the Deathday "1975-12-19"', + ) + // book should not have been created + const book = await SELECT.one.from(Books).where({ ID: 55 }) + expect(book).to.not.exist + }) + + test('deep insert should not be fulfilled after constraint violation in child', async () => { + await expect( + POST( + '/admin/Genres', + { + ID: 256, + name: 'Fantasy', + children: [ + { + ID: 56, + name: 'Fable', + children: [ + { + ID: 58, + name: 'We forbid genre names with more than 20 characters', + }, + ], + }, + { + ID: 57, + name: 'Sibling Fable', + }, + ], + }, + { auth: { username: 'alice' } }, + ), + ).to.be.rejectedWith( + 'Genre name "We forbid genre names with more than 20 characters" exceeds maximum length of 20 characters (50)', + ) + }) + + test('deep insert via different entities', async () => { + await expect( + POST( + '/admin/A', + { + ID: 1, + toB: [ + { + ID: 2, + A: 42, + }, + ], + }, + { auth: { username: 'alice' } }, + ), + ).to.be.rejectedWith('A must not be 42') + }) + + test('constraint with exists subquery', async () => { + await expect( + INSERT.into(Books).entries([ + { + ID: 240, + title: 'Elantris', + stock: 10, + pages: [ + { number: 1, text: 'The first page of this Book is filled with adventures' }, + { + number: 2, + text: 'The second page is also absolutely amazing and makes you want to read more', + footnotes: [ + // if there would n footnotes, the error would be raised n times due to the `exists` used in the constraint + { + number: 3, + text: 'This footnote contains a "FORBIDDEN PHRASE" and should be rejected', + }, + ], + }, + ], + }, + ]), + ).to.be.rejectedWith('The phrase "FORBIDDEN PHRASE" is not allowed in footnotes') + }) + + test('assertion in batch (make sure there is only one query in the end)', async () => { + await expect( + INSERT.into(Books).entries([ + { ID: 500, title: 'The Way of Kings', stock: 10 }, + { ID: 501, title: 'Words of Radiance', stock: -1 }, + { ID: 502, title: 'Oathbringer', stock: 10 }, + { ID: 503, title: 'Edgedancer', stock: 10 }, + { ID: 504, title: 'Dawnshard', stock: 10 }, + ]), + ).to.be.rejectedWith('Stock for book "Words of Radiance" (501) must not be a negative number') + }) + + test('calculated element in condition and in paramaters', async () => { + await expect( + INSERT.into(Books).entries([{ ID: 500, title: 'The Way of Kings', stock: 1000, price: 15 }]), + ).to.be.rejectedWith('Potential revenue of book "The Way of Kings" (500) must not exceed 10.000$, but is 15000$') + }) + + test('with pseudo variable $now', async () => { + await expect( + INSERT.into(Authors).entries([{ ID: 100, name: 'Future Author', dateOfBirth: '2100-01-01' }]), + ).to.be.rejectedWith('The authors date of birth must not be in the future') + }) + + test('deduplicate messages if multiple constraints in child are violated but only one in header', async () => { + /** + * given this example: + * + * @assert.constraint.firstEditingConstraint : { + * condition: ( length(text) > 0 ) + * } + * @assert.constraint.secondEditingConstraint : { + * condition : ( length(footnotes.text) < length(text) ), + * } + * + * Now, we set the text length to 0 for a page. + * This will violate the first constraint, but will also violate the second constraint (if the footnote has any text). + * The second constraint will be violated `n` times where `n` is the number of footnotes. + * + * We need to issue the error for the footnotes `n` times, because each footnote has in fact violated it's constraint. + * However, the first constraint should only be issued once, + * because it is a constraint on the page itself (which is the same for all `n` rows). + */ + + try { + await INSERT.into(Books).entries([ + { + ID: 240, + title: 'Elantris', + stock: 10, + pages: [ + // page with empty text not allowed + { + number: 1, + text: '', + footnotes: [ + { number: 1, text: 'The footnote text must not be longer than the text of its page' }, + { number: 2, text: 'The footnote text must not be longer than the text of its page' }, + { number: 3, text: 'The footnote text must not be longer than the text of its page' }, + { number: 4, text: 'The footnote text must not be longer than the text of its page' }, + ], + }, + ], + }, + ]) + } catch (e) { + // 1 error for the page because it has no text + // 4 errors for the footnotes because they are all longer than the text of their page + expect(e.details).to.have.length(5) + } + }) + }) +})