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..0765f4480f 100644 --- a/vendor/wheels/tests/_assets/models/Tag.cfc +++ b/vendor/wheels/tests/_assets/models/Tag.cfc @@ -194,4 +194,63 @@ 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++; + } + } + + // 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]); + 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..2d66df9e87 --- /dev/null +++ b/vendor/wheels/tests/specs/model/SavedChangesSpec.cfc @@ -0,0 +1,188 @@ +/** + * 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("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"); + 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..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 @@ -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** (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