Skip to content

fix(model): keep soft deletes reversible and make softDelete=false a real delete - #4397

Merged
bpamiri merged 5 commits into
developfrom
peter/soft-delete-semantics
Oct 6, 2026
Merged

bpamiri merged 5 commits into
developfrom
peter/soft-delete-semantics

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #4370, closes #4371, closes #4372.

Summary

One rule for all three issues:

  • A soft delete is reversible. It never destroys or unlinks data a restore would need.
  • A permanent delete (softDelete=false) is a real DELETE, and it includes rows that are already soft-deleted.

#4370: soft-deleting a parent no longer destroys or unlinks its dependents

$deleteDependents() ran every dependent= action whatever kind of delete the parent got. Now, when the parent is soft-deleted:

dependent= Child has a soft-delete column Child has none
delete / deleteAll soft-deleted (as before) left alone (was hard-deleted)
remove / removeAll left alone (was unlinked) left alone (was unlinked)

dependent= still applies in full on a real delete. The decision lives in $dependentKeptBySoftDelete().

#4371: softDelete=false includes rows that are already soft-deleted

delete(), deleteAll(), deleteOne() and deleteByKey() with softDelete=false now always set includeSoftDeletes=true, for their own rows and for dependent= children. A purge needs one argument, not two. An explicit includeSoftDeletes=false alongside it no longer keeps soft-deleted rows. That's deliberate: a real delete means real.

#4372: an object on a soft-deleted row

  • A soft delete runs the parent UPDATE first, with AND deletedAt IS NULL, and handles dependents only when that hit the row. The foreign-key reason for doing dependents first doesn't apply, because the row stays. So a second soft delete(), or one from a stale copy, returns false and touches nothing, even inside a caller's transaction {}. Permanent deletes keep the old order.
  • The "already deleted" short-circuit reads the persisted deletedAt ($persistedSoftDeleteValue()), so a value set only in memory doesn't skip the delete.
  • After a soft delete the object holds the deletedAt it wrote ($markSoftDeleted()). Only that column's change baseline moves; every other property keeps its own.
  • A vetoed or rolled-back soft delete puts deletedAt back ($restoreSoftDeleteState()), so a retry works:
    • an afterDelete returning false, or a dependent that throws: a catch-free finally in $delete() that restores only when the delete didn't complete;
    • a rollback of the surrounding Wheels transaction: a softDeleteBefore field on the transaction-queue entry, restored with the saved-change state in $restoreSavedChangesBeforeQueue(). That includes the callbacks = false path from fix(model): skip commit callbacks when callbacks = false #4378.
  • reload() finds a soft-deleted row (includeSoftDeletes=true, still reload=true). It throws Wheels.RecordNotFound when the row is gone, instead of blanking every property; uncaught in an action, that renders the 404 page. An object with no key is left as it is: an unsaved object, or a save on Oracle, which returns no generated key (save(reload=true)). A finder answering false instead of a query (BoxLang) counts as no row.

The new helpers are all public and $-prefixed (invariant 7). The finally has no loop (invariant 12).

Behaviour changes

Each has a changed fragment and an entry in the 4.1 → 4.2 upgrade guide:

  • a parent soft delete leaves non-soft-delete dependents alone;
  • softDelete=false always includes soft-deleted rows, and with instantiate=true those rows now run their delete callbacks;
  • reload() on a missing row throws.

Tests

New model/SoftDeleteSemanticsSpec.cfc, 18 checks. It uses five small fixture models on the existing tables. SoftDeletePostChildPosts pairs the posts table with itself, because it's the only soft-delete table, to give a soft-delete parent soft-delete children.

RED from a clean develop worktree with only the spec and fixtures added, Lucee 7: 11 checks fail. For example:

  • leaves dependent=delete children that can't be soft-deleted in place: expected 3, received 0.
  • leaves dependent=removeAll children linked to it: NOT NULL constraint failed: c_o_r_e_comments.postid.
  • refuses a second soft delete...: expected false, got true.
  • reloads the soft-deleted row instead of blanking the object: expected the title, received "".
  • throws from reload() when the row no longer exists: no error raised.

Five checks pass on develop, as baselines:

  • soft-deletable dependents;
  • the permanent-delete path;
  • deletedAt set only in memory;
  • the two rollback-restore checks. Develop never sets deletedAt on the object, so there is nothing to restore.

The two rollback-restore checks are proven by mutation instead. With this branch and return; as the first line of $restoreSoftDeleteState(), both fail (Expected [] but received [2026-10-05 ...]), and they pass without it.

GREEN, full core suite on SQLite, 557 bundles, 0 fail / 0 error, on the branch rebased onto today's develop:

Engine Pass
Lucee 7 6964
BoxLang 6964
Adobe 2023 6959
Adobe 2025 6959
RustCFML 0.693 6939 (173 skipped)

Also passing: the complexity gate, the consumer-docs check and the guides build (852 pages).

Docs

  • Models guide: a "Soft delete" section (reversible, dependent= on a soft delete, reload(), purging with softDelete=false, and that restoring doesn't cascade).
  • Associations guide: a dependent bullet for soft deletes.
  • 4.1 → 4.2 upgrade guide: three sections.
  • .ai/models.md (both copies).
  • Four fragments, under changelog.d/437*.

🤖 Generated with Claude Code

Review follow-ups (head f1fa37b63)

  • 998e4b893 (rev1-r1 MEDIUM): a default delete() of a model without a soft-delete column forced includeSoftDeletes=true, so already soft-deleted dependent="deleteAll" children got deletedAt stamped again. Now only softDelete=false includes them. New check "doesn't re-stamp soft-deleted children when a parent without deletedAt is deleted" (fixture AuthorDependentAllPosts). It failed on 668b49e3e: the 2020 stamp became 2026.
  • f1fa37b63 (matrix 37366055439 must-fixes):
    • Oracle: save(reload=true) threw RecordNotFound, because Oracle returns no key. reload() now returns at once without a key, and treats a non-query as no row. New check: "leaves an object without a key alone on reload()".
    • CockroachDB: the two SoftDeletePostChildPosts specs paired a post with an author id, which only worked where sequential ids overlap. They now create their own parent and two child posts ($parentPostWithChildren()).

Local results on f1fa37b63:

Run Pass Fail Error
Lucee 7 + SQLite, full suite (557 bundles) 6966 0 0
BoxLang + SQLite, full suite (557 bundles) 6966 0 0
Lucee 7 + CockroachDB, model specs 1443 0 0
Lucee 7 + Oracle, model specs 1457 0 0

…real delete

- A soft delete no longer destroys or unlinks dependents a restore would need.
  Children with their own soft-delete column are soft-deleted; others are left
  alone. dependent= then applies to them only on a real delete.
- softDelete=false on delete(), deleteAll(), deleteOne() and deleteByKey() also
  removes rows that are already soft-deleted, including dependent children.
- A soft delete runs the parent UPDATE (AND deletedAt IS NULL) first, then its
  dependents only when it hit the row. So a second or stale soft delete returns
  false and changes nothing, even inside a caller's transaction.
- After a soft delete the object holds the deletedAt it wrote. A vetoed or
  rolled-back delete puts it back, so a retry works.
- reload() reloads a soft-deleted row and throws Wheels.RecordNotFound when the
  row is gone, instead of blanking the object.

Closes #4370, #4371, #4372.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review: head 668b49e. One MEDIUM to fix; everything else is APPROVE.

Checked against orch1's focus list and my build notes

  • A soft delete never destroys or unlinks. $dependentKeptBySoftDelete() skips remove/removeAll outright on a soft-deleted parent. It also skips delete/deleteAll when the child model has no soft-delete column (model(...).$softDeletion()). Specs pin both, plus soft-deletable children.
  • Parent UPDATE first, dependents only when it hit one row. delete() appends AND <deletedAt> IS NULL and captures the timestamp from sql[2], which is the bound param $addDeleteClause appends. $deleteRecordAndDependents() runs the statement first and calls $markSoftDeleted and $deleteDependents(parentSoftDeleted=true) only on recordCount == 1. The stale-copy-inside-a-caller's-transaction spec pins that. Permanent deletes keep dependents-first order for foreign keys.
  • Restore on failure (my three build notes).
    • (a) The finally in $delete() is catch-free with no loop (invariants 12 and 22), and the restore is in public $restoreSoftDeleteState().
    • (b) It restores only when !local.rv, and the try's only statement assigns rv, so a throw also restores.
    • (c) The mutation steps in the body (return; first in $restoreSoftDeleteState() makes both checks fail) are a valid RED.
  • Outer rollback. softDeleteBefore rides on the queue entry from both $enqueueTransactionCallbacks and $enqueueSavedChangesRestore. So the callbacks=false path from fix(model): skip commit callbacks when callbacks = false #4378 is kept, and it's restored in $restoreSavedChangesBeforeQueue(), a normal helper rather than a finally.
  • Second soft delete is a no-op. $persistedSoftDeleteValue() reads changedFrom() when the column was edited in memory, so a value set only in memory still deletes. Pinned.
  • reload() uses includeSoftDeletes = true and throws Wheels.RecordNotFound when no row exists. Its fragment notes the 404 mapping.
  • Docs. The two changed fragments and the three upgrade-guide sections match the code. Both .ai/models.md copies are byte-identical.

MEDIUM: a soft-delete-less parent now re-stamps already soft-deleted dependent="deleteAll" children

  • In delete(), the else branch sets includeSoftDeletes = true whenever the class has no soft-delete column, even on the default softDelete = true call. $deleteDependents() then forwards softDelete = true, includeSoftDeletes = true.
  • Example: an Author (no deletedAt) with hasMany(name="posts", dependent="deleteAll"), where Post has deletedAt. author.delete() runs UPDATE posts SET deletedAt = <now> WHERE authorId = X with no deletedAt IS NULL filter, so posts deleted earlier lose their original deletedAt. On develop they were filtered out.
  • For dependent="delete" the same reach is harmless, because each child's delete() now returns false early. In neither case does it purge anything, so the forced includeSoftDeletes buys nothing on the default call.
  • Fix (one line), keeping develop's behaviour: } else if (!arguments.softDelete) {. The existing "reaches dependent children that are already soft-deleted" spec passes softDelete = false explicitly, so it still holds.
  • If the intent is that deleting a parent with no soft-delete column should hard-delete its soft-deletable children, that's a separate behaviour change. It would need softDelete = false forwarded to the dependents, plus a decision and a changed fragment.
  • Either way, please add a spec for this case.

Overlap: #4394 (dependent= + callbacks=false) touches $delete/$deleteDependents too. Whichever lands second needs a rebase that keeps both the callbacks forwarding and the parentSoftDeleted gate.

PR CI at review time: 25 pending, 2 skipping. The compat matrix (37366055439) is queued. The final follows the MEDIUM fix (the delta is one line plus a spec), the matrix, and PR CI.

A default delete() of a model without a soft-delete column forced
includeSoftDeletes=true, so its dependent="deleteAll" soft-delete children
that were already soft-deleted got deletedAt stamped again. Only
softDelete=false now includes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Matrix 37366055439 (head 668b49e): two MUST-FIX regressions. 26 legs are fully green. SoftDeleteSemanticsSpec is 16/16 on every MySQL, PostgreSQL, SQL Server, SQLite and H2 leg.

MUST-FIX 1: save(reload = true) now throws on every Oracle leg (5/5 engines).

  • crudSpec › "should not throw error when saving a new model without properties": tag.new().save(reload = true).

  • On Oracle the generated key isn't returned for that insert, so key() is "" and the new reload() code fails:

    • Lucee and Adobe: Wheels.RecordNotFound: Can't reload this Tag object: no row exists for its key ().
    • BoxLang: findByKey("") returns false, so local.query.recordCount fails with The instance [java.lang.Boolean] has no public field ... [RECORDCOUNT].
  • On develop, the same path silently blanked the properties but didn't throw, so this is a new failure on a normal create path.

  • Suggested fix, in reload():

    1. If !Len(key()), there's no row to identify. Return without throwing, keeping the in-memory values.
    2. Treat a non-query result as not found: if (!IsQuery(local.query) || !local.query.recordCount) Throw(...).

    Keep the RecordNotFound throw for a real key whose row is gone.

MUST-FIX 2: two SoftDeleteSemanticsSpec cases error on every CockroachDB leg (5/5 engines). This is a fixture assumption in the spec.

  • "soft-deletes dependents that can be soft-deleted" and "leaves dependents alone when a stale copy's soft delete changes nothing…" take an author id (findOne(...).authorId) and look it up as a post key (model("SoftDeletePostChildPosts").findByKey(parentId)).
  • That works only where the sequential ids of the two tables happen to overlap. CockroachDB's unique_rowid() ids don't, so findByKey returns false, and the specs fail with "The function [delete] does not exist in the Boolean" (Lucee), "The delete method was not found" (Adobe), or the Java Boolean error (BoxLang).
  • Fix: inside the rolled-back transaction, create the parent post and its child posts explicitly (children's authorId = the new parent's id). Don't rely on cross-table id overlap.

Infrastructure, not code: adobe2025 + sqlite has no result. Its runner received a shutdown signal mid-run (exit 143, during the Actions outage).

These two fixes plus your pending MEDIUM fix (else if (!arguments.softDelete)) can go in one push. Then a fresh full matrix on the new head (shared model plumbing), and the final.

… build their own posts

- reload() returns at once when the object has no key (unsaved, or saved on
  Oracle, which returns no generated key), instead of throwing RecordNotFound;
  a finder answering false instead of a query also counts as no row.
- The two SoftDeletePostChildPosts specs create their parent and child posts
  instead of pairing a post with an author id, which only worked where the
  sequential ids overlapped (not on CockroachDB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Re-review: head f1fa37b. The MEDIUM and both MUST-FIXes are resolved; code APPROVE.

  • MEDIUM (998e4b8): } else if (!arguments.softDelete) {. A default delete() on a model with no soft-delete column no longer forces includeSoftDeletes, so already soft-deleted dependent="deleteAll" children keep their original deletedAt. The new spec uses AuthorDependentAllPosts (dependent="deleteAll") with a 2020 stamp. Explicit softDelete=false still reaches them (that spec is unchanged).
  • MUST-FIX 1 (Oracle save(reload=true)): reload() now returns early when !Len(key()), so an unsaved object, or a save on an adapter that returns no generated key, is left as it is. A real key whose row is gone still throws Wheels.RecordNotFound, and !IsQuery(...) covers BoxLang's false result. The new "leaves an object without a key alone" spec captures into var state (invariant 11). The fragment says it.
  • MUST-FIX 2 (CockroachDB specs): $parentPostWithChildren() creates a parent post and two children (authorId = the parent's id) inside the rolled-back transaction, so the specs no longer depend on post and author ids overlapping.

Verified locally on real Oracle and CockroachDB, per the author. The final follows the fresh full matrix on f1fa37b (Oracle and CockroachDB legs in particular) and green PR CI.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

35 900 tests   35 175 ✅  10m 34s ⏱️
 2 840 suites     725 💤
     5 files         0 ❌

Results for commit 7910f8f.

♻️ This comment has been updated with latest results.

bpamiri added a commit that referenced this pull request Oct 6, 2026
…and on rollback (#4437)

* fix(model): clear the whole per-request query cache on any ORM write and on rollback

The per-request finder cache (cacheQueriesDuringRequest, on by default) stored each finder's result
in the base model's slot keyed by its SQL, but an ORM write cleared only the writing model's slot. A
query that joined another model through include= lives in the BASE model's slot, so a write to the
included model left it returning the joined model's pre-write columns for the rest of the request
(#4429). Reads inside a transaction that then rolled back had the same shape: the rolled-back rows
stayed cached as phantoms.

- $clearRequestCache() now clears the entire request.wheels["$queryCache"], not just the calling
  model's slot. Every ORM write path already funnels through it (create / update / delete, the *All
  and *ByKey forms, the #4397 soft-delete UPDATE via $runDeleteStatement), so none can forget the
  cross-model case and there is no new merge race.
- $runInTransaction now clears the request cache when it resolves a rollback — both the thrown-body
  path and the returned-false / mode=rollback path. A commit does not need it; its reads committed.
- forgetCachedQueries (#4364) is unchanged; its docs now point at all=true after a raw write to a
  table that other models join through include=.

The core runner forces cacheQueriesDuringRequest OFF, so neither staleness was suite-visible. A new
tests/specs/requestcache group turns the cache on per spec (restored in afterEach, with the mutated
rows restored too) and pins the same-model clear, the cross-model include= clear (RED on develop),
the rollback phantom case (RED on develop), and that a read with no write between is still served
from cache. The guides' caching page documents the per-request cache.

Closes #4429

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* fix(model): clear the request query cache on savepoint rollback too

Addresses rev1-r1's review of #4437: a transaction="savepoint" rollback goes through
$runSavepoint, not $runInTransaction, so a savepoint unit's reads stayed cached as phantoms
after the savepoint rolled back while the outer transaction carried on and committed.

- $runSavepoint now clears the request cache after both rollbackToSavepoint calls (the
  exception path and the returned-false path).
- New specs: a savepoint rollback clears its cached reads (RED on the prior head), and a
  rollback through the exception/throw path of $runInTransaction clears too.
- The guides note that a raw transaction{} block rollback needs forgetCachedQueries(all=true),
  since the automatic clear only fires for ORM transactions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

* test(model): bind the request-cache spec's raw id as cf_sql_bigint, not integer

Addresses the #4437 matrix review: $rawPostTitle bound the post id as cf_sql_integer, so
Adobe 2023/2025 x CockroachDB errored on 64-bit ids ("Invalid data ... for CF_SQL_INTEGER").
Bind cf_sql_bigint and take the id as an untyped argument so a 64-bit value is not truncated
through a numeric cast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>

---------

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@bpamiri

bpamiri commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

FINAL: APPROVE. Head 7910f8f (f1fa37b + develop merges).

@bpamiri
bpamiri merged commit ee93247 into develop Oct 6, 2026
42 checks passed
@bpamiri
bpamiri deleted the peter/soft-delete-semantics branch October 6, 2026 01:03
bpamiri added a commit that referenced this pull request Oct 6, 2026
Since #4397 (#4371), includeSoftDeletes=true is no longer needed to
remove rows that are already soft-deleted.

Signed-off-by: Peter Amiri <peter@alurium.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant