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/4370-soft-delete-dependents.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Soft-deleting a record no longer destroys or unlinks `dependent=` children that a restore would need. Children whose model has a soft-delete column are soft-deleted with it, as before. Other children are left alone: `dependent="delete"` / `"deleteAll"` no longer hard-deletes them and `dependent="remove"` / `"removeAll"` no longer sets their foreign keys to `NULL`. On a soft delete, `dependent=` now applies to them only on a real delete (#4370)
1 change: 1 addition & 0 deletions changelog.d/4371-soft-delete-false-purges.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `delete()`, `deleteAll()`, `deleteOne()` and `deleteByKey()` with `softDelete=false` are always real deletes. They now also remove rows that were already soft-deleted, and the `dependent=` children of a deleted record, without `includeSoftDeletes=true`. An explicit `includeSoftDeletes=false` no longer keeps them. With `instantiate=true` those rows now run their delete callbacks too (#4371)
1 change: 1 addition & 0 deletions changelog.d/4372-reload-missing-row.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `reload()` throws `Wheels.RecordNotFound` when the object's row no longer exists, instead of silently blanking every property. Uncaught in a controller action, that renders the 404 page. An object with no key (unsaved, or saved on a database that returns no generated key, such as Oracle) is left as it is, where it used to have every property blanked (#4372)
4 changes: 4 additions & 0 deletions changelog.d/4372-soft-deleted-instance.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- An object whose row is soft-deleted behaves consistently (#4372):
- After a soft `delete()`, the object's `deletedAt` holds the timestamp written. It goes back to its earlier value if the delete is vetoed or rolled back, so a retry works.
- A second soft `delete()`, or one from a stale copy of a row soft-deleted elsewhere, returns `false` and changes nothing. It no longer rewrites `deletedAt`, and it no longer soft-deletes the dependents of a row it didn't delete.
- `reload()` reloads a soft-deleted row instead of blanking every property.
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 @@ -61,6 +61,7 @@ Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.ke
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) ...`).
Soft delete (table has `deletedAt`, which `t.timestamps()` adds): `delete()` sets `deletedAt`, and finders skip such rows unless `includeSoftDeletes=true`. `softDelete=false` is a real DELETE, including rows that are already soft-deleted (4.2+). On a soft delete, `dependent=` children with their own `deletedAt` are soft-deleted and all others are left untouched. `reload()` finds a soft-deleted row and throws `Wheels.RecordNotFound` when the row is gone. A second soft `delete()` returns `false`.
Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing.

Literal LIKE search: `findAll(where="title LIKE '%#escapeForLike(params.q)#%' ESCAPE '\'")`. `escapeForLike()` escapes `\` `%` `_` (and `[` on SQL Server — `\[` is illegal on Oracle; on SQL Server `[` is escaped only after a model has initialised the adapter, so call a `model()` finder before using it in a fresh/just-reloaded app) so a user's term isn't read as wildcards; the quoted literal is bound. Always declare `ESCAPE '\'`: MySQL/PG/CockroachDB/H2 default the escape char to `\` but SQLite/Oracle/SQL Server have none, and the 3-arg builder `where("title","LIKE",...)` emits no `ESCAPE`, so without it `\` matches literally. Escapes LIKE metacharacters only, not SQL quotes.
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 @@ -61,6 +61,7 @@ Finders: `model("User").findAll()`, `findOne(where="...")`, `findByKey(params.ke
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) ...`).
Soft delete (table has `deletedAt`, which `t.timestamps()` adds): `delete()` sets `deletedAt`, and finders skip such rows unless `includeSoftDeletes=true`. `softDelete=false` is a real DELETE, including rows that are already soft-deleted (4.2+). On a soft delete, `dependent=` children with their own `deletedAt` are soft-deleted and all others are left untouched. `reload()` finds a soft-deleted row and throws `Wheels.RecordNotFound` when the row is gone. A second soft `delete()` returns `false`.
Opt a `select=false` calculated property into one call (additive): `findAll(includeCalculated="fullName")`. Unknown names throw `Wheels.CalculatedPropertyNotFound` in dev/testing.

Literal LIKE search: `findAll(where="title LIKE '%#escapeForLike(params.q)#%' ESCAPE '\'")`. `escapeForLike()` escapes `\` `%` `_` (and `[` on SQL Server — `\[` is illegal on Oracle; on SQL Server `[` is escaped only after a model has initialised the adapter, so call a `model()` finder before using it in a fresh/just-reloaded app) so a user's term isn't read as wildcards; the quoted literal is bound. Always declare `ESCAPE '\'`: MySQL/PG/CockroachDB/H2 default the escape char to `\` but SQLite/Oracle/SQL Server have none, and the 3-arg builder `where("title","LIKE",...)` emits no `ESCAPE`, so without it `\` matches literally. Escapes LIKE metacharacters only, not SQL quotes.
Expand Down
27 changes: 24 additions & 3 deletions vendor/wheels/model/associations.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -242,12 +242,18 @@

/*
* Called when a model object is deleted (e.g. post.delete()).
* Deletes all associated records (or sets their foreign key values to NULL).
* Deletes all associated records (or sets their foreign key values to NULL). When the object itself
* was soft-deleted (`parentSoftDeleted`), nothing a restore would need is destroyed or unlinked:
* dependents that can be soft-deleted are, and every other dependent is left alone.
*/
public void function $deleteDependents(boolean softDelete = true, boolean includeSoftDeletes = false) {
public void function $deleteDependents(boolean softDelete = true, boolean includeSoftDeletes = false, boolean parentSoftDeleted = false) {
for (local.key in variables.wheels.class.associations) {
local.association = variables.wheels.class.associations[local.key];
if (ListFindNoCase("hasMany,hasOne", local.association.type) && local.association.dependent != false) {
if (
ListFindNoCase("hasMany,hasOne", local.association.type)
&& local.association.dependent != false
&& !$dependentKeptBySoftDelete(association = local.association, parentSoftDeleted = arguments.parentSoftDeleted)
) {
local.all = "";
if (local.association.type == "hasMany") {
local.all = "All";
Expand Down Expand Up @@ -285,6 +291,21 @@
}
}

/**
* Internal function. True when a soft-deleted parent leaves this dependent association alone:
* `remove` / `removeAll` would unlink the children, and `delete` / `deleteAll` would destroy children
* whose model has no soft-delete column.
*/
public boolean function $dependentKeptBySoftDelete(required struct association, required boolean parentSoftDeleted) {
if (!arguments.parentSoftDeleted) {
return false;
}
if (ListFindNoCase("remove,removeAll", arguments.association.dependent)) {
return true;
}
return !model(arguments.association.modelName).$softDeletion();
}

/**
* Internal function.
* Registers a foreign key property with integer type for BoxLang compatibility.
Expand Down
52 changes: 29 additions & 23 deletions vendor/wheels/model/callbacks.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -428,15 +428,15 @@
* With `callbacks = false` (a save or delete that skips its callbacks) only the saved-change
* restore is queued.
*/
public void function $enqueueTransactionCallbacks(required string operation, struct savedBefore, boolean callbacks = true) {
public void function $enqueueTransactionCallbacks(required string operation, struct savedBefore, boolean callbacks = true, struct softDeleteBefore) {
// A delete changes no saved-change state, so a rollback puts back the current one.
if (!StructKeyExists(arguments, "savedBefore")) {
arguments.savedBefore = $savedChangesState();
}
if (!arguments.callbacks || !$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);
$enqueueSavedChangesRestore(argumentCollection = arguments);
return;
}
local.conn = this.$hashedConnectionArgs();
Expand All @@ -456,16 +456,17 @@
&& 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 = true,
savedChanges = $savedChangesState(),
savedBefore = arguments.savedBefore
}
);
local.entry = {
object = this,
operation = arguments.operation,
callbacks = true,
savedChanges = $savedChangesState(),
savedBefore = arguments.savedBefore
};
if (StructKeyExists(arguments, "softDeleteBefore")) {
local.entry.softDeleteBefore = arguments.softDeleteBefore;
}
ArrayAppend(request.wheels.$txnCallbacks[local.conn].queue, local.entry);
} else {
this.$runTransactionCallbacks(type = "afterCommit", operation = arguments.operation);
}
Expand All @@ -476,7 +477,7 @@
* 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) {
public void function $enqueueSavedChangesRestore(required string operation, required struct savedBefore, struct softDeleteBefore) {
if (
!StructKeyExists(request, "wheels")
|| !StructKeyExists(request.wheels, "$txnCallbacks")
Expand All @@ -486,16 +487,17 @@
}
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
}
);
local.entry = {
object = this,
operation = arguments.operation,
callbacks = false,
savedChanges = $savedChangesState(),
savedBefore = arguments.savedBefore
};
if (StructKeyExists(arguments, "softDeleteBefore")) {
local.entry.softDeleteBefore = arguments.softDeleteBefore;
}
ArrayAppend(request.wheels.$txnCallbacks[local.conn].queue, local.entry);
}
}

Expand Down Expand Up @@ -842,6 +844,10 @@
if (StructKeyExists(local.entry, "savedBefore")) {
local.entry.object.$restoreSavedChanges(local.entry.savedBefore);
}
// A rolled-back soft delete also puts back the object's deletedAt.
if (StructKeyExists(local.entry, "softDeleteBefore")) {
local.entry.object.$restoreSoftDeleteState(local.entry.softDeleteBefore);
}
}
}

Expand Down
Loading
Loading