Skip to content

Run lifecycle callbacks once for polymorphic values (plan item 2.6) - #4395

Merged
evanchooly merged 1 commit into
masterfrom
polymorphic-lifecycle
Oct 15, 2026
Merged

evanchooly merged 1 commit into
masterfrom
polymorphic-lifecycle

Conversation

@evanchooly

Copy link
Copy Markdown
Member

Plan item 2.6 (review B3). Item 2.5 isn't a separate PR: the plan bundles it into 3.8 ("Includes correctness fix 2.5"), because both rewrite the same subtype loop in PathTarget.

The bug

When a type has lifecycle callbacks, LifecycleEncoder ran @PrePersist for the codec's declared type. It then encoded through EntityEncoder, which hands a subtype value to the subtype's codec. The subtype inherits the callbacks, so its codec is also a LifecycleEncoder and ran them again. As a result, a Circle in a List<Shape>, or encoded through Shape's codec, got @PrePersist and @PostPersist twice. Under reflection the counts were [2, 2] instead of [1, 1].

Change

LifecycleEncoder.encode hands a subtype value to the subtype's codec before running any callback. That codec runs them once, with the subtype's model.

LifecycleDecoder needed no change. It resolves the subtype's model from the discriminator before any callback runs and never re-dispatches to another codec, so @PreLoad and @PostLoad already ran once. The new test pins that.

Tests

CodecHarnessTest.runsPolymorphicLifecycleCallbacksOnce runs under both mappers on a fresh datastore. It uses new harness models: an abstract HarnessCallbackShape that counts all four callbacks in @Transient fields, a HarnessCallbackCircle subclass, and a HarnessCallbackHolder with a List<HarnessCallbackShape>. It checks:

  • nested: a circle inside the holder;
  • root: a circle encoded and decoded through HarnessCallbackShape's codec.

Each case expects one @PrePersist and one @PostPersist on encode, and one @PreLoad and one @PostLoad on decode. The nested case failed before the fix with [2, 2, 0, 0]. The counters are @Transient, so the recorded fixtures are unchanged.

The core and Kotlin suites pass with -Dmorphia.mapper=critter and with reflection: 1481 + 5 tests, 0 failures.

Decisions to review

  1. Only the encoder changed. The plan said to fix both LifecycleEncoder and LifecycleDecoder. The decoder already resolved the subtype before calling back and was never double-calling, which the test confirms.
  2. 2.5 still ships with 3.8, as the plan says. It stays unstruck until then.

LifecycleEncoder ran the declared type's @PrePersist/@PostPersist and then
encoded the value through EntityEncoder, which hands a subtype to the
subtype's codec, whose LifecycleEncoder ran them again. It now hands a
subtype to the subtype's codec before running any callbacks.
Copilot AI balanced review requested due to automatic review settings October 15, 2026 02:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The focused dispatch change follows existing codec behavior, with regression coverage for both encoding paths and no unresolved findings.

0 open findings

What changed in this PR

Fixes duplicate lifecycle callbacks when Morphia encodes polymorphic values by delegating to the subtype codec before running callbacks.

Changes:

  • Moves subtype dispatch ahead of persistence callbacks.
  • Adds nested and root regression tests for both mappers, including load callback counts.
  • Marks plan item 2.6 complete; the decoder remains unchanged.
File Description
design/​mapping-game-plan.md Marks item 2.6 complete.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​models/​HarnessCallbackShape.java Adds transient callback counters.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​models/​HarnessCallbackHolder.java Adds a polymorphic list fixture.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​models/​HarnessCallbackCircle.java Adds the concrete subtype fixture.
core/​src/​test/​java/​dev/​morphia/​test/​mapping/​codec/​harness/​CodecHarnessTest.java Verifies callbacks run once for nested and root values.
core/​src/​main/​java/​dev/​morphia/​mapping/​codec/​pojo/​LifecycleEncoder.java Delegates subtype encoding before callbacks.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@evanchooly
evanchooly merged commit 252995a into master Oct 15, 2026
5 checks passed
@evanchooly
evanchooly deleted the polymorphic-lifecycle branch October 15, 2026 02:59
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.

2 participants