Skip to content

intent: allow a calculated action on a to-one relation (derive an FK server-side) - #6681

Merged
delchev merged 1 commit into
masterfrom
feat/intent-calculated-action-on-relation
Aug 12, 2026
Merged

intent: allow a calculated action on a to-one relation (derive an FK server-side)#6681
delchev merged 1 commit into
masterfrom
feat/intent-calculated-action-on-relation

Conversation

@delchev

@delchev delchev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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:

hook why it cannot do this
init: a literal seed id, and it is the FK's database-level default — applied by the INSERT
dependsOn a UI-only cascade; never fires on a server-side create
setRelationField a BPMN step, and its parser requires value to be a literal integer id

The 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. IntentModel is bound by Gson (IntentParser.parseGSON.fromJson), which ignores unknown properties, so calculatedActionOnCreate on a relations: 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

  • RelationIntent gains calculatedActionOnCreate / calculatedActionOnUpdate + isCalculated().
  • New shared EdmIntentGenerator.putCalculatedAction emits isCalculatedProperty plus the action names, called from both relation builders — relationProperty (same-model) and crossModelRelationProperty. The motivating case is cross-model, so covering only the first would have passed unit tests and failed on the real model.
  • IntentParser 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).

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 its actionAssign macro emit

entity.<Relation> = Beans.get(<Action>.class).calculate(entity);

against the Integer FK with no new branch. The gap was engine-only, so adopting this needs a jar swap and no registry re-seed.

Verification

Unit: EdmIntentGeneratorTest.relationCalculatedActionEmitsTheServerSideCallOutOnBothRelationBuilders asserts the three emitted keys on both builders and that an action-less relation stays unmarked. IntentParserTest asserts 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. Full engine-intent suite: 406 green.

Runtime, against a locally built instance carrying this change, driving a real six-document consumer:

  • all six generated repositories emit the Beans.get(...).calculate(entity) assignment;
  • client-Java batch 871 units / 902 class files, no failures;
  • REST creates omitting the currency returned it derived from the company on all five document types plus payments;
  • an explicitly posted currency was respected, not overwritten;
  • end to end: an ISSUED invoice and a payment created with no currency at all auto-allocated and flipped the invoice to PAID.

Not included — an IntentEmissionCoverageIT case

engine-intent/CLAUDE.md asks every new keyword to extend that gate, and I have not done so; it is recorded as owed in the commit message and in engine-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 compilable custom/ class into the emission-test project — 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 a custom/ fixture there.

Docs

engine-intent/CLAUDE.md and intent-assistant-guide.md both updated, including when to reach for this over init: / 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

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
delchev merged commit 0788f7f into master Aug 12, 2026
10 checks passed
@delchev
delchev deleted the feat/intent-calculated-action-on-relation branch August 12, 2026 20:10
@delchev
delchev restored the feat/intent-calculated-action-on-relation branch August 12, 2026 20:37
@delchev
delchev deleted the feat/intent-calculated-action-on-relation branch August 13, 2026 05:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant