From c8874a757ec89307d64bf438a16728e4fd4c6e72 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 08:10:56 -0700 Subject: [PATCH 1/2] feat(model): report what the last save wrote with savedChanges() hasChanged(), changedFrom(), changedProperties() and allChanges() describe unsaved changes, so a successful save resets them, and an afterCommit had no way to tell what its commit changed. Apps kept their own flags across a transaction and got their lifecycle wrong. savedChanges(), hasSavedChange(), savedChangeFrom() and savedChangedProperties() are their saved counterparts: - set right after the row write, before the after* callbacks, so afterSave, afterCommit and the caller see the same thing; a failed save puts back the previous state, a save that changed nothing reports nothing; - each transaction queue entry keeps its save's snapshot, so each afterCommit sees the save that queued it, and an afterRollback the save that was undone, after which the state from before it is restored (also for a model with no transaction callbacks); - on a create, every property the insert wrote, from ""; - reload() and clearChangeInformation() clear it; save(reload=true) keeps it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- changelog.d/saved-changes.added.md | 1 + cli/lucli/templates/app/.ai/models.md | 1 + docs/consumer-ai/.ai/models.md | 1 + vendor/wheels/model/callbacks.cfm | 104 +++++++++- vendor/wheels/model/create.cfm | 22 ++- vendor/wheels/model/properties.cfm | 135 ++++++++++++- vendor/wheels/model/read.cfm | 2 + vendor/wheels/tests/_assets/models/Tag.cfc | 50 +++++ .../tests/specs/model/SavedChangesSpec.cfc | 179 ++++++++++++++++++ .../docs/v4-2-0/basics/models-and-the-orm.mdx | 35 ++++ 10 files changed, 516 insertions(+), 14 deletions(-) create mode 100644 changelog.d/saved-changes.added.md create mode 100644 vendor/wheels/tests/specs/model/SavedChangesSpec.cfc diff --git a/changelog.d/saved-changes.added.md b/changelog.d/saved-changes.added.md new file mode 100644 index 0000000000..3b2dce0b97 --- /dev/null +++ b/changelog.d/saved-changes.added.md @@ -0,0 +1 @@ +- Models report what their last successful save wrote: `savedChanges()`, `hasSavedChange(property)`, `savedChangeFrom(property)` and `savedChangedProperties()`, the saved counterparts of `allChanges()`, `hasChanged()`, `changedFrom()` and `changedProperties()`, which a save resets. They're available from the save's `after*` callbacks on, and each `afterCommit` sees the save that queued it, so an `afterCommit` can act on what a commit changed (for example, announce a record once each time it ends up published) without a hand-kept flag. An `afterRollback` sees the save that was rolled back, after which the previous state is restored. `reload()` and `clearChangeInformation()` clear it diff --git a/cli/lucli/templates/app/.ai/models.md b/cli/lucli/templates/app/.ai/models.md index d9aeaefa4c..d318c163a2 100644 --- a/cli/lucli/templates/app/.ai/models.md +++ b/cli/lucli/templates/app/.ai/models.md @@ -60,6 +60,7 @@ component extends="Model" { Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`. Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`. Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`. +What the last save wrote (4.2+): `savedChanges()`, `hasSavedChange("status")`, `savedChangeFrom("status")`, `savedChangedProperties()`, the saved counterparts of `allChanges()` / `hasChanged()` / `changedFrom()` / `changedProperties()`. Use them in `afterCommit`: each call sees the save that queued it, so a record saved twice in one transaction is told apart, and no hand-kept flag is needed (`if (hasSavedChange("published") && this.published) ...`). Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing. ## Scopes / Enums / Builder / Batch diff --git a/docs/consumer-ai/.ai/models.md b/docs/consumer-ai/.ai/models.md index d9aeaefa4c..d318c163a2 100644 --- a/docs/consumer-ai/.ai/models.md +++ b/docs/consumer-ai/.ai/models.md @@ -60,6 +60,7 @@ component extends="Model" { Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.key)`. Create: `model("User").new(params.user).save()`, or `model("User").create(params.user)`. Include associations: `findAll(include="role,orders")`. Pagination: `findAll(page=params.page, perPage=25)`. +What the last save wrote (4.2+): `savedChanges()`, `hasSavedChange("status")`, `savedChangeFrom("status")`, `savedChangedProperties()`, the saved counterparts of `allChanges()` / `hasChanged()` / `changedFrom()` / `changedProperties()`. Use them in `afterCommit`: each call sees the save that queued it, so a record saved twice in one transaction is told apart, and no hand-kept flag is needed (`if (hasSavedChange("published") && this.published) ...`). Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing. ## Scopes / Enums / Builder / Batch diff --git a/vendor/wheels/model/callbacks.cfm b/vendor/wheels/model/callbacks.cfm index 70b7e0c1b2..7aa4e1f93c 100644 --- a/vendor/wheels/model/callbacks.cfm +++ b/vendor/wheels/model/callbacks.cfm @@ -426,8 +426,15 @@ * because the write is already committed (decision B1). Inside a foreign raw * transaction{} (detectable engines only) it skips both callbacks and warns once. */ - public void function $enqueueTransactionCallbacks(required string operation) { + public void function $enqueueTransactionCallbacks(required string operation, struct savedBefore) { + // A delete changes no saved-change state, so a rollback puts back the current one. + if (!StructKeyExists(arguments, "savedBefore")) { + arguments.savedBefore = $savedChangesState(); + } if (!$hasTransactionCallbacks()) { + // No callbacks to fire, but a rollback must still put back what savedChanges() reported + // before this save (F49), so the save is queued for that alone. + $enqueueSavedChangesRestore(operation = arguments.operation, savedBefore = arguments.savedBefore); return; } local.conn = this.$hashedConnectionArgs(); @@ -449,13 +456,47 @@ ) { ArrayAppend( request.wheels.$txnCallbacks[local.conn].queue, - {object = this, operation = arguments.operation} + { + object = this, + operation = arguments.operation, + callbacks = true, + savedChanges = $savedChangesState(), + savedBefore = arguments.savedBefore + } ); } else { this.$runTransactionCallbacks(type = "afterCommit", operation = arguments.operation); } } + /** + * Internal. Queues a save of a model with no afterCommit / afterRollback callbacks, inside a real + * Wheels transaction only, so a rollback puts back the saved-change state from before it (F49). + * Checked cheaply first: outside a Wheels transaction there is nothing to queue. + */ + public void function $enqueueSavedChangesRestore(required string operation, required struct savedBefore) { + if ( + !StructKeyExists(request, "wheels") + || !StructKeyExists(request.wheels, "$txnCallbacks") + || StructIsEmpty(request.wheels.$txnCallbacks) + ) { + return; + } + local.conn = this.$hashedConnectionArgs(); + if (StructKeyExists(request.wheels.$txnCallbacks, local.conn) && request.wheels.$txnCallbacks[local.conn].real) { + ArrayAppend( + request.wheels.$txnCallbacks[local.conn].queue, + { + object = this, + operation = arguments.operation, + callbacks = false, + savedChanges = $savedChangesState(), + savedBefore = arguments.savedBefore + } + ); + } + } + /** * Internal. True if the engine can tell that a transaction Wheels did not open is * active, i.e. a raw, non-Wheels transaction we are nested in. Probed once and cached @@ -742,14 +783,63 @@ * leave a stuck transaction marker or a leaked queue. */ public void function $runQueueCallbacks(required array queue, required string type, boolean propagateErrors = true) { - local.iEnd = ArrayLen(arguments.queue); - for (local.i = 1; local.i <= local.iEnd; local.i++) { - local.entry = arguments.queue[local.i]; - local.entry.object.$runTransactionCallbacks( + try { + local.iEnd = ArrayLen(arguments.queue); + for (local.i = 1; local.i <= local.iEnd; local.i++) { + local.entry = arguments.queue[local.i]; + // An entry queued only to restore the saved-change state fires nothing (F49). + if (StructKeyExists(local.entry, "callbacks") && !local.entry.callbacks) { + continue; + } + $runQueueEntryCallbacks(entry = local.entry, type = arguments.type, propagateErrors = arguments.propagateErrors); + } + } finally { + // A rollback puts each object back to what savedChanges() reported before its first + // rolled-back save. A helper call: Lucee 7 miscompiles loops inside finally. + if (arguments.type == "afterRollback") { + $restoreSavedChangesBeforeQueue(arguments.queue); + } + } + } + + /** + * Internal. Fires one queue entry's callbacks with the object reporting the save that queued it + * (savedChanges(), F49), then puts back the object's own state, also when a callback throws. + */ + public void function $runQueueEntryCallbacks(required struct entry, required string type, boolean propagateErrors = true) { + local.object = arguments.entry.object; + // Only a model save carries a snapshot; other entries (a deferred job enqueue) just fire. + if (!StructKeyExists(arguments.entry, "savedChanges")) { + local.object.$runTransactionCallbacks( + type = arguments.type, + operation = arguments.entry.operation, + propagateErrors = arguments.propagateErrors + ); + return; + } + local.current = local.object.$savedChangesState(); + local.object.$restoreSavedChanges(arguments.entry.savedChanges); + try { + local.object.$runTransactionCallbacks( type = arguments.type, - operation = local.entry.operation, + operation = arguments.entry.operation, propagateErrors = arguments.propagateErrors ); + } finally { + local.object.$restoreSavedChanges(local.current); + } + } + + /** + * Internal. After a rollback, puts each object in `queue` back to the saved-change state from before + * its first rolled-back save (F49): walked last to first, so the earliest entry's state wins. + */ + public void function $restoreSavedChangesBeforeQueue(required array queue) { + for (local.i = ArrayLen(arguments.queue); local.i >= 1; local.i--) { + local.entry = arguments.queue[local.i]; + if (StructKeyExists(local.entry, "savedBefore")) { + local.entry.object.$restoreSavedChanges(local.entry.savedBefore); + } } } diff --git a/vendor/wheels/model/create.cfm b/vendor/wheels/model/create.cfm index a506d4776f..d808d49069 100644 --- a/vendor/wheels/model/create.cfm +++ b/vendor/wheels/model/create.cfm @@ -165,7 +165,10 @@ local.rollback = true; } + // What this save writes, from the write on (F49); put back if the save fails. + local.savedBefore = $savedChangesState(); $create(parameterize = arguments.parameterize, reload = arguments.reload); + $captureSavedChanges(); if ( $saveAssociations(argumentCollection = arguments) && $callback("afterCreate", arguments.callbacks) @@ -173,14 +176,17 @@ ) { $updatePersistedProperties(); if (arguments.reload) { - this.reload(); + $reloadKeepingSavedChanges(); } local.rv = true; // v4.2.0: queue afterCommit/afterRollback (fires at the outermost // transaction resolve, or immediately in none/false mode). - $enqueueTransactionCallbacks(operation = "create"); - } else if (local.rollback) { - $resetToNew(); + $enqueueTransactionCallbacks(operation = "create", savedBefore = local.savedBefore); + } else { + $restoreSavedChanges(local.savedBefore); + if (local.rollback) { + $resetToNew(); + } } } else { $validateAssociations(callbacks = arguments.callbacks); @@ -194,7 +200,9 @@ && $callback("beforeSave", arguments.callbacks) && $callback("beforeUpdate", arguments.callbacks) ) { + local.savedBefore = $savedChangesState(); $update(parameterize = arguments.parameterize, reload = arguments.reload); + $captureSavedChanges(); if ( $saveAssociations(argumentCollection = arguments) && $callback("afterUpdate", arguments.callbacks) @@ -202,11 +210,13 @@ ) { $updatePersistedProperties(); if (arguments.reload) { - this.reload(); + $reloadKeepingSavedChanges(); } local.rv = true; // v4.2.0: queue afterCommit/afterRollback (see create branch). - $enqueueTransactionCallbacks(operation = "update"); + $enqueueTransactionCallbacks(operation = "update", savedBefore = local.savedBefore); + } else { + $restoreSavedChanges(local.savedBefore); } } else { $validateAssociations(callbacks = arguments.callbacks); diff --git a/vendor/wheels/model/properties.cfm b/vendor/wheels/model/properties.cfm index 4cb1d85d3f..e9fad9d769 100644 --- a/vendor/wheels/model/properties.cfm +++ b/vendor/wheels/model/properties.cfm @@ -471,7 +471,84 @@ } /** - * Clears all internal knowledge of the current state of the object. + * Returns a struct detailing the changes the last successful save wrote to the database, in the + * same shape as `allChanges()`: `{property: {changedFrom, changedTo}}`. Available from the + * save's `afterCreate` / `afterUpdate` / `afterSave` callbacks on, after `allChanges()` has been + * reset. Each `afterCommit` sees the save that queued it, and an `afterRollback` the save that was + * undone. Empty before the first save, after a save that changed nothing, and after `reload()`. + * + * [section: Model Object] + * [category: Change Functions] + */ + public struct function savedChanges() { + if (!StructKeyExists(variables, "$savedChanges")) { + return {}; + } + return Duplicate(variables.$savedChanges); + } + + /** + * Returns `true` if the last successful save changed the specified property, or any property if + * none is passed in. See `savedChanges()` for when that information is available. + * + * [section: Model Object] + * [category: Change Functions] + * + * @property Name of property (or a list of properties) to check. + */ + public boolean function hasSavedChange(string property = "") { + if (!StructKeyExists(variables, "$savedChanges")) { + return false; + } + if (!Len(arguments.property)) { + return !StructIsEmpty(variables.$savedChanges); + } + for (local.key in ListToArray(arguments.property)) { + if (StructKeyExists(variables.$savedChanges, Trim(local.key))) { + return true; + } + } + return false; + } + + /** + * Returns the value a property had before the last successful save changed it, or an empty + * string when that save didn't change it. + * + * [section: Model Object] + * [category: Change Functions] + * + * @property Name of property to get the previous value for. + */ + public string function savedChangeFrom(required string property) { + if (StructKeyExists(variables, "$savedChanges") && StructKeyExists(variables.$savedChanges, arguments.property)) { + return variables.$savedChanges[arguments.property].changedFrom; + } + return ""; + } + + /** + * Returns a list of the object properties the last successful save changed. + * + * [section: Model Object] + * [category: Change Functions] + */ + public string function savedChangedProperties() { + local.rv = ""; + if (!StructKeyExists(variables, "$savedChanges")) { + return local.rv; + } + for (local.key in variables.wheels.class.properties) { + if (StructKeyExists(variables.$savedChanges, local.key)) { + local.rv = ListAppend(local.rv, local.key); + } + } + return local.rv; + } + + /** + * Clears all internal knowledge of the current state of the object, including what the last save + * changed (`savedChanges()`). * * [section: Model Object] * [category: Change Functions] @@ -480,6 +557,62 @@ */ public void function clearChangeInformation(string property) { $updatePersistedProperties(argumentCollection = arguments); + if (!StructKeyExists(arguments, "property")) { + StructDelete(variables, "$savedChanges"); + } else if (StructKeyExists(variables, "$savedChanges")) { + StructDelete(variables.$savedChanges, arguments.property); + } + } + + /** + * Internal function. Records what the save in progress just wrote, as `savedChanges()` reports it + * (F49): what `allChanges()` reports right after the write, by value. On a create that is every + * property holding a value (from ""), including the generated key; properties the object never + * set aren't listed. + */ + public void function $captureSavedChanges() { + local.changes = allChanges(); + for (local.key in StructKeyArray(local.changes)) { + local.change = local.changes[local.key]; + if ( + !StructKeyExists(this, local.key) + || (IsSimpleValue(local.change.changedFrom) && IsSimpleValue(local.change.changedTo) && !Len(local.change.changedFrom) && !Len(local.change.changedTo)) + ) { + StructDelete(local.changes, local.key); + } + } + variables.$savedChanges = Duplicate(local.changes); + } + + /** + * Internal function. The saved-change state, to put back with $restoreSavedChanges(). + */ + public struct function $savedChangesState() { + if (StructKeyExists(variables, "$savedChanges")) { + return {exists = true, value = Duplicate(variables.$savedChanges)}; + } + return {exists = false, value = {}}; + } + + /** + * Internal function. Puts back a saved-change state from $savedChangesState(): after a save that + * failed or was rolled back, and around each queued afterCommit / afterRollback. + */ + public void function $restoreSavedChanges(required struct state) { + if (arguments.state.exists) { + variables.$savedChanges = Duplicate(arguments.state.value); + } else { + StructDelete(variables, "$savedChanges"); + } + } + + /** + * Internal function. `reload()` for save(reload = true), keeping what the save changed. + */ + public void function $reloadKeepingSavedChanges() { + local.state = $savedChangesState(); + this.reload(); + $restoreSavedChanges(local.state); } /** diff --git a/vendor/wheels/model/read.cfm b/vendor/wheels/model/read.cfm index 6b3d3203d3..038b4ad62b 100644 --- a/vendor/wheels/model/read.cfm +++ b/vendor/wheels/model/read.cfm @@ -613,6 +613,8 @@ * [category: Miscellaneous Functions] */ public void function reload() { + // A reloaded object reports no saved changes, as after clearChangeInformation() (F49). + StructDelete(variables, "$savedChanges"); local.query = findByKey(key = key(), reload = true, returnAs = "query"); local.properties = propertyNames(); local.columnInfo = variables.wheels.class.properties; diff --git a/vendor/wheels/tests/_assets/models/Tag.cfc b/vendor/wheels/tests/_assets/models/Tag.cfc index eb20d55659..a234d960a4 100644 --- a/vendor/wheels/tests/_assets/models/Tag.cfc +++ b/vendor/wheels/tests/_assets/models/Tag.cfc @@ -194,4 +194,54 @@ component extends="Model" { return false; } + + // Saved-change helpers (F49). They read request.$scTag / request.$scNames and append to + // request.$scLog, so specs can assert what each callback saw. + function recordSavedChangesOnCommit() { + $recordSavedChanges("commit"); + } + + function recordSavedChangesOnRollback() { + $recordSavedChanges("rollback"); + } + + function recordSavedChangesInAfterSave() { + $recordSavedChanges("afterSave"); + return true; + } + + public void function $recordSavedChanges(required string phase) { + if (!StructKeyExists(request, "$scLog")) { + request.$scLog = []; + } + ArrayAppend(request.$scLog, { + phase = arguments.phase, + changed = hasSavedChange("name"), + from = savedChangeFrom("name"), + name = this.name, + properties = savedChangedProperties() + }); + } + + // Announces only when this save changed the name AND the committed name is the live one. + function announceIfLive() { + if (!StructKeyExists(request, "$scAnnounced")) { + request.$scAnnounced = 0; + } + if (hasSavedChange("name") && this.name == "savedch-live") { + request.$scAnnounced++; + } + } + + function txnRenameTwice() { + request.$scTag.update(name = request.$scNames[1]); + request.$scTag.update(name = request.$scNames[2]); + return true; + } + + function txnRenameThenThrow() { + request.$scTag.update(name = request.$scNames[1]); + Throw(type = "Wheels.TestSavedChangesBoom", message = "rename then throw"); + } + } diff --git a/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc b/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc new file mode 100644 index 0000000000..eaaea91280 --- /dev/null +++ b/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc @@ -0,0 +1,179 @@ +/** + * Saved-change functions (F49): savedChanges(), hasSavedChange(), savedChangeFrom() and + * savedChangedProperties() report what the last successful save wrote, after allChanges() and its + * siblings have been reset by that save. Each afterCommit sees the save that queued it, so two saves + * of one object in a transaction are told apart, and an afterRollback sees the save that was undone. + */ +component extends="wheels.WheelsTest" { + + function run() { + + g = application.wo; + + describe("Saved changes", () => { + + beforeEach(() => { + request.$scLog = []; + request.$scAnnounced = 0; + }); + + afterEach(() => { + g.model("tag").$clearCallbacks(type = "afterCommit"); + g.model("tag").$clearCallbacks(type = "afterRollback"); + g.model("tag").$clearCallbacks(type = "afterSave"); + g.model("tag").deleteAll(where = "name LIKE 'savedch-%'", instantiate = false, callbacks = false, transaction = "commit"); + StructDelete(request, "$scTag"); + StructDelete(request, "$scNames"); + }); + + it("is empty for an object that was never saved", () => { + var t = g.model("tag").new(name = "savedch-new"); + expect(t.savedChanges()).toBe({}); + expect(t.hasSavedChange()).toBeFalse(); + expect(t.hasSavedChange("name")).toBeFalse(); + expect(t.savedChangeFrom("name")).toBe(""); + expect(t.savedChangedProperties()).toBe(""); + }); + + it("reports what a create wrote, from empty values", () => { + var t = g.model("tag").new(name = "savedch-create"); + expect(t.save(transaction = "none")).toBeTrue(); + var saved = t.savedChanges(); + expect(saved).toHaveKey("name"); + expect(saved.name.changedFrom).toBe(""); + expect(saved.name.changedTo).toBe("savedch-create"); + expect(saved).toHaveKey("id"); + expect(saved).notToHaveKey("description", "a property the object never set wasn't written"); + expect(t.hasSavedChange("name")).toBeTrue(); + expect(ListFindNoCase(t.savedChangedProperties(), "name")).toBeGT(0); + // The unsaved-change functions are reset by the save, as before. + expect(t.allChanges()).toBe({}); + expect(t.hasChanged()).toBeFalse(); + }); + + it("reports only the properties an update changed", () => { + var t = g.model("tag").create(name = "savedch-before", transaction = "none"); + t.name = "savedch-after"; + t.save(transaction = "none"); + expect(StructKeyList(t.savedChanges())).toBe("name"); + expect(t.savedChangeFrom("name")).toBe("savedch-before"); + expect(t.savedChanges().name.changedTo).toBe("savedch-after"); + expect(t.hasSavedChange("description")).toBeFalse(); + expect(t.savedChangedProperties()).toBe("name"); + }); + + it("is empty after a save that changed nothing", () => { + var t = g.model("tag").create(name = "savedch-noop", transaction = "none"); + t.save(transaction = "none"); + expect(t.savedChanges()).toBe({}); + expect(t.hasSavedChange()).toBeFalse(); + }); + + it("keeps the previous state when a save fails after the write", () => { + var t = g.model("tag").create(name = "savedch-kept", transaction = "none"); + var before = Duplicate(t.savedChanges()); + g.model("tag").$registerCallback(type = "afterSave", methods = "callbackThatReturnsFalse"); + t.name = "savedch-vetoed"; + expect(t.save(transaction = "commit")).toBeFalse(); + expect(t.savedChanges()).toBe(before); + }); + + it("is cleared by reload() and clearChangeInformation(), and kept by save(reload = true)", () => { + var t = g.model("tag").create(name = "savedch-clear", transaction = "none"); + t.reload(); + expect(t.savedChanges()).toBe({}); + + t.name = "savedch-clear2"; + t.description = "d"; + t.save(transaction = "none"); + t.clearChangeInformation("description"); + expect(StructKeyList(t.savedChanges())).toBe("name"); + t.clearChangeInformation(); + expect(t.savedChanges()).toBe({}); + + t.name = "savedch-clear3"; + t.save(transaction = "none", reload = true); + expect(t.savedChangeFrom("name")).toBe("savedch-clear2"); + }); + + it("is available in afterSave for the save in progress", () => { + var t = g.model("tag").create(name = "savedch-as-before", transaction = "none"); + g.model("tag").$registerCallback(type = "afterSave", methods = "recordSavedChangesInAfterSave"); + t.name = "savedch-as-after"; + t.save(transaction = "none"); + expect(ArrayLen(request.$scLog)).toBe(1); + expect(request.$scLog[1].changed).toBeTrue(); + expect(request.$scLog[1].from).toBe("savedch-as-before"); + }); + + it("shows each afterCommit the save that queued it when one object is saved twice in a transaction", () => { + var t = g.model("tag").create(name = "savedch-a", transaction = "none"); + g.model("tag").$registerCallback(type = "afterCommit", methods = "recordSavedChangesOnCommit"); + request.$scTag = t; + request.$scNames = ["savedch-b", "savedch-c"]; + g.model("tag").invokeWithTransaction(method = "txnRenameTwice", transaction = "commit"); + expect(ArrayLen(request.$scLog)).toBe(2, "one afterCommit per save"); + expect(request.$scLog[1].from).toBe("savedch-a"); + expect(request.$scLog[2].from).toBe("savedch-b"); + // Both run after the commit, so the object holds its final, committed state. + expect(request.$scLog[1].name).toBe("savedch-c"); + expect(request.$scLog[2].name).toBe("savedch-c"); + // Once the callbacks are done, the object reports its last save. + expect(t.savedChangeFrom("name")).toBe("savedch-b"); + }); + + it("lets afterCommit announce a record that ends up live exactly once, with no flags", () => { + var t = g.model("tag").create(name = "savedch-draft", transaction = "none"); + g.model("tag").$registerCallback(type = "afterCommit", methods = "announceIfLive"); + request.$scTag = t; + // Live, then back down, in one transaction: it doesn't end up live. + request.$scNames = ["savedch-live", "savedch-draft"]; + g.model("tag").invokeWithTransaction(method = "txnRenameTwice", transaction = "commit"); + expect(request.$scAnnounced).toBe(0); + // Live again later: announced once. + t.update(name = "savedch-live", transaction = "commit"); + expect(request.$scAnnounced).toBe(1); + // Saved again while live, without the name changing: not announced again. + t.update(description = "still live", transaction = "commit"); + expect(request.$scAnnounced).toBe(1); + }); + + it("shows afterRollback the undone save, then restores the state from before it", () => { + var t = g.model("tag").create(name = "savedch-rb-before", transaction = "none"); + var before = Duplicate(t.savedChanges()); + g.model("tag").$registerCallback(type = "afterRollback", methods = "recordSavedChangesOnRollback"); + request.$scTag = t; + request.$scNames = ["savedch-rb-undone"]; + var state = {threw = false}; + try { + g.model("tag").invokeWithTransaction(method = "txnRenameThenThrow", transaction = "commit"); + } catch (any e) { + state.threw = true; + } + expect(state.threw).toBeTrue(); + expect(ArrayLen(request.$scLog)).toBe(1); + expect(request.$scLog[1].phase).toBe("rollback"); + expect(request.$scLog[1].from).toBe("savedch-rb-before"); + expect(t.savedChanges()).toBe(before); + }); + + it("restores the state from before a rolled-back save on an object without transaction callbacks", () => { + var t = g.model("tag").create(name = "savedch-plain-before", transaction = "none"); + var before = Duplicate(t.savedChanges()); + request.$scTag = t; + request.$scNames = ["savedch-plain-undone"]; + var state = {threw = false}; + try { + g.model("tag").invokeWithTransaction(method = "txnRenameThenThrow", transaction = "commit"); + } catch (any e) { + state.threw = true; + } + expect(state.threw).toBeTrue(); + expect(t.savedChanges()).toBe(before); + }); + + }); + + } + +} diff --git a/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx b/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx index 39a07ac13e..12eeae2ebb 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx @@ -292,6 +292,41 @@ component extends="Model" { - On RustCFML, the raw block can't be detected, so what fires is up to the engine and isn't guaranteed. Keep writes that have these callbacks out of raw `transaction {}` blocks. + +### What a save changed: `savedChanges()` + +`hasChanged()`, `changedFrom()`, `changedProperties()` and `allChanges()` describe changes that are *not yet* saved, so a successful save resets them. From Wheels 4.2, four counterparts describe what the last successful save *wrote*: + +| Before the save | After the save | +|---|---| +| `allChanges()` | `savedChanges()`: `{property: {changedFrom, changedTo}}` | +| `hasChanged(property)` | `hasSavedChange(property)`; any property when omitted | +| `changedFrom(property)` | `savedChangeFrom(property)` | +| `changedProperties()` | `savedChangedProperties()` | + +They're set as soon as the row is written, so `afterCreate`, `afterUpdate`, `afterSave`, `afterCommit` and the code after `save()` all see the same thing. + +```cfm {test:compile} +component extends="Model" { + function config() { + afterCommit(methods="announceIfPublished", on="create,update"); + } + + private void function announceIfPublished() { + // Announce a book each time it ends up published, never twice for one publication. + if (hasSavedChange("published") && this.published) { + enqueueAnnouncement(this.id); + } + } +} +``` + +- **Each `afterCommit` sees the save that queued it.** When one object is saved twice in a transaction, each of the two `afterCommit` calls reports its own save's changes, while the object's properties hold the committed, final values. A book published and unpublished in one transaction is therefore not announced. +- **An `afterRollback` sees the save that was rolled back.** Once the rollback is done, `savedChanges()` reports what it did before that save. +- **On a create**, every property the insert wrote is listed, from an empty value, including the generated key. +- **A save that changed nothing** reports nothing. **A save that fails** keeps what the previous save reported. +- **`reload()` and `clearChangeInformation()` clear it.** `save(reload=true)` keeps it. +- **A save inside one of the object's own callbacks** replaces it with that save's changes. ## Custom methods From 4d48bdaecb12317ccf0edbf2624deab4c4120f0c Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Mon, 5 Oct 2026 08:24:39 -0700 Subject: [PATCH 2/2] docs(model): document a save inside a callback in savedChanges() A save inside one of the object's own callbacks replaces savedChanges() with its own changes, which include the outer save's because the outer save hasn't reset its baseline yet. The guide says so with an example, and a spec pins it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- vendor/wheels/tests/_assets/models/Tag.cfc | 9 +++++++++ vendor/wheels/tests/specs/model/SavedChangesSpec.cfc | 9 +++++++++ .../content/docs/v4-2-0/basics/models-and-the-orm.mdx | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/vendor/wheels/tests/_assets/models/Tag.cfc b/vendor/wheels/tests/_assets/models/Tag.cfc index a234d960a4..0765f4480f 100644 --- a/vendor/wheels/tests/_assets/models/Tag.cfc +++ b/vendor/wheels/tests/_assets/models/Tag.cfc @@ -233,6 +233,15 @@ component extends="Model" { } } + // An afterSave that saves the object again, once. + function saveDescriptionOnce() { + if (!Len(this.description ?: "")) { + this.description = "savedch-nested"; + this.save(transaction = "none"); + } + return true; + } + function txnRenameTwice() { request.$scTag.update(name = request.$scNames[1]); request.$scTag.update(name = request.$scNames[2]); diff --git a/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc b/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc index eaaea91280..2d66df9e87 100644 --- a/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc +++ b/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc @@ -106,6 +106,15 @@ component extends="wheels.WheelsTest" { expect(request.$scLog[1].from).toBe("savedch-as-before"); }); + it("reports the inner save, outer changes included, when a callback saves the object again", () => { + var t = g.model("tag").create(name = "savedch-nest-before", transaction = "none"); + g.model("tag").$registerCallback(type = "afterSave", methods = "saveDescriptionOnce"); + t.name = "savedch-nest-after"; + t.save(transaction = "none"); + expect(ListSort(t.savedChangedProperties(), "textnocase")).toBe("description,name"); + expect(t.savedChangeFrom("name")).toBe("savedch-nest-before"); + }); + it("shows each afterCommit the save that queued it when one object is saved twice in a transaction", () => { var t = g.model("tag").create(name = "savedch-a", transaction = "none"); g.model("tag").$registerCallback(type = "afterCommit", methods = "recordSavedChangesOnCommit"); diff --git a/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx b/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx index 12eeae2ebb..352ea25907 100644 --- a/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx +++ b/web/sites/guides/src/content/docs/v4-2-0/basics/models-and-the-orm.mdx @@ -326,7 +326,7 @@ component extends="Model" { - **On a create**, every property the insert wrote is listed, from an empty value, including the generated key. - **A save that changed nothing** reports nothing. **A save that fails** keeps what the previous save reported. - **`reload()` and `clearChangeInformation()` clear it.** `save(reload=true)` keeps it. -- **A save inside one of the object's own callbacks** replaces it with that save's changes. +- **A save inside one of the object's own callbacks** (say, an `afterSave` that sets a property and calls `save()` again) replaces it with that inner save's changes. Because the outer save hasn't reset its baseline yet when the inner one runs, the inner save's changes include the outer save's too: after `title` is saved and an `afterSave` then sets and saves `slug`, `savedChanges()` lists both `title` and `slug`. ## Custom methods