Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<owner>_<target>`) / `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: `<Declaring><Target>` (or the relation's `through: <Name>`) 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, `<project>-Settings-<entity>` 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.<model>.data.<entityname>` 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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);
Expand All @@ -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;
}

Expand All @@ -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<String> issues = new ArrayList<>();
private static void validate(IntentModel model, List<String> 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
Expand Down Expand Up @@ -5640,6 +5647,7 @@ private static void validateSeeds(IntentModel model, Set<String> 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);
}
}
}
Expand Down Expand Up @@ -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<String> issues) {
if (entity == null) {
return; // the unknown entity is reported separately
}
Set<String> 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<String, Object> 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()) {
Expand Down Expand Up @@ -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));
}
}
}
Expand Down
Loading
Loading