Skip to content

Don't soft-destroy nested records before save#78

Open
Drakula2k wants to merge 3 commits into
simi:masterfrom
Drakula2k:fix-early-soft-destroy
Open

Don't soft-destroy nested records before save#78
Drakula2k wants to merge 3 commits into
simi:masterfrom
Drakula2k:fix-early-soft-destroy

Conversation

@Drakula2k
Copy link
Copy Markdown

Currently paranoid nested attributes call destroy_document right when the nested attributes are assigned, regardless of validations on the parent record, or .save being called at all.

This PR fixes it.

Here is a bit of git archeology from Claude on this issue:

  Oct 24 2012 — mongoid dc0908953 / dfe40a3ac (3.0.10), Durran Jordan:

  ▎ Fix removal on embedded document via nested attrs.
  ▎ Previously, embedded documents would get removed from the relation on a nested attributes delete immediately, which would cause issues if the parent document validation failed. Now these removals are delayed until the parent passes
  ▎ validation and goes to persist.

  This commit is fixing the exact same bug we just fixed — but only for non-paranoid embedded docs. Durran intentionally kept paranoid docs on the immediate-destroy path by adding || doc.paranoid? to the condition. The most likely reason:
   the deferred path eventually calls relation.delete(doc) → add_atomic_pull(doc), which would do a hard $pull and defeat paranoia entirely. Keeping paranoid on the immediate path preserved soft-delete semantics, but at the cost of the
  validation-failure leak.

  Oct 29 2012 — mongoid a9a65525d (next release), same author:

  ▎ Remove Mongoid::Paranoia. [ close #2433 ]

  Five days later, when Durran ripped built-in Paranoia out of upstream, he also removed the || doc.paranoid? short-circuit. The diff is literally the change we just made to the gem:

  - if !doc.embedded? || parent.new_record? || doc.paranoid?
  + if !doc.embedded? || parent.new_record?

  He removed it because there were no longer any paranoid docs to need it — not because he had figured out a way to make paranoid + deferred-destroy work together. The combined intent (defer + preserve soft-delete) was never reconciled in
   upstream Mongoid.

  Feb 2 2013 — mongoid_paranoia "Init" by Josef Šimánek:

  When Josef extracted Paranoia into a standalone gem, he copied the patches verbatim from Mongoid 3.0.10 (the version with the || doc.paranoid? short-circuit). The bug came along for the ride and has lived in every version of
  mongoid_paranoia since.

  Why our fix is safe

  Our fix combines what neither of the original commits did:

  - Remove || doc.paranoid? from Many#destroy (Durran's intent in a9a65525d, generalized) → defers paranoid destroys to flagged_destroys.
  - Keep the EmbedsMany::Proxy#delete patch's _assigning? && paranoid? branch that calls doc.destroy(suppress: true) (Durran's original concern in dc0908953) → when the deferred lambda fires inside process_flagged_destroys's _assigning do
   ... end wrap, paranoia's soft-delete still runs.

  The two patches together preserve paranoia's soft-delete semantics AND defer the write until after validation. That's the missing combination Durran never got to ship before he removed Paranoia entirely.

@simi
Copy link
Copy Markdown
Owner

simi commented May 8, 2026

Hello, thanks for the PR @Drakula2k. Seems your e-friend left some excessive comments, please consider removing them if not useful or sharing some historical states. Can you share what risks this brings? Is it 100% backwards compatible (not saying original behaviour was right and intended)?

@Drakula2k
Copy link
Copy Markdown
Author

Drakula2k commented May 11, 2026

@simi thanks for checking! Cleaned up the specs and comments.

I can't say it's 100% backwards compatible, but almost 😃

On a fairly large codebase of TableCheck monolith I found only 1 case where this change caused a regression: we use embeds_many :phones on one of models, and then also override the equality operator on this embedded Phone model (so it can't rely on IDs being different on object_already_related?).

So when the same phone is destroyed but then added back (within the same request/parameters) it was implicitly relying on this instant removal to keep the newly added phone.

I've added a fix for this by patching object_already_related? to check .flagged_for_destroy?. I'll probably open a PR with it on mongoid itself too.

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