feat(intent): a roll-up's counted child may be owned by another model (#6930) - #6932
Merged
Conversation
…#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>
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.
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 theviarelation's ownmodel:), 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-invoicesownsSalesInvoiceCustomerPayment, 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.allocatedand theunappliedfigure derived from it belong tocustomer-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:
model:is the owner'suses: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 walkviato 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
rollupsvocabulary (entity:+model:+via:+field:) rather than the issue'schild:/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 inaggregates:, 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) andchildGenFolder(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 gainschildModel, 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:
viamust reference the declared parent. That needed the owner'srelationshipEntityName, now carried onCrossModelSupport.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/byare all resolved against the owner's.modeland a miss drops the roll-up loudly (the schedules' cross-model-source rule).Three deliberate limits
capacity/balance/statuswork; 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 acapacitypass for an enforced limit.groupingKeysis the union over the OWNER's own consumers, so the-rekeyednotice 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 foreignoffield (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
EdmIntentGeneratorsites 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), aviapointing at another entity and an unresolvableofboth dropped with an issue, every parse refusal, and the local case unchanged.IntentEngineIT(58) andIntentEmissionCoverageITgreen - they guard the local rendering path the template'schildProject/childGenFolderrename touched.mvn formatter:validateclean; 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