intent: allow a calculated action on a to-one relation (derive an FK server-side) - #6681
Merged
Merged
Conversation
A to-one relation could not express a DERIVED default. `init:` covers the fixed case (a literal target seed id), but nothing covered "read it off another record" - a sales document's currency defaulting from its company's base currency. The adjacent hooks all fall short by design: `dependsOn` is a UI-only cascade that never fires on a server-side create, and the `setRelationField` process step takes a literal id too. Authoring `calculatedActionOnCreate` on a relation looked like it should work and silently did nothing: the typed mapping is Gson, which ignores unknown properties, so the key was dropped at parse time with no error - the #6541 family, one level up from seed rows. RelationIntent gains calculatedActionOnCreate/OnUpdate + isCalculated(), and the new shared EdmIntentGenerator.putCalculatedAction emits isCalculatedProperty plus the action names from BOTH relation builders (same-model relationProperty and crossModelRelationProperty). The DAO template needed NO change, which is the point worth recording: a relation is already an ordinary property in the .model - the FK column, typed to the target's key - so the template's shared property loop and its actionAssign macro emit `entity.<Relation> = Beans.get(<class>.class).calculate(entity);` against the Integer FK with no new branch. The gap was engine-only, so consuming it needs a jar swap and no registry re-seed. The parser rejects the combination where there is no single FK to assign, or where the value is not the author's to set: a collection relation, a composition parent (preset by the layout) and an EntityStatus badge (owned by the workflow transitions - `init:` is its starting-value hook). Tests: EdmIntentGeneratorTest asserts the three emitted keys on both builders and that an action-less relation stays unmarked; IntentParserTest asserts the key binds onto the model (the typed-mapping test is the regression guard for the silent drop) and that a collection relation is rejected. Full engine-intent suite 406 green. NOT done, owed: an IntentEmissionCoverageIT case. That fixture has no calculated action of either kind today, and adding one needs a compilable custom/ class in the emission-test project - a generated repository referencing a missing class fails the whole client-Java batch and with it every REST assertion in that gate. Left for a change that can run the gate rather than guessed at.
delchev
added a commit
that referenced
this pull request
Aug 13, 2026
) The keyword landed in #6681 without an IntentEmissionCoverageIT case, which engine-intent/CLAUDE.md requires of every new keyword in the same PR. This pays that back. The fixture gains Tariff + Quote, where Quote.Tariff declares `calculatedActionOnCreate: QuoteTariffAction`, plus the hand-written custom/QuoteTariffAction.java the contract requires - written into the emission-test project by the IT itself, since a generated repository referencing a missing class fails the whole client-Java batch and with it every REST assertion in this gate. That coupling is why the case was deferred; carrying the class as part of the fixture is what makes it safe. Three layers, because no one of them alone catches this keyword's regression: - the .model property attribute; - the repository's `entity.Tariff = Beans.get(QuoteTariffAction.class) .calculate(entity);` plus the decoded imports: line - THE regression, since a relation is an ordinary property in the .model but only fields used to carry the calculated attributes into it, so the keyword parsed and validated while the emitted repository had no assignment at all and the create stored null with every pipeline step green; - the runtime, both directions of the documented contract: a create that OMITS the FK comes back carrying the base-flagged tariff, and one that SUPPLIES a tariff keeps it ("an explicit pick always wins"). The base tariff is seeded as row 2, not row 1, so the runtime assertion cannot be satisfied by a first row or a stray column default - only by the action having run and matched on `base`. The runtime layer earned its place while writing this: annotating the action with org.springframework.stereotype.Component instead of the SDK's org.eclipse.dirigible.sdk.component.Component compiles cleanly and then fails every create with "No qualifying bean of type custom.QuoteTariffAction available". An emission-only case - the cheaper option - would have passed while the feature was dead at runtime. Verified: IntentEmissionCoverageIT green (Tests run: 1, Failures: 0, Errors: 0). Both new assertion groups were also observed FAILING first - the emission group on a wrong model filename, the runtime group on the 500 above - so neither is passing vacuously. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Lets a to-one relation declare
calculatedActionOnCreate/calculatedActionOnUpdate, the counterpart of the field-level calculated action, so a relation's FK can be derived server-side instead of only fixed.Why
init:covers the fixed case — a literal target seed id. Nothing covered "read it off another record", and the adjacent hooks all fall short by design:init:dependsOnsetRelationFieldvalueto be a literal integer idThe motivating case: a sales document's Currency defaulting from its company's base currency. Auto-settlement matches a payment to an invoice only on an exact currency equality, so a currency-less document can never settle itself — it sits unpaid next to the credit that should have cleared it.
The silent-drop half
Authoring the key on a relation looked like it should work and did nothing.
IntentModelis bound by Gson (IntentParser.parse→GSON.fromJson), which ignores unknown properties, socalculatedActionOnCreateon arelations:line was discarded at parse time — no error, no warning, generate 200, code-gen 201, publish 200, and a default that never appears. That is the #6541 family one level up from seed rows; I have commented there arguing the ask generalises to any unknown intent key rather than another per-keyword guard.How
RelationIntentgainscalculatedActionOnCreate/calculatedActionOnUpdate+isCalculated().EdmIntentGenerator.putCalculatedActionemitsisCalculatedPropertyplus the action names, called from both relation builders —relationProperty(same-model) andcrossModelRelationProperty. The motivating case is cross-model, so covering only the first would have passed unit tests and failed on the real model.IntentParserrejects the combination where there is no single FK to assign, or where the value is not the author's to set: a collection relation, a composition parent (preset by the layout), and anEntityStatusbadge (owned by the workflow transitions —init:is its starting-value hook).The DAO template needed no change, which is the part worth knowing: a relation is already an ordinary property in the
.model— the FK column, typed to the target's key — so the template's shared property loop and itsactionAssignmacro emitagainst the
IntegerFK with no new branch. The gap was engine-only, so adopting this needs a jar swap and no registry re-seed.Verification
Unit:
EdmIntentGeneratorTest.relationCalculatedActionEmitsTheServerSideCallOutOnBothRelationBuildersasserts the three emitted keys on both builders and that an action-less relation stays unmarked.IntentParserTestasserts the key binds onto the model — deliberately a typed-mapping test, since that binding is the regression guard for the silent drop — and that a collection relation is rejected. Fullengine-intentsuite: 406 green.Runtime, against a locally built instance carrying this change, driving a real six-document consumer:
Beans.get(...).calculate(entity)assignment;Not included — an
IntentEmissionCoverageITcaseengine-intent/CLAUDE.mdasks every new keyword to extend that gate, and I have not done so; it is recorded as owed in the commit message and inengine-intent/CLAUDE.md. The reason is concrete rather than an oversight: that fixture carries no calculated action of either kind today, so adding one means introducing the first compilablecustom/class into theemission-testproject — and a generated repository referencing a missing class fails the whole client-Java batch, taking every REST assertion in that gate with it. I could not run that gate locally to confirm, and guessing at a smoke gate seemed worse than a documented gap. Happy to add it if you can point me at the intended shape for acustom/fixture there.Docs
engine-intent/CLAUDE.mdandintent-assistant-guide.mdboth updated, including when to reach for this overinit:/dependsOn/setRelationField, and the rule that the action should return the current value unchanged when one is already set so a user's explicit pick always wins.🤖 Generated with Claude Code