Skip to content

DataContext: attribute-level change tracking and merge hardening#5446

Open
knstvk wants to merge 14 commits into
masterfrom
data-context-attribute-tracking
Open

DataContext: attribute-level change tracking and merge hardening#5446
knstvk wants to merge 14 commits into
masterfrom
data-context-attribute-tracking

Conversation

@knstvk

@knstvk knstvk commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch reworks DataContextImpl change tracking and merge so that unsaved
user edits are no longer lost or spuriously reported. Tracking becomes
per-attribute (with baselines) instead of per-entity, the merge now protects
dirty attributes from being overwritten, and several long-standing merge and
loaded-state defects are fixed. Targets the 3.1 line.

What changed

  • Attribute-level change tracking. A new internal DataContextChangeTracker
    records, per managed instance, which attributes the user changed and the
    baseline each change is measured against (scalar value, reference id, embedded
    dotted path, or an order-insensitive membership bag for collections).
    Reverting an attribute to its baseline un-dirties it; when the last dirty
    attribute of an entity reverts, the entity leaves getModified() (unless kept
    via setModified(entity, true)).

  • Merge conflict rule: user edits win over incoming state. For a dirty
    attribute on an already-managed instance, a non-fresh merge skips the copy and
    a fresh merge keeps the user value but rebaselines it (un-dirtying only if the
    incoming value is now equal). Clean attributes copy as before. Reloads and
    stale copies no longer clobber unsaved edits.

  • Loaded-state correctness and durability. The merge resets a pre-existing
    managed instance's loaded-state cache before its copy loops (recomputed from
    the unioned fetch group) instead of copying the source's cache, so a save no
    longer writes null over a loaded value. A value that becomes present because it
    was set or merge-installed is now marked loaded, and set/fetched loaded-state
    stays durable across a fresh narrower merge.

  • Event model split. Merge-triggered ChangeEvents are deferred and fired
    when the outermost merge unwinds; an edit injected by a listener during a merge
    is now recognized and tracked instead of being swallowed. Replaces the old
    disableListeners boolean.

  • Composition and child→parent saves. DataContextInternal.mergeFromChild
    lets a child context's edits win over the parent and unions the child's dirty
    attributes into the parent's tracker. A child save marks the composition owner
    chain modified (reopen protection only, not persisted).

  • Identity map on merge-transplant. A merge that brings a
    reference/collection loaded onto an instance the context tracked unfetched now
    merges the reached nodes into the context instead of transplanting source
    copies.

New public API

  • DataContext.getModifiedAttributes(Object entity) — default method returning
    the names of the entity's modified, not-yet-saved attributes.
  • Diagnostics log category io.jmix.flowui.datacontext.diagnostics (DEBUG):
    reports every dirty/revert/rebaseline transition, merge-skips of dirty
    attributes, and a once-per-instance notice when a read-only NoopDataContext
    receives a merge() call.

isModified, getModified, setModified, clearChanges, and hasChanges
keep their signatures; isModified membership is now exact.

Fixed issues

Tests

New data_components suites cover the tracker, the merge conflict policy,
diagnostics, and loaded-state behavior, plus StandardDetailView cases for the
unsaved-changes prompt. A DataContextInvariantFuzzTest generates random
load/merge/edit scenarios and enforces a tiered regression gate over the merge
invariants (slow test, run with -PincludeSlowTests=true).

@knstvk knstvk changed the title data-context-attribute-tracking DataContext attribute tracking Jul 12, 2026
knstvk added 12 commits July 17, 2026 09:58
On a root non-fresh merge of a fuller copy onto a pre-existing managed
instance, the copy loops read isLoaded from a stale CachingLoadedPropertiesInfo
and skipped newly available values; the end-of-merge reset then made those
attributes report loaded while holding null, so save wrote null over real
database values.

Reset the destination's loaded-state cache before the copy loops
(resetLoadedInfoBeforeCopy in mergeState) so copy decisions recompute from the
unioned fetch group and apply the values. Remove mergeFromChild's now-redundant
manual pre-merge reset. Add two DataContextMergeTest regressions.
#4907)

mergeFromChild now marks the merged composition child's owner chain in a
dedicated compositionModifiedOwners set, consulted by isModified so the
reopen gate keeps the in-context instance instead of reloading a stale
copy. Owners are kept out of modifiedInstances, so they are not persisted
and the composition-save contract holds. Fixes #4907 half (ii).
…arrower merge

Generalize the increment-03 cold reset to the fresh path: widen
isColdResetTarget from `isRoot && !isFresh` to `(isRoot || isFresh)` so a
fresh merge onto a pre-existing caching instance recomputes loaded-state
from the unioned fetch group instead of copying the narrower source's
cache. A genuinely fetched attribute the narrower source omits no longer
reverts to unloaded. Safe because mergeFetchGroups always unions, never
narrows. Fuzz I2 83->0, I1 held at 0.
…d injected edits (#4071, #1258)

Replace the disableListeners boolean with a mergeDepth counter, a recorded
mergeApplied set of the merge's own (entity, attribute) writes, and a deferred
ChangeEvent queue flushed when the outermost merge unwinds.

- #4071: a property edit merged into an already-managed instance now fires a
  ChangeEvent (before: the whole merge ran suppressed).
- #1258: a change fired during a merge that the merge did not apply is a
  listener-injected user edit and is now tracked, not dropped.

Clear mergeApplied before the flush so a throwing flush listener cannot leak
stale entries into the next merge. Pinned by four DataContextMergePolicyTest
cases; fuzz gate a pure no-regression net.
…1409)

StandardDetailView.hasUnsavedChanges() trusted the modifiedAfterOpen latch,
which stays true after an edit is reverted to its baseline. After the latch
gate, require the DataContext modified set to be non-empty, so reverting all
edits clears the prompt while a pending new entity is still reported. The
latch and the save-notification path are unchanged.
DataContextInvariantFuzzTest was a measurement harness that always
passed and only printed a report. Add a tiered gate in the then: block:
the closed invariants (I1, I2, I3, I6, I7, exceptions) must stay at 0 at
any scenario count, and the known residuals are held to upper bounds
(I4-ref-not-in-context <= 90, I4-ref-not-identical <= 64, I5 <= 9),
enforced only at the canonical config (200 scenarios, default seed) so
exploratory runs check only the zeros. A residual below its bound keeps
the gate green and logs a note; the nightly diff still catches
improvements. The failure message distinguishes a real regression from
fuzz-config drift.

Add a comment to the sales test entities (Order, OrderLine, Customer,
Address) noting that their attribute set feeds the fuzz baseline and a
structural change requires re-baselining.

Slow-only (-PincludeSlowTests=true), unchanged. Verified green at the
baseline: zeros hold, I4 = 90/64 and I5 = 9, exactly at the bounds.
@knstvk knstvk changed the title DataContext attribute tracking DataContext: attribute-level change tracking and merge hardening Jul 21, 2026
@knstvk
knstvk marked this pull request as ready for review July 21, 2026 08:54
@knstvk
knstvk requested a review from Gavrilov-Ivan July 21, 2026 08:54
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