diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index efcebdaad9..eda85f5972 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -366,6 +366,7 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `int` Semantics worth knowing: +- **An UNKNOWN key is a validation error, everywhere (#6541) — `UnknownKeyValidator` + `validateSeedRowKeys`.** The typed mapping is Gson, which ignores unknown properties, so a key the model does not declare used to be accepted and discarded: parse 200, generate 200, code-gen 201, publish 200, and the promise the author wrote simply absent at runtime — the *authored-but-silently-dropped* failure mode this module refuses everywhere else. Two shapes, one rule. (1) **Typed nodes:** `UnknownKeyValidator.collect` walks the RAW YAML tree against the model classes — declared fields, honouring `@SerializedName` (`extends`), so the check can never drift from what the parser maps — and reports `unknown key [requird] at [entities[Rate].relations[Scheme]] - did you mean [required]?`. The walk follows TYPED structure only: a `Map`-valued property (a step's `args`, a `map:`/`defaults:` projection, a relation's `where:`, a widget's `at:`) carries names from the model being described, not from the intent schema, and stays deliberately opaque — those are the per-feature validators' vocabulary. (2) **Seed rows:** their keys are the entity's own field and to-one relation names (plus the `stage:` marker), matched exactly, because that is what `CsvimIntentGenerator` emits columns for — a typo, a **case slip** (`contributionScheme` for the relation `ContributionScheme`), or a collection relation (no FK column) contributes nothing, and when the lost column was a NOT NULL FK the import then skipped EVERY row: a rates nomenclature imported as zero rows, downstream computations ran on empty data, every pipeline step green. Both messages name the key, where it sits, and the nearest declared name, with an explicit "(names are case-sensitive)" on a pure case difference — the slip hardest to see by eye. The issues join the structural ones, so one parse still reports everything. Generalizes `rejectRemovedNumberKeys`, which special-cased three keys for exactly this reason; that check stays because its migration messages are better than "unknown key". Verified against 63 real production intents plus every in-repo fixture (no false positives) and red-first (9 of 12 unit cases fail without it). - **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching the Dirigible convention (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores it since the FK lives on the child; a `manyToMany` never reaches the generator at all - it is expanded into its link entity at parse time). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. - **`kind: manyToMany` is MATERIALIZED into the intermediate (link) entity — `ManyToManyExpander`, run first thing in `IntentParser.validate`.** An n:m is always a link row, so the parser writes the link entity the author used to have to write by hand: `` (or the relation's `through: `) with a generated integer key, a `composition` to the declaring side and a `manyToOne` to the target (cross-model when the relation carried `model:`), and the authored relation is rewritten to the navigation-only `oneToMany` to that link. Everything after the expansion — every validator, every generator, the editor's diagram and the `/parse` response — sees an ordinary composition + association pair, so the link gets its table, its FK columns, its detail grid under the declaring entity's page, and can be seeded / reported on / referenced like any other entity. **Why it is an expansion and not a generator:** one representation of an n:m in the model means no generator (present or future) can forget the case, which is exactly how `manyToMany` came to parse cleanly and generate NOTHING for a year (#6718 — the authored-but-silently-unconsumed class). The link carries ONLY its key and the two FKs: bridge data (a quantity, a partial amount, a valid-from) means the link is a domain entity, authored explicitly with its own fields and no `manyToMany`. The target-picker attributes (`where` / `show` / `major` / `size` / `leafOnly`) travel onto the link's target relation; the attributes that only describe a hand-authored to-one (`composition`, `function`, `init`, `dependsOn`, calculated actions, `personal`, `partner`) are REJECTED rather than carried nowhere, as are: the same pair declared from both sides (one link, not two), a link name that collides with a declared entity (drop the `manyToMany` or name it with `through:`), and `through:` on any other kind. A self-referencing n:m is legitimate and names its ends apart. The emission + runtime promise (link table in the schema, a link row round-tripping through the master's detail query) is asserted in `IntentEmissionCoverageIT`, not only in the parser's unit test. - **Perspective resolution is CENTRAL and settings-aware — `IntentEntities.resolvePerspective(name, compositionParents, model|settingEntities)`.** A `function: Setting` entity's generated artifacts live under the global `Settings` perspective (`data/settings` + `api/settings` packages, `-Settings-` event topics), so ANY emission that names a package, an import, a controller URL or a topic must resolve through the settings-aware overload — the raw composition walk is `IntentEntities.compositionPerspective` and is reserved for the deliberately settings-less use (role naming in the EDM generator). History: the settings rule originally lived only in `EdmIntentGenerator.perspectiveFor` and was hand-copied as `isSetting() ? "Settings" : …` ternaries into SOME glue sites; the sites without it (the notify relation loads, decision resolvers, several GlueIntentGenerator builders) emitted imports of a non-existent `gen..data.` package and the whole client-Java batch failed to compile — the Sofia city-signals first-use failure (`{Status.name}` in a notify body). Do not reintroduce per-site ternaries; extend the central resolution. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 04b6e88d00..9cb6c6dec4 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -186,6 +186,11 @@ public static IntentModel parse(String yaml) { rejectRemovedNumberKeys(tree); rejectLifecycleOn(tree); moveGeneratesItemLines(tree); + // A key the typed model does not declare is dropped by the Gson mapping without a sound, so it + // is collected here - on the raw tree, while the author's spelling still exists - and reported + // together with the structural issues below. + List issues = new ArrayList<>(); + UnknownKeyValidator.collect(tree, issues); // Statuses may be referenced by their seeded NAME; resolve them to ids on the raw tree so the // typed mapping, every validator and every generator keep seeing the integers they always saw. StatusSymbolResolver.resolve(tree); @@ -203,9 +208,9 @@ public static IntentModel parse(String yaml) { + " a recipient/path field must be a plain scalar (e.g. `to: member.email`, not `to: {member.email}`)")); } if (model == null) { - return new IntentModel(); + model = new IntentModel(); } - validate(model); + validate(model, issues); return model; } @@ -223,9 +228,11 @@ private static String rootMessage(Throwable ex) { /** * Run all structural checks. Collects every issue before throwing so authors get one complete error * message rather than playing whack-a-mole. + * + * @param model the typed model + * @param issues the issues already found on the raw tree (unknown keys), appended to */ - private static void validate(IntentModel model) { - List issues = new ArrayList<>(); + private static void validate(IntentModel model, List issues) { propagateSensitiveDerivations(model); // An n:m is materialised into its intermediate (link) entity FIRST, so every validator and // generator below sees an ordinary composition + association pair - the DSL holds exactly one @@ -5640,6 +5647,7 @@ private static void validateSeeds(IntentModel model, Set entityNames, Li validateLanguageSeed(seed, byName.get(seed.getEntity()), issues); } else { validateSeedStages(seed, byName.get(seed.getEntity()), nomenclatures, issues); + validateSeedRowKeys(seed, byName.get(seed.getEntity()), issues); } } } @@ -5698,6 +5706,43 @@ private static void validateSeedStages(SeedIntent seed, EntityIntent entity, Set } } + /** + * A seed row's keys are the entity's own declared names: a field, or a to-one relation carrying the + * FK. The CSV generator emits a column per declared field plus one per referenced to-one relation + * and reads each cell by that exact name, so a key matching neither - a typo, a case slip + * ({@code contributionScheme} for the relation {@code ContributionScheme}), a collection relation + * that has no column - contributes nothing. That drop used to be silent, and when the missing + * column was a NOT NULL FK the import then skipped EVERY row, leaving an empty nomenclature behind + * a fully green pipeline. It is an error naming the key, the entity and the nearest declared name. + */ + private static void validateSeedRowKeys(SeedIntent seed, EntityIntent entity, List issues) { + if (entity == null) { + return; // the unknown entity is reported separately + } + Set declared = new java.util.LinkedHashSet<>(); + for (FieldIntent field : entity.getFields()) { + if (field.getName() != null) { + declared.add(field.getName()); + } + } + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (relation.getName() != null && toOne) { + declared.add(relation.getName()); + } + } + for (Map row : seed.getRows()) { + for (String key : row.keySet()) { + // `stage` is the lifecycle classification marker - metadata about the row, never a column. + if (declared.contains(key) || LifecycleStages.STAGE_KEY.equals(key)) { + continue; + } + issues.add("seed [" + seed.getName() + "] row references [" + key + "] which is not a field or a to-one relation of [" + + entity.getName() + "]" + UnknownKeyValidator.suggestion(key, declared)); + } + } + } + /** The field name a seed row keys the entity's primary key by ({@code id} by convention). */ private static String seedIdField(EntityIntent entity) { for (FieldIntent field : entity.getFields()) { @@ -5743,7 +5788,8 @@ private static void validateLanguageSeed(SeedIntent seed, EntityIntent entity, L for (String key : row.keySet()) { if (!allowed.contains(key)) { issues.add("seed [" + seed.getName() + "] row references [" + key - + "] which is not the id or a translatable (string/text) field of [" + entity.getName() + "]"); + + "] which is not the id or a translatable (string/text) field of [" + entity.getName() + "]" + + UnknownKeyValidator.suggestion(key, allowed)); } } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java new file mode 100644 index 0000000000..2c6ba94d3a --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyValidator.java @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.dirigible.components.intent.model.IntentModel; + +import com.google.gson.annotations.SerializedName; + +/** + * Rejects intent keys the typed model does not declare, on the RAW YAML tree - before the Gson + * mapping silently drops them. + * + *

+ * The typed mapping ignores unknown properties, so an invented or mis-cased key + * ({@code calculatedActionOnCreate} on a relation that never had it, {@code contributionScheme} for + * the relation {@code ContributionScheme}) used to be accepted and discarded: generation returned + * 200, code generation 201, publish 200, and the promise the author wrote was simply absent at + * runtime. That is the failure mode this module refuses everywhere else, so it is refused here too + * - an unknown key is a validation ERROR naming the key, where it sits, and the nearest declared + * name. + * + *

+ * The known keys are read from the model classes themselves (declared fields, honouring + * {@link SerializedName}), so the check can never drift from what the parser actually maps. The + * walk follows typed structure only: a {@code Map}-valued property (a step's {@code args}, a + * {@code map:} / {@code defaults:} projection, a relation's {@code where:}) carries author-chosen + * keys and is deliberately opaque here - those are validated by the per-feature validators that + * know their vocabulary. + */ +final class UnknownKeyValidator { + + /** Model POJOs live in one package; only those are walked as typed nodes. */ + private static final String MODEL_PACKAGE = IntentModel.class.getPackageName(); + + /** Declared key -> field, per model class. Reflection is done once per class. */ + private static final Map, Map> KEYS = new ConcurrentHashMap<>(); + + private UnknownKeyValidator() {} + + /** + * Walk the raw tree against the typed model and collect one issue per unknown key. + * + * @param tree the SnakeYAML-loaded raw tree (anything but a map is ignored - the typed mapping + * reports it) + * @param issues the collecting issue list + */ + static void collect(Object tree, List issues) { + walk(tree, IntentModel.class, "", issues); + } + + /** + * The declared name closest to the given key, or null when nothing is close enough. A pure case + * difference always wins - it is the most common slip and the one hardest to spot by eye. + * + * @param key the authored key + * @param declared the names actually declared + * @return the nearest declared name, or null + */ + static String nearest(String key, Collection declared) { + String best = null; + int bestDistance = Integer.MAX_VALUE; + for (String candidate : declared) { + if (candidate.equalsIgnoreCase(key)) { + return candidate; + } + int distance = distance(key.toLowerCase(Locale.ROOT), candidate.toLowerCase(Locale.ROOT)); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + return best != null && bestDistance <= threshold(key) ? best : null; + } + + /** + * The "did you mean [x]?" suffix for an unknown key, empty when nothing is close enough. + * + * @param key the authored key + * @param declared the names actually declared + * @return the suffix to append to an issue message + */ + static String suggestion(String key, Collection declared) { + String nearest = nearest(key, declared); + if (nearest == null) { + return ""; + } + return nearest.equalsIgnoreCase(key) ? " - did you mean [" + nearest + "]? (names are case-sensitive)" + : " - did you mean [" + nearest + "]?"; + } + + private static void walk(Object node, Class type, String path, List issues) { + if (!(node instanceof Map map)) { + return; // a wrong-shaped node is reported by the typed mapping, not here + } + Map declared = keysOf(type); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + Field field = declared.get(key); + if (field == null) { + issues.add("unknown key [" + key + "] " + at(path) + suggestion(key, declared.keySet())); + continue; + } + descend(entry.getValue(), field.getGenericType(), path.isEmpty() ? key : path + "." + key, issues); + } + } + + /** Follow a declared property into its typed children; stop at anything not a model POJO. */ + private static void descend(Object value, Type type, String path, List issues) { + if (type instanceof Class single && isModelType(single)) { + walk(value, single, path, issues); + return; + } + if (!(type instanceof ParameterizedType parameterized) || !List.class.equals(parameterized.getRawType())) { + return; + } + Type argument = parameterized.getActualTypeArguments()[0]; + if (!(argument instanceof Class element) || !isModelType(element) || !(value instanceof List list)) { + return; + } + for (int i = 0; i < list.size(); i++) { + walk(list.get(i), element, path + "[" + label(list.get(i), i) + "]", issues); + } + } + + /** A list element is addressed by its authored name when it has one, else by its index. */ + private static String label(Object element, int index) { + if (element instanceof Map map && map.get("name") != null) { + return String.valueOf(map.get("name")); + } + return String.valueOf(index); + } + + private static String at(String path) { + return path.isEmpty() ? "at the intent root" : "at [" + path + "]"; + } + + private static boolean isModelType(Class type) { + return MODEL_PACKAGE.equals(type.getPackageName()) && !type.isEnum(); + } + + private static Map keysOf(Class type) { + return KEYS.computeIfAbsent(type, UnknownKeyValidator::readKeys); + } + + private static Map readKeys(Class type) { + Map keys = new LinkedHashMap<>(); + for (Field field : type.getDeclaredFields()) { + if (field.isSynthetic() || Modifier.isStatic(field.getModifiers())) { + continue; + } + SerializedName serialized = field.getAnnotation(SerializedName.class); + keys.put(serialized == null ? field.getName() : serialized.value(), field); + } + return keys; + } + + /** Edit distance that still counts as a typo - a longer name affords a longer slip. */ + private static int threshold(String key) { + if (key.length() <= 4) { + return 1; + } + return key.length() <= 8 ? 2 : 3; + } + + /** Plain Levenshtein distance, single-row dynamic programming. */ + private static int distance(String left, String right) { + int[] previous = new int[right.length() + 1]; + int[] current = new int[right.length() + 1]; + for (int j = 0; j <= right.length(); j++) { + previous[j] = j; + } + for (int i = 1; i <= left.length(); i++) { + current[0] = i; + for (int j = 1; j <= right.length(); j++) { + int substitution = previous[j - 1] + (left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1); + current[j] = Math.min(substitution, Math.min(previous[j] + 1, current[j - 1] + 1)); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[right.length()]; + } +} diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index af05074731..12a40af8af 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -130,6 +130,12 @@ not as an apology. does not exist, that notification or schedule is dropped and reported in the generate response's `warnings` (as well as the server log) - fix the reference so the glue is emitted. - **Names are identifiers** within their block and must be unique. +- **Only the keys documented here exist, and they are case-sensitive.** A key the schema does not + declare - an invented one, or a case slip (`Required:` for `required:`, `contributionScheme:` for + the relation `ContributionScheme`) - is a validation ERROR naming the key and the nearest declared + name; it is never accepted and ignored. The same holds for a **seed row**, whose keys are the + entity's own field and to-one relation names (plus the lifecycle `stage:` marker). Never invent a + plausible-looking key to express something: if the schema cannot say it, say so instead. ## Capabilities diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyIntentTest.java new file mode 100644 index 0000000000..60ec2b0950 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/UnknownKeyIntentTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * A key the intent does not declare is a validation ERROR, never a silent drop - dirigible #6541. + * + *

+ * Two halves of one failure mode. A key on a typed node (an entity, a field, a relation, a report, + * ...) is dropped by the Gson mapping, which ignores unknown properties: the artifacts look + * completely correct and only the promise the author wrote is missing. A key on a seed ROW is + * dropped by the CSV generator, which emits a column per declared field plus the referenced to-one + * relations: the column disappears, and when it was a NOT NULL FK the import then skips every row. + * Both used to pass through the whole pipeline green. + */ +class UnknownKeyIntentTest { + + private static final String YAML = """ + name: contributions + entities: + - name: ContributionScheme + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true } + - name: Rate + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: percent, type: decimal } + relations: + - { name: ContributionScheme, kind: manyToOne, to: ContributionScheme, required: true } + - { name: revisions, kind: oneToMany, to: Rate } + seeds: + - name: rates + entity: Rate + rows: + - { id: 1, percent: 13.78, ContributionScheme: 1 } + - { id: 2, percent: 4.80, ContributionScheme: 2 } + """; + + @Test + void theShowcaseParses() { + assertDoesNotThrow(() -> IntentParser.parse(YAML)); + } + + /** The case that opened the issue: a seed row key mis-cased against the relation it means. */ + @Test + void aMisCasedSeedRowKeyIsRejectedAndNamesTheRelationItMeant() { + String issue = assertIssue(YAML.replace("ContributionScheme: 1", "contributionScheme: 1"), "row references [contributionScheme]"); + assertTrue(issue.contains("[Rate]"), "the message must name the entity: " + issue); + assertTrue(issue.contains("did you mean [ContributionScheme]?"), "the message must name the nearest declared name: " + issue); + assertTrue(issue.contains("case-sensitive"), "a pure case slip must say so: " + issue); + } + + /** A collection relation has no FK column, so a row keyed by it contributes nothing. */ + @Test + void aSeedRowKeyedByACollectionRelationIsRejected() { + assertIssue(YAML.replace("percent: 13.78", "revisions: 2"), "row references [revisions]"); + } + + @Test + void aSeedRowKeyMatchingNothingIsRejected() { + assertIssue(YAML.replace("percent: 13.78", "percentage: 13.78"), "row references [percentage]"); + } + + /** The lifecycle stage marker is metadata about the row, not a column - it stays accepted. */ + @Test + void theStageMarkerIsNotAnUnknownSeedRowKey() { + String yaml = """ + name: sales + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT, stage: draft } + """; + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } + + /** The other half: a plausible-looking key on a typed node, which used to do nothing at all. */ + @Test + void anUnknownRelationKeyIsRejectedAndLocated() { + String issue = assertIssue( + YAML.replace("to: ContributionScheme, required: true", "to: ContributionScheme, calculatedActionOnCreat: RateAction"), + "unknown key [calculatedActionOnCreat]"); + assertTrue(issue.contains("entities[Rate].relations[ContributionScheme]"), "the message must locate the key: " + issue); + assertTrue(issue.contains("did you mean [calculatedActionOnCreate]?"), "the message must suggest the declared name: " + issue); + } + + @Test + void anUnknownFieldKeyIsRejected() { + assertIssue(YAML.replace("name: percent, type: decimal", "name: percent, type: decimal, lenght: 10"), "unknown key [lenght]"); + } + + /** Case matters on a declared key too - the typed mapping is case-sensitive. */ + @Test + void aMisCasedFieldKeyIsRejected() { + String issue = assertIssue(YAML.replace("name: name, type: string, required: true", "name: name, type: string, Required: true"), + "unknown key [Required]"); + assertTrue(issue.contains("case-sensitive"), "a pure case slip must say so: " + issue); + } + + @Test + void anUnknownRootKeyIsRejected() { + String issue = assertIssue(YAML.replace("entities:", "entites:"), "unknown key [entites]"); + assertTrue(issue.contains("at the intent root"), "a root-level key must be located as such: " + issue); + } + + /** A nested typed block ({@code number:}, {@code dependsOn:}, {@code widget:}) is walked too. */ + @Test + void anUnknownKeyInsideANestedBlockIsRejected() { + String yaml = YAML.replace(" - { name: percent, type: decimal }", + " - { name: code, type: string, number: { series: Rate, stampOn: create, partition: Company } }"); + String issue = assertIssue(yaml, "unknown key [partition]"); + assertTrue(issue.contains("entities[Rate].fields[code].number"), "the message must locate the key: " + issue); + } + + /** + * Author-keyed maps stay opaque: a step's {@code args}, a {@code map:} projection and a relation's + * {@code where:} carry names from the model being described, not from the intent schema. + */ + @Test + void authorKeyedMapsAreNotWalked() { + String yaml = """ + name: orders + entities: + - name: Country + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: region, type: integer } + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: status, type: string } + relations: + - { name: Country, kind: manyToOne, to: Country, where: { region: 2 } } + processes: + - name: OrderApproval + trigger: { onCreate: Order } + steps: + - { name: review, kind: userTask, args: { assignee: manager } } + - { name: activate, kind: serviceTask, args: { setField: status, value: ACTIVE } } + """; + assertDoesNotThrow(() -> IntentParser.parse(yaml)); + } + + /** Unknown keys join the structural issues, so one parse reports everything to fix. */ + @Test + void unknownKeysAreReportedTogetherWithTheStructuralIssues() { + IntentValidationException ex = assertThrows(IntentValidationException.class, + () -> IntentParser.parse(YAML.replace("name: contributions", "name: contributions\nversionn: 2") + .replace("entity: Rate", "entity: Rat"))); + assertEquals(2, ex.getIssues() + .size(), + "both problems should be reported in one pass: " + ex.getIssues()); + } + + private static String assertIssue(String yaml, String expected) { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + String issue = ex.getIssues() + .stream() + .filter(i -> i.contains(expected)) + .findFirst() + .orElse(null); + assertTrue(issue != null, "expected an issue containing [" + expected + "] but got " + ex.getIssues()); + return issue; + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index f9778d91db..64b875d2b3 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -404,6 +404,45 @@ void parse_reports_every_validation_issue_at_once() { "process [Flow] decision [decide] `then` references unknown step [missingStep]"))); } + /** + * A key the intent does not declare is dropped by the typed mapping without a sound, so the whole + * pipeline used to report success over an authored promise that was simply absent (#6541). Both + * shapes are refused: an invented key on a typed node, and a seed row key matching no field or + * to-one relation - each naming the nearest declared name. + */ + @Test + void parse_rejects_an_unknown_key_and_an_unknown_seed_row_key() { + String yaml = """ + name: contributions + entities: + - name: Scheme + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Rate + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: percent, type: decimal } + relations: + - { name: Scheme, kind: manyToOne, to: Scheme, requird: true } + seeds: + - name: rates + entity: Rate + rows: + - { id: 1, percent: 13.78, scheme: 1 } + """; + restAssuredExecutor.execute(() -> given().contentType("text/plain") + .body(yaml) + .when() + .post(PARSE_URL) + .then() + .statusCode(422) + .body("issues", hasItems( + "unknown key [requird] at [entities[Rate].relations[Scheme]] - did you mean [required]?", + "seed [rates] row references [scheme] which is not a field or a to-one relation of [Rate] - did you mean [Scheme]? (names are case-sensitive)"))); + } + @Test void parse_rejects_a_trigger_to_an_unknown_entity() { String yaml = """