Skip to content

feat(intent): a roll-up's counted child may be owned by another model (#6930) - #6932

Merged
delchev merged 1 commit into
masterfrom
intent-cross-model-rollup
Aug 24, 2026
Merged

feat(intent): a roll-up's counted child may be owned by another model (#6930)#6932
delchev merged 1 commit into
masterfrom
intent-cross-model-rollup

Conversation

@delchev

@delchev delchev commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Closes #6930.

The gap

A rollups: entry required the CHILD - the rows being aggregated - to live in the same model as the parent whose field it maintains. The mirror case was already open (a local child, a cross-model parent reached through the via relation's own model:), but the inverse was inexpressible - and it is the one an n:m pairing forces: a link entity lives with the document that owns one side of the pairing, while the other side's total belongs to the module that owns that side.

Accounts receivable is the case in the issue. sales-invoices owns SalesInvoiceCustomerPayment, so the invoice-side roll-up (paid / balance / PARTIAL-PAID) works today. The payment side of the same rows could not be declared at all: CustomerPayment.allocated and the unapplied figure derived from it belong to customer-payments. So "is this payment fully applied?" was answered by a register report instead of a stored number - which cannot be a list column, a dashboard figure, a schedule condition or a report dimension.

The shape

Two keys, declared by the module that owns the parent:

uses:
  - { model: sales-invoices }
rollups:
  - { name: paymentAllocated, entity: SalesInvoiceCustomerPayment, model: sales-invoices,
      parent: CustomerPayment, via: CustomerPayment, field: allocated,
      op: sum, of: amount, capacity: amount, balance: unapplied }

model: is the owner's uses: alias - the same key, same meaning, a cross-model schedule source already takes. parent: is authored rather than derived, because a foreign child's relations are not in this document: nothing here can walk via to a target. It must be local - a total landing in a third model is that model's roll-up to declare, and writing it from here would invert the dependency edge.

DSL note: this follows the existing rollups vocabulary (entity: + model: + via: + field:) rather than the issue's child: / match: / into: sketch. The issue's own stated precedent - schedules' cross-model source, fromUses: - is what that vocabulary already is; into: in particular already means the target entity in aggregates:, so reusing it for a field would have been a collision.

What it emits

The child's coordinates come from the owner: childProject (the topic - this project publishes nothing about that entity, so a local topic would subscribe to silence) and childGenFolder (the imports), both defaulting to this project, so a local roll-up renders byte-identically. The class name is prefixed with the owner alias and the pipeline's coalescing key gains childModel, because a local and a foreign child of the same name rolling up through the same relation are two handlers - one class name for both would have the pipeline write one file over the other.

Against a silent wrong total: via must reference the declared parent. That needed the owner's relationshipEntityName, now carried on CrossModelSupport.TargetInfo.propertyRelations - a property that resolves but points elsewhere would key the aggregate on foreign ids and look the parents up by them. via / of / by are all resolved against the owner's .model and a miss drops the roll-up loudly (the schedules' cross-model-source rule).

Three deliberate limits

  • capacity / balance / status work; the overdraw GUARD does not. All three are writes on the local parent, and the unapplied balance is half the driver - so they are maintained. What cannot follow across the boundary is the check that refuses a child row overdrawing the parent: it is emitted into the child's own write path, which the owner module generates. Generate reports that instead of letting a capacity pass for an enforced limit.
  • Re-parenting repairs the side the event names. groupingKeys is the union over the OWNER's own consumers, so the -rekeyed notice for this relation exists only if the owner also groups by it. The handler is emitted regardless (same store-driven recompute - it converges whenever the notice does arrive), the parent a row moves to is always correct, and delete + re-create is exact.
  • sensitive: / visibleTo: do not propagate from a foreign of field (the flag is not in this document), so a restricted total declares its own restriction. Documented on both doc pages and in the guide.

Both EdmIntentGenerator sites that walk the roll-ups skip a cross-model child (groupingKeys, buildRollupGuards), as do the two parser propagation loops - otherwise a local entity that merely shares the foreign child's name would be treated as it. The editor's glue diagram anchors such a card to the local parent and names the foreign child in its detail line, as a cross-model schedule does.

Verification

  • GlueRollupCrossModelTest - 14 tests: the emitted coordinates and class name, the perspective read off a real owner model (mocked repository), a via pointing at another entity and an unresolvable of both dropped with an issue, every parse refusal, and the local case unchanged.
  • engine-intent + ide-template unit suites: 893 tests green.
  • IntentEngineIT (58) and IntentEmissionCoverageIT green - they guard the local rendering path the template's childProject / childGenFolder rename touched.
  • mvn formatter:validate clean; javadoc clean under -P release.

Docs: dirigible.io PR (help/intent glue.md + dsl-reference.md) and an IntentFile spec proposal, both linked below.

🤖 Generated with Claude Code

…#6930)

A `rollups:` entry required the CHILD - the rows being aggregated - to be
declared in the same model as the parent whose field it maintains. The mirror
case had been opened before (a local child, a cross-model PARENT reached through
the `via` relation's own `model:`), but the inverse was inexpressible, and it is
the one an n:m pairing forces: a link entity lives with the document that owns
ONE side of the pairing, while the other side's total belongs to the module that
owns that side.

Accounts receivable is the case. `sales-invoices` owns the allocation entity and
therefore the invoice-side roll-up (paid / balance / PARTIAL-PAID) works today.
The payment side of the same rows could not be declared at all: the sum of a
payment's allocations, and the unapplied figure derived from it, belong to
`customer-payments`. So "is this payment fully applied?" was answered by a
register report instead of a stored number - which cannot be a list column, a
dashboard figure, a schedule condition or a report dimension.

Two keys, declared by the module that owns the parent:

    rollups:
      - { name: paymentAllocated, entity: SalesInvoiceCustomerPayment,
          model: sales-invoices, parent: CustomerPayment, via: CustomerPayment,
          field: allocated, op: sum, of: amount,
          capacity: amount, balance: unapplied }

`model:` is the owner's `uses:` alias - the same key, with the same meaning,
that a cross-model schedule source already takes. `parent:` is authored rather
than derived because a foreign child's relations are not in this document:
nothing here can walk `via` to a target, which is also why `via` / `of` / `by`
are resolved against the OWNER's `.model` at generation time and a miss drops
the roll-up loudly. It must be LOCAL - a total landing in a third model is that
model's roll-up to declare, and writing it from here would invert the dependency
edge. The DSL follows the existing rollups vocabulary rather than the issue's
`child:` / `match:` / `into:` sketch, which the issue's own stated precedent
(schedules, `fromUses:`) implies.

Emission is the child's coordinates coming from the owner: `childProject` (the
topic - this project publishes nothing about that entity, so a local topic would
subscribe to silence) and `childGenFolder` (the imports), both defaulting to
this project, so a local roll-up renders byte-identically. The class name is
prefixed with the owner alias and the pipeline's coalescing key gains
`childModel`: a local and a foreign child of the same name rolling up through
the same relation are two handlers, and one class name for both would have the
pipeline write one file over the other.

Guards against a silent wrong total: `via` must reference the declared parent,
which needs the owner's `relationshipEntityName` - now carried on
`CrossModelSupport.TargetInfo.propertyRelations`. A property that resolves but
points elsewhere would key the aggregate on foreign ids and look the parents up
by them.

Three deliberate limits. `capacity` / `balance` / `status` ARE maintained (all
three are writes on the local parent, and the unapplied balance is half the
driver), but the overdraw GUARD is not installed - that check lives in the
child's own write path, which the owner module generates - and Generate reports
it rather than letting a capacity pass for an enforced limit. The vacated side
of a re-parented foreign row is repaired only when the owner publishes a re-key
notice for that relation, since `groupingKeys` is the union over the OWNER's own
consumers; the handler is emitted regardless (same store-driven recompute, it
converges whenever the notice does arrive) and delete + re-create is exact.
And `sensitive:` / `visibleTo:` do not propagate from a foreign field, so a
restricted total declares its own restriction.

Both `EdmIntentGenerator` sites that walk the roll-ups skip a cross-model child
(`groupingKeys`, `buildRollupGuards`), as do the two parser propagation loops -
otherwise a local entity that merely SHARES the foreign child's name would be
treated as it. The editor's glue diagram anchors such a card to the local parent
and names the foreign child in its detail line, as a cross-model schedule does.

Covered by `GlueRollupCrossModelTest`: the emitted coordinates and class name,
the perspective read off a REAL owner model, a `via` pointing at another entity
and an unresolvable `of` both dropped with an issue, every parse refusal, and
the local case unchanged. `IntentEngineIT` (58) and `IntentEmissionCoverageIT`
guard the local rendering path the template rename touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@delchev
delchev merged commit f71562e into master Aug 24, 2026
10 checks passed
@delchev
delchev deleted the intent-cross-model-rollup branch August 24, 2026 19:01
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.

intent: rollups with a cross-model child - sum foreign n:m allocation rows onto the local parent (CustomerPayment.allocated / Customer.unappliedCredit)

1 participant