Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/saved-changes.added.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions cli/lucli/templates/app/.ai/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/consumer-ai/.ai/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 97 additions & 7 deletions vendor/wheels/model/callbacks.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
}
}

Expand Down
22 changes: 16 additions & 6 deletions vendor/wheels/model/create.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -165,22 +165,28 @@
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)
&& $callback("afterSave", arguments.callbacks)
) {
$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);
Expand All @@ -194,19 +200,23 @@
&& $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)
&& $callback("afterSave", arguments.callbacks)
) {
$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);
Expand Down
135 changes: 134 additions & 1 deletion vendor/wheels/model/properties.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions vendor/wheels/model/read.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading