Repository navigation
fix(model): keep soft deletes reversible and make softDelete=false a real delete - #4397
Conversation
…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>
|
Review: head 668b49e. One MEDIUM to fix; everything else is APPROVE. Checked against orch1's focus list and my build notes
MEDIUM: a soft-delete-less parent now re-stamps already soft-deleted
Overlap: #4394 ( 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>
|
Matrix 37366055439 (head 668b49e): two MUST-FIX regressions. 26 legs are fully green. MUST-FIX 1:
MUST-FIX 2: two
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 ( |
… 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>
|
Re-review: head f1fa37b. The MEDIUM and both MUST-FIXes are resolved; code APPROVE.
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. |
Wheels Test Results35 900 tests 35 175 ✅ 10m 34s ⏱️ Results for commit 7910f8f. ♻️ This comment has been updated with latest results. |
…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>
|
FINAL: APPROVE. Head 7910f8f (f1fa37b + develop merges).
|
Closes #4370, closes #4371, closes #4372.
Summary
One rule for all three issues:
softDelete=false) is a realDELETE, and it includes rows that are already soft-deleted.#4370: soft-deleting a parent no longer destroys or unlinks its dependents
$deleteDependents()ran everydependent=action whatever kind of delete the parent got. Now, when the parent is soft-deleted:dependent=delete/deleteAllremove/removeAlldependent=still applies in full on a real delete. The decision lives in$dependentKeptBySoftDelete().#4371:
softDelete=falseincludes rows that are already soft-deleteddelete(),deleteAll(),deleteOne()anddeleteByKey()withsoftDelete=falsenow always setincludeSoftDeletes=true, for their own rows and fordependent=children. A purge needs one argument, not two. An explicitincludeSoftDeletes=falsealongside it no longer keeps soft-deleted rows. That's deliberate: a real delete means real.#4372: an object on a soft-deleted row
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 softdelete(), or one from a stale copy, returnsfalseand touches nothing, even inside a caller'stransaction {}. Permanent deletes keep the old order.deletedAt($persistedSoftDeleteValue()), so a value set only in memory doesn't skip the delete.deletedAtit wrote ($markSoftDeleted()). Only that column's change baseline moves; every other property keeps its own.deletedAtback ($restoreSoftDeleteState()), so a retry works:afterDeletereturningfalse, or a dependent that throws: a catch-freefinallyin$delete()that restores only when the delete didn't complete;softDeleteBeforefield on the transaction-queue entry, restored with the saved-change state in$restoreSavedChangesBeforeQueue(). That includes thecallbacks = falsepath from fix(model): skip commit callbacks when callbacks = false #4378.reload()finds a soft-deleted row (includeSoftDeletes=true, stillreload=true). It throwsWheels.RecordNotFoundwhen 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 answeringfalseinstead of a query (BoxLang) counts as no row.The new helpers are all public and
$-prefixed (invariant 7). Thefinallyhas no loop (invariant 12).Behaviour changes
Each has a
changedfragment and an entry in the 4.1 → 4.2 upgrade guide:softDelete=falsealways includes soft-deleted rows, and withinstantiate=truethose 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.SoftDeletePostChildPostspairs 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:
deletedAtset only in memory;deletedAton 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:
Also passing: the complexity gate, the consumer-docs check and the guides build (852 pages).
Docs
dependent=on a soft delete,reload(), purging withsoftDelete=false, and that restoring doesn't cascade).dependentbullet for soft deletes..ai/models.md(both copies).changelog.d/437*.🤖 Generated with Claude Code
Review follow-ups (head
f1fa37b63)998e4b893(rev1-r1 MEDIUM): a defaultdelete()of a model without a soft-delete column forcedincludeSoftDeletes=true, so already soft-deleteddependent="deleteAll"children gotdeletedAtstamped again. Now onlysoftDelete=falseincludes them. New check "doesn't re-stamp soft-deleted children when a parent without deletedAt is deleted" (fixtureAuthorDependentAllPosts). It failed on668b49e3e: the 2020 stamp became 2026.f1fa37b63(matrix 37366055439 must-fixes):save(reload=true)threwRecordNotFound, 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()".SoftDeletePostChildPostsspecs 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: