Soft Deletable Behavior
This behavior lets you implement soft delete for your records in your models by introducing a flag to an existing table which indicates that a row has been deleted, instead of deleting the record.
Using this behavior you can implement soft deletion in your CakePHP models so no real data is lost when you issue a delete on a specific record. Instead, a field of your choosing is used to indicate that a record has been soft deleted, and the behavior will automatically override your specific find operations so only non-soft deleted records are fetched.
Get Soft Deletable Behavior 1.1.38 (Release Notes & Changelog)
All reports, enhancements and feature feedback should be provided through the project page, and not in comments for this article, so I can keep a closer track. Please do report any issues you find with Soft Deletable Behavior using its tracker:
Cake Syrup Tracker (Bugs / Features)
If you want to view the source code of the latest version of the Soft Deletable Behavior you can do so using the SVN browser: soft_deletable.php
That's it! When you use the function del() in your model (or its alias delete(), both native CakePHP functions) it will instead perform a soft delete on the record, setting its 'deleted' field to 1 and if there's a 'deleted_date' field it will also save the timestamp when it was deleted there.)
To change those and other settings, you can specify an array of settings right on your $actsAs property. Let's look at all the available settings:
Any other attributes in the form of attribute => value that are included on the configuration will be seen as setting fields (which names are taken from the attribute name) to specific values when a record is being soft deleted.
On our previous example, let us say that we want to also set the value of the field 'published' to '0' when a record gets soft deleted, and also wish to not have added the automatic conditions to only retrieve non-deleted records. We do so by specifying the appropiate settings:
If you don't specify 'find' => false when setting the conditions for the behavior (just as we did on our previous example), then SoftDeleteBehavior will add specific conditions so that when you issue a find() or findAll() on the model only non-deleted records are returned. However, you can also disable this behavior alltogether (like we did by setting 'find' to false), or disable it when you need. There are different ways to disable this, one easy way is that you add your own conditions when you do a find() or findAll() on the field 'deleted':
On this case we're obtaining both soft-deleted and non-deleted records by specifying that we wish to get those articles that have the 'deleted' field set to either 0 or 1.
Another way is to disable these automatic conditions on demand, and then re-enable for any future queries. We do so by using the behavior function called enableSoftDeletable(), which takes a boolean argument that should be set to true when you want automatic conditions added, or false otherwise:
We'll then get all records (both soft-deleted and non deleted.) Notice than when calling enableSoftDeletable() with just one parameter you are also disabling the automatic soft deletion of records. If you just wish to override the conditions Soft Deletable adds to your find operations then a safer approach is to tell the behavior to only disable the find override:
If you want to pemanently remove the record when calling del() on the model that holds it (and since default behavior would be to soft-delete the record), then you can override the behavior for method 'delete' by setting:
You can also use the provided hardDelete method to keep it simpler:
If you want to purge (permanently delete) all soft deleted records you can also use the method purge:
When a record has been deleted on a model that has the SoftDelete behavior applied, then that record is not really being deleted. Instead, as we've seen, a specific field on the table is set to 1 to indicate that is deleted, and conditions are added to any find() call to make sure that only records which have that field set to any value other than 1 are returned. Therefore, we can safely undelete a record by using the behavior method undelete().
On the following example we start by deleting a record, then obtaining all records, and then undeleting that record. We use debug() instead of proper CakePHP behavior just to show how it can be used from your controllers:
Here's the code for the behavior. Save this as a file named soft_deletable.php in your app/models/behaviors folder. In the following section you can also find how to set up test cases for this behavior.
Once you have your test environment setup and you have installed the Soft Deletable behavior as was instructed on previous section, create a file named deletable_article_fixture.php in your app/tests/fixtures folder with the contents shown on the following link:
deletable_article_fixture.php
Next, create a file named deletable_comment_fixture.php in your app/tests/fixtures folder with the contents shown on the following link:
deletable_comment_fixture.php
Now create a file named soft_deletable.test.php and place it on your app/tests/cases/behaviors folder with the contents shown on the following link:
soft_deletable.test.php
Run your test by accessing the URL (replace example.com with your own server address): http://www.example.com/test.php. Once there, click on App Test Cases, and then look for the option behaviors/soft_deletable.test.php and click it. You will see the results of the test on your browser.
Download, Source Code and Bug Tracking
The latest Soft Deletable Behavior release is 1.1.38. For those of you who wish to keep up with the latest (not necessarily stable) Soft Deletable Behavior resides in the SVN repository of a project that includes other CakePHP goodies: Cake Syrup. All future official releases will be posted on this article.Get Soft Deletable Behavior 1.1.38 (Release Notes & Changelog)
All reports, enhancements and feature feedback should be provided through the project page, and not in comments for this article, so I can keep a closer track. Please do report any issues you find with Soft Deletable Behavior using its tracker:
Cake Syrup Tracker (Bugs / Features)
If you want to view the source code of the latest version of the Soft Deletable Behavior you can do so using the SVN browser: soft_deletable.php
Installation
- Create a file named soft_deletable.php in your app/models/behaviors folder using the contents provided below.
- For those models that will use this behavior, you need to identify which field holds the deleted marker (eg: deleted) that will be set to '1' when a record has been soft deleted, and optionally which field will hold the time it was soft deleted (eg: deleted_date)
Usage
The simplest way you can use this behavior is by adding its name to the $actsAs array for your model. For example, let's assume you have a model named Article (which maps to a database table named articles, that has among other fields 'deleted' and 'deleted_date'). Then edit your app/models/article.php file and add $actsAs as follows:Model Class:
Download code
<?php
class Article extends AppModel {
var $name = 'Article';
var $actsAs = array('SoftDeletable');
}
?>
That's it! When you use the function del() in your model (or its alias delete(), both native CakePHP functions) it will instead perform a soft delete on the record, setting its 'deleted' field to 1 and if there's a 'deleted_date' field it will also save the timestamp when it was deleted there.)
To change those and other settings, you can specify an array of settings right on your $actsAs property. Let's look at all the available settings:
- field: name of the field in the mapped database table that will hold the value '1' when a record is deleted, or the value '0' when it is not. This field should have a default value of '0'. Defaults to 'deleted'.
- field_date: name of the field in the mapped database table that will hold the timestamp when a record has been deleted. Defaults to 'deleted_date'. This field is optional, and can be set to null. Defaults to 'deleted_date'.
- delete: a boolean, set to true if you want to soft delete the record when you call del() on the model, or false if when calling del() record should be removed from database. Defaults to true.
- find: a boolean, set to true to automatically add conditions so only non-deleted records are retrieved when performing any find() operation by using beforeFind(), or false otherwise. Defaults to true.
Any other attributes in the form of attribute => value that are included on the configuration will be seen as setting fields (which names are taken from the attribute name) to specific values when a record is being soft deleted.
On our previous example, let us say that we want to also set the value of the field 'published' to '0' when a record gets soft deleted, and also wish to not have added the automatic conditions to only retrieve non-deleted records. We do so by specifying the appropiate settings:
Model Class:
Download code
<?php
class Article extends AppModel {
var $name = 'Article';
var $actsAs = array('SoftDelete' => array('find' => false, 'published' => '0'));
}
?>
Overriding automatic find conditions and soft delete behavior
If you don't specify 'find' => false when setting the conditions for the behavior (just as we did on our previous example), then SoftDeleteBehavior will add specific conditions so that when you issue a find() or findAll() on the model only non-deleted records are returned. However, you can also disable this behavior alltogether (like we did by setting 'find' to false), or disable it when you need. There are different ways to disable this, one easy way is that you add your own conditions when you do a find() or findAll() on the field 'deleted':
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$articles = $this->Article->findAll(array('Article.deleted' => array(0, 1)));
$this->set('articles', $articles);
}
}
?>
On this case we're obtaining both soft-deleted and non-deleted records by specifying that we wish to get those articles that have the 'deleted' field set to either 0 or 1.
Another way is to disable these automatic conditions on demand, and then re-enable for any future queries. We do so by using the behavior function called enableSoftDeletable(), which takes a boolean argument that should be set to true when you want automatic conditions added, or false otherwise:
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$this->Article->enableSoftDeletable(false);
$articles = $this->Article->findAll();
$this->Article->enableSoftDeletable(true);
$this->set('articles', $articles);
}
}
?>
We'll then get all records (both soft-deleted and non deleted.) Notice than when calling enableSoftDeletable() with just one parameter you are also disabling the automatic soft deletion of records. If you just wish to override the conditions Soft Deletable adds to your find operations then a safer approach is to tell the behavior to only disable the find override:
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$this->Article->enableSoftDeletable('find', false);
$articles = $this->Article->findAll();
$this->Article->enableSoftDeletable('find', true);
$this->set('articles', $articles);
}
}
?>
If you want to pemanently remove the record when calling del() on the model that holds it (and since default behavior would be to soft-delete the record), then you can override the behavior for method 'delete' by setting:
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$this->Article->enableSoftDeletable('delete', false);
$this->Article->del(1);
}
}
?>
You can also use the provided hardDelete method to keep it simpler:
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$this->Article->hardDelete(1);
}
}
?>
If you want to purge (permanently delete) all soft deleted records you can also use the method purge:
Controller Class:
Download code
<?php
class ArticlesController extends AppController {
var $name = 'Articles';
function index() {
$this->Article->purge();
}
}
?>
Undeleting a record
When a record has been deleted on a model that has the SoftDelete behavior applied, then that record is not really being deleted. Instead, as we've seen, a specific field on the table is set to 1 to indicate that is deleted, and conditions are added to any find() call to make sure that only records which have that field set to any value other than 1 are returned. Therefore, we can safely undelete a record by using the behavior method undelete().
On the following example we start by deleting a record, then obtaining all records, and then undeleting that record. We use debug() instead of proper CakePHP behavior just to show how it can be used from your controllers:
PHP Snippet:
Download code
<?php
// Soft-delete article with ID 1
$this->Article->del(1);
// Show all articles (automatic conditions are on,
// so only non-deleted articles are obtained)
debug($this->Article->findAll());
// Undelete previously deleted article
$this->Article->undelete(1);
// Show all articles
debug($this->Article->findAll());
?>
Behavior
Here's the code for the behavior. Save this as a file named soft_deletable.php in your app/models/behaviors folder. In the following section you can also find how to set up test cases for this behavior.
Behavior Class:
Download code
<?php
/* SVN FILE: $Id: soft_deletable.php 38 2007-11-26 19:36:27Z mgiglesias $ */
/**
* SoftDeletable Behavior class file.
*
* @filesource
* @author Mariano Iglesias
* @link http://cake-syrup.sourceforge.net/ingredients/soft-deletable-behavior/
* @version $Revision: 38 $
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
* @package app
* @subpackage app.models.behaviors
*/
/**
* Model behavior to support soft deleting records.
*
* @package app
* @subpackage app.models.behaviors
*/
class SoftDeletableBehavior extends ModelBehavior
{
/**
* Contain settings indexed by model name.
*
* @var array
* @access private
*/
var $__settings = array();
/**
* Initiate behaviour for the model using settings.
*
* @param object $Model Model using the behaviour
* @param array $settings Settings to override for model.
* @access public
*/
function setup(&$Model, $settings = array())
{
$default = array('field' => 'deleted', 'field_date' => 'deleted_date', 'delete' => true, 'find' => true);
if (!isset($this->__settings[$Model->alias]))
{
$this->__settings[$Model->alias] = $default;
}
$this->__settings[$Model->alias] = am($this->__settings[$Model->alias], ife(is_array($settings), $settings, array()));
}
/**
* Run before a model is deleted, used to do a soft delete when needed.
*
* @param object $Model Model about to be deleted
* @param boolean $cascade If true records that depend on this record will also be deleted
* @return boolean Set to true to continue with delete, false otherwise
* @access public
*/
function beforeDelete(&$Model, $cascade = true)
{
if ($this->__settings[$Model->alias]['delete'] && $Model->hasField($this->__settings[$Model->alias]['field']))
{
$attributes = $this->__settings[$Model->alias];
$id = $Model->id;
$data = array($Model->alias => array(
$attributes['field'] => 1
));
if (isset($attributes['field_date']) && $Model->hasField($attributes['field_date']))
{
$data[$Model->alias][$attributes['field_date']] = date('Y-m-d H:i:s');
}
foreach(am(array_keys($data[$Model->alias]), array('field', 'field_date', 'find', 'delete')) as $field)
{
unset($attributes[$field]);
}
if (!empty($attributes))
{
$data[$Model->alias] = am($data[$Model->alias], $attributes);
}
$Model->id = $id;
$deleted = $Model->save($data, false, array_keys($data[$Model->alias]));
if ($deleted && $cascade)
{
$Model->_deleteDependent($id, $cascade);
$Model->_deleteLinks($id);
}
return false;
}
return true;
}
/**
* Permanently deletes a record.
*
* @param object $Model Model from where the method is being executed.
* @param mixed $id ID of the soft-deleted record.
* @param boolean $cascade Also delete dependent records
* @return boolean Result of the operation.
* @access public
*/
function hardDelete(&$Model, $id, $cascade = true)
{
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
$deleted = $Model->del($id, $cascade);
$this->enableSoftDeletable($Model, 'delete', $onDelete);
$this->enableSoftDeletable($Model, 'find', $onFind);
return $deleted;
}
/**
* Permanently deletes all records that were soft deleted.
*
* @param object $Model Model from where the method is being executed.
* @param boolean $cascade Also delete dependent records
* @return boolean Result of the operation.
* @access public
*/
function purge(&$Model, $cascade = true)
{
$purged = false;
if ($Model->hasField($this->__settings[$Model->alias]['field']))
{
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
$purged = $Model->deleteAll(array($this->__settings[$Model->alias]['field'] => '1'), $cascade);
$this->enableSoftDeletable($Model, 'delete', $onDelete);
$this->enableSoftDeletable($Model, 'find', $onFind);
}
return $purged;
}
/**
* Restores a soft deleted record, and optionally change other fields.
*
* @param object $Model Model from where the method is being executed.
* @param mixed $id ID of the soft-deleted record.
* @param $attributes Other fields to change (in the form of field => value)
* @return boolean Result of the operation.
* @access public
*/
function undelete(&$Model, $id = null, $attributes = array())
{
if ($Model->hasField($this->__settings[$Model->alias]['field']))
{
if (empty($id))
{
$id = $Model->id;
}
$data = array($Model->alias => array(
$Model->primaryKey => $id,
$this->__settings[$Model->alias]['field'] => '0'
));
if (isset($this->__settings[$Model->alias]['field_date']) && $Model->hasField($this->__settings[$Model->alias]['field_date']))
{
$data[$Model->alias][$this->__settings[$Model->alias]['field_date']] = null;
}
if (!empty($attributes))
{
$data[$Model->alias] = am($data[$Model->alias], $attributes);
}
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
$Model->id = $id;
$result = $Model->save($data, false, array_keys($data[$Model->alias]));
$this->enableSoftDeletable($Model, 'find', $onFind);
$this->enableSoftDeletable($Model, 'delete', $onDelete);
return ($result !== false);
}
return false;
}
/**
* Set if the beforeFind() or beforeDelete() should be overriden for specific model.
*
* @param object $Model Model about to be deleted.
* @param mixed $methods If string, method (find / delete) to enable on, if array array of method names, if boolean, enable it for find method
* @param boolean $enable If specified method should be overriden.
* @access public
*/
function enableSoftDeletable(&$Model, $methods, $enable = true)
{
if (is_bool($methods))
{
$enable = $methods;
$methods = array('find', 'delete');
}
if (!is_array($methods))
{
$methods = array($methods);
}
foreach($methods as $method)
{
$this->__settings[$Model->alias][$method] = $enable;
}
}
/**
* Run before a model is about to be find, used only fetch for non-deleted records.
*
* @param object $Model Model about to be deleted.
* @param array $queryData Data used to execute this query, i.e. conditions, order, etc.
* @return mixed Set to false to abort find operation, or return an array with data used to execute query
* @access public
*/
function beforeFind(&$Model, $queryData)
{
if ($this->__settings[$Model->alias]['find'] && $Model->hasField($this->__settings[$Model->alias]['field']))
{
$Db =& ConnectionManager::getDataSource($Model->useDbConfig);
$include = false;
if (!empty($queryData['conditions']) && is_string($queryData['conditions']))
{
$include = true;
$fields = array(
$Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field']),
$Db->name($this->__settings[$Model->alias]['field']),
$Model->alias . '.' . $this->__settings[$Model->alias]['field'],
$this->__settings[$Model->alias]['field']
);
foreach($fields as $field)
{
if (preg_match('/^' . preg_quote($field) . '[\s=!]+/i', $queryData['conditions']) || preg_match('/\\x20+' . preg_quote($field) . '[\s=!]+/i', $queryData['conditions']))
{
$include = false;
break;
}
}
}
else if (empty($queryData['conditions']) || (!in_array($this->__settings[$Model->alias]['field'], array_keys($queryData['conditions'])) && !in_array($Model->alias . '.' . $this->__settings[$Model->alias]['field'], array_keys($queryData['conditions']))))
{
$include = true;
}
if ($include)
{
if (empty($queryData['conditions']))
{
$queryData['conditions'] = array();
}
if (is_string($queryData['conditions']))
{
$queryData['conditions'] = $Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field']) . '!= 1 AND ' . $queryData['conditions'];
}
else
{
$queryData['conditions'][$Model->alias . '.' . $this->__settings[$Model->alias]['field']] = '!= 1';
}
}
}
return $queryData;
}
/**
* Run before a model is saved, used to disable beforeFind() override.
*
* @param object $Model Model about to be saved.
* @return boolean True if the operation should continue, false if it should abort
* @access public
*/
function beforeSave(&$Model)
{
if ($this->__settings[$Model->alias]['find'])
{
if (!isset($this->__backAttributes))
{
$this->__backAttributes = array($Model->alias => array());
}
else if (!isset($this->__backAttributes[$Model->alias]))
{
$this->__backAttributes[$Model->alias] = array();
}
$this->__backAttributes[$Model->alias]['find'] = $this->__settings[$Model->alias]['find'];
$this->__backAttributes[$Model->alias]['delete'] = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
}
return true;
}
/**
* Run after a model has been saved, used to enable beforeFind() override.
*
* @param object $Model Model just saved.
* @param boolean $created True if this save created a new record
* @access public
*/
function afterSave(&$Model, $created)
{
if (isset($this->__backAttributes[$Model->alias]['find']))
{
$this->enableSoftDeletable($Model, 'find', $this->__backAttributes[$Model->alias]['find']);
$this->enableSoftDeletable($Model, 'delete', $this->__backAttributes[$Model->alias]['delete']);
unset($this->__backAttributes[$Model->alias]['find']);
unset($this->__backAttributes[$Model->alias]['delete']);
}
}
}
?>
Test Case
First of all, follow instructions on how to set up your CakePHP test suite by reading the section Installation on the article Testing Models with CakePHP 1.2 test suite.Once you have your test environment setup and you have installed the Soft Deletable behavior as was instructed on previous section, create a file named deletable_article_fixture.php in your app/tests/fixtures folder with the contents shown on the following link:
deletable_article_fixture.php
Next, create a file named deletable_comment_fixture.php in your app/tests/fixtures folder with the contents shown on the following link:
deletable_comment_fixture.php
Now create a file named soft_deletable.test.php and place it on your app/tests/cases/behaviors folder with the contents shown on the following link:
soft_deletable.test.php
Run your test by accessing the URL (replace example.com with your own server address): http://www.example.com/test.php. Once there, click on App Test Cases, and then look for the option behaviors/soft_deletable.test.php and click it. You will see the results of the test on your browser.
Comments
Comment
1 what about...
I would like to share some ideas for improvements since I implemented something like this in an existing cake application, but since it is still in 1.1.xx I had to use app_model.php... (just a question: can I run your behaviour in 1.1.xx?)
I used only one field 'deleted' (type DATETIME) set to NULL by default or filled with the timestamp when deleted.
This way I can avoid the other boolean field.
What about this idea?
I wrote a 'purge' method too to delete an already "soft deleted" row.
It could be a simple wrapper to your code:
but I think it can be handy and useful...$this->Article->override(false, array('delete', 'find'));
$this->Article->del(1);
I general I think that the whole 'override' thing -at least for the most used function, could be named in a more meaningful way...
anyway: thank you for your great code!
just my 2 cents
Stefano
Bug
2 Conditions are ignored when not using array syntax
Comment
3 Cascading soft delete
I noticed that when 'dependent' => true is set in the model, a soft delete (or actual delete) is not performed on associated records. I can update my models to handle this manually but wanted to check here to make sure I am not missing something obvious.
Cheers,
Billy
Comment
4 Soft Deletable Behavior updated
Comment
5 related models
I haven't been able to figure that one out (except adding conditions to association definition)
Comment
6 a bug
need to change
!in_array($this->__settings[$Model->alias]['field'], array_keys($queryData['conditions']))
to
!in_array($this->__settings[$Model->alias]['field'], array_keys($queryData['conditions']), true)
to see why run this on your local php installation
if(in_array('deleted', array(0)))
print "wtf!!!!!";
Comment
7 Breaks because of new query syntax
"To help ensure that applications are secure with no extra effort on the part of the developer, we have moved all operators used in conditions to the "key" side. For example, $conditions = array('Model.field >' => $value); is the new syntax. We have maintained backwards compatibility for the most common cases, you will need to update your affected application code."
Therefore, two lines need to be updated:
if (is_string($queryData['conditions']))to{
$queryData['conditions'] = $Db->name($Model->alias) . '.' .
$Db->name($this->__settings[$Model->alias]['field']) . '!= 1 AND ' .
$queryData['conditions'];
}
else
{
$queryData['conditions'][$Model->alias . '.' .
$this->__settings[$Model->alias]['field']] = '!= 1';
}
if (is_string($queryData['conditions'])){
$queryData['conditions'] = $Db->name($Model->alias) . '.' .
$Db->name($this->__settings[$Model->alias]['field'] . ' !=') . ' 1 AND ' .
$queryData['conditions'];
}
else
{
$queryData['conditions'][$Model->alias . '.' .
$this->__settings[$Model->alias]['field'] . ' !='] = ' 1';
}
Comment
8 Enhancements
However, I noticed that values indicating non-deleted ('0') and deleted ('1') records are hard-coded in the functions. It would be nice and easy if you allowed them to be changed by configuration. If a user, for some odd reasons, wants them to be 'NO' and 'YES' respectively, why not allowing him?
A more practical example would be my project - I use the same approach, but only with field 'valid' which defaults to '1' and after deleting (setting to invalid) it turns into '0'. I wasn't able to do this with SoftDeletable, although it otherwise fits my needs perfectly. I made some changes to enable it, I could share the code if you were interested, but it's just a find&replace;)
I also noticed that other bakers and core developers heavily use the PHP extract() function to make their code more readable. It might save you some keystrokes as you wouldn't have to keep writing
$this->__settings[$Model->alias][xxx]all over again.Anyway, thanks for the effort and keep up the good work!;)
Comment
9 Return value of the del() function
It would make a lot of sense, because from the application's point of view, the record WAS deleted, so it is only appropriate to notify the developer/user. Additionally, now there is no way to tell whether the update operation (performed by the behavior) failed or not, you get "false" from Model::del() either way.
Do you think there's a way to make this work?
Comment
10 deleted = 0
line 273 (in the `beforeSave()` function):
// also set the deleted field to '0' (instead of null)
if (!$Model->id) {
$Model->data[$Model->alias][$this->__settings[$Model->alias]['field']] = 0;
}
Comment
11 Agreed.
Agreed, this screws some things up.
Comment
12 Recursive undelete
I saw that the author updated the behavior to support recursive softdelete, but this feature is somehow useless without support for recursive undelete. As far as I gather from studying the code this is not supported.
Would this be difficult to implement?
Bug
13 recursive find
Comment
14 SoftDeletable
In your model, it's $actsAs = array('SoftDeletable') ... not 'SoftDelete' as shown.
Comment
15 Invalid SQL generated
To fix this, change line 279 (Revision 38) to:
$queryData['conditions'][] = $Model->alias . '.' . $this->__settings[$Model->alias]['field'] . ' != 1';
?>
Question
16 Recursive 'find'
Dan is right. How do we go about hiding any "recipes" that belong to a soft deleted "book"?
Obviously, this is do-able with manual conditions, but how can it be made as DEFAULT for ALL associated models?
Comment
17 Resursive 'find'
Just a tip which might save other people some time. Quickest manual way to remove 'Soft Deleted' items in associated models is to set 'conditions' => 'Model.deleted != 1' when defining the relationships in the Model.
Comment
18 For the association models
Fix
In app_model, i create a function softDeletableAssociation, and i called this function inside of beforeFind, (u can use this function or have to enter in every model and set conditions = deleted != 1), the function verify if model has any association, and then verify if are using softDeletable,
before add deleted != 1, its check if exist a pre-condition(when is set on model)...
just that... works fine with me, im using a 4 months, and just now i remember to post ;P
Model Class:
<?php
function softDeletableAssociation(){
$tableModels = $this->hasMany;
if(!empty($tableModels)){
foreach($tableModels as $tableAliasModelName=>$tableModelInfo){
if(isset($this->$tableModelInfo['className']->actsAs)){
$searchDeletable = array_search('SoftDeletable', $this->$tableModelInfo['className']->actsAs);
//Verifica se o model possue o deletable behavior - Verify if this model use deletable behavior
if(isset($searchDeletable)){
if(empty($tableModelInfo['conditions'])){
unset($this->hasMany[$tableAliasModelName]['conditions']);
$this->hasMany[$tableAliasModelName]['conditions'] = array("$tableAliasModelName.deleted !="=>1);
}
else{
unset($this->hasMany[$tableAliasModelName]['conditions']);
$this->hasMany[$tableAliasModelName]['conditions'] = array_merge($tableModelInfo["conditions"] , array("$tableAliasModelName.deleted !="=> 1));
}
}
}
$this->$tableModelInfo['className']->actsAs);
}
}
}
function beforeFind(){
$this->softDeletableAssociation();
}
?>
Bug
19 Fix
Here I have the correct code (if statement in the beforeFind):
if (is_string($queryData['conditions']))
{
$queryData['conditions'] = $Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field'] . ' !=') . '1 AND ' . $queryData['conditions'];
}
else
{
$queryData['conditions'][$Model->alias . '.' . $this->__settings[$Model->alias]['field'] . ' !='] = '1';
}
Question
20 How to redirect after softdelete?
tks,
Ana
Comment
21 Supposed solution for the return value of the del() function
Ana: The thing is *del()* only returns true when the deletion occurs. Due to the false return, if you use a if(del()) you'll always get false, and if you put the redirect inside its arguments this code will never run. The solution would be just to del() and redirect and hope for it to be ok.
The solution for del to return true is to override the del() method in your model for the following:
function del($id = null, $cascade = true) {
if (!empty($id)) {
$this->id = $id;
}
$id = $this->id;
if ($this->exists() && $this->beforeDelete($cascade)) {
$db =& ConnectionManager::getDataSource($this->useDbConfig);
if (!$this->Behaviors->trigger($this, 'beforeDelete', array($cascade), array('break' => true, 'breakOn' => false))) {
return true;
}
}
return false;
}
Notice the return true; after the Behavior's beforeDelete trigger.
I don't know if this is such a good practice but it does work. Please share your opinions.
Comment
22 WhoDidIt: deleted_by
Additional Defualt-Settings in setup:
$default = array('field' => 'deleted', 'field_date' => 'deleted_date', 'field_by' => 'deleted_by',
'delete' => true, 'find' => true,
'auth_session' => 'Auth', //name of Auth session key
'user_model' => 'User', //name of User model
);
Here is the new beforeDelete:
function beforeDelete(&$Model, $cascade = true)
{
if ($this->__settings[$Model->alias]['delete'] && $Model->hasField($this->__settings[$Model->alias]['field']))
{
$attributes = $this->__settings[$Model->alias];
$id = $Model->id;
$data = array($Model->alias => array( $attributes['field'] => 1 ));
if (isset($attributes['field_date']) && $Model->hasField($attributes['field_date']))
{
$data[$Model->alias][$attributes['field_date']] = date('Y-m-d H:i:s');
}
if (isset($attributes['field_by']) && $Model->hasField($attributes['field_by'])
&& isset($attributes['auth_session']) && isset($attributes['user_model']))
{
$AuthSession = $attributes['auth_session'];
$UserSession = $attributes['user_model'];
$userId = Set::extract($_SESSION, $AuthSession.'.'.$UserSession.'.'.'id');
if ($userId) {
$data[$Model->alias][$attributes['field_by']] = $userId;
}
}
foreach(am(array_keys($data[$Model->alias]), array('field', 'field_date', 'field_by',
'auth_session','user_model', 'find', 'delete')) as $field)
{
unset($attributes[$field]);
}
if (!empty($attributes))
{
$data[$Model->alias] = am($data[$Model->alias], $attributes);
}
$Model->id = $id;
$deleted = $Model->save($data, false, array_keys($data[$Model->alias]));
if ($deleted && $cascade)
{
$Model->_deleteDependent($id, $cascade);
$Model->_deleteLinks($id);
}
return false;
}
return true;
}
Comment
23 Field_date only mod
Simply, when field_date IS NULL then the record is undeleted, if it IS NOT NULL, then the record is deleted.
My mod saves us from keeping 2 separate fields in our DB.
One field enough now, that is field_date, field can be left null
<?php
/* SVN FILE: $Id: soft_deletable.php 38 2007-11-26 19:36:27Z mgiglesias $ */
/**
* SoftDeletable Behavior class file.
*
* @filesource
* @author Mariano Iglesias (modded by Tomasz Kraus)
* @link http://cake-syrup.sourceforge.net/ingredients/soft-deletable-behavior/
* @version $Revision: 38 $
* @license http://www.opensource.org/licenses/mit-license.php The MIT License
* @package app
* @subpackage app.models.behaviors
*/
/**
* Model behavior to support soft deleting records.
*
* field now also can be null, if so, field_date is used to mark deletions
*
* @package app
* @subpackage app.models.behaviors
*/
class SoftDeletableBehavior extends ModelBehavior
{
/**
* Contain settings indexed by model name.
*
* @var array
* @access private
*/
var $__settings = array();
/**
* Initiate behaviour for the model using settings.
*
* @param object $Model Model using the behaviour
* @param array $settings Settings to override for model.
* @access public
*/
function setup(&$Model, $settings = array())
{
$default = array('field' => 'deleted', 'field_date' => 'deleted_date', 'delete' => true, 'find' => true);
if (!isset($this->__settings[$Model->alias]))
{
$this->__settings[$Model->alias] = $default;
}
$this->__settings[$Model->alias] = am($this->__settings[$Model->alias], ife(is_array($settings), $settings, array()));
}
/**
* Run before a model is deleted, used to do a soft delete when needed.
*
* @param object $Model Model about to be deleted
* @param boolean $cascade If true records that depend on this record will also be deleted
* @return boolean Set to true to continue with delete, false otherwise
* @access public
*/
function beforeDelete(&$Model, $cascade = true)
{
if ($this->__settings[$Model->alias]['delete'] &&
($Model->hasField($this->__settings[$Model->alias]['field']) ||
$Model->hasField($this->__settings[$Model->alias]['field_date'])))
{
$attributes = $this->__settings[$Model->alias];
$id = $Model->id;
$data = array(
$Model->alias => array(
$Model->primaryKey => $id,
)
);
if (isset($attributes['field']) && $attributes['field'] && $Model->hasField($attributes['field'])) {
$data[$Model->alias][$attributes['field']] = 1;
}
if (isset($attributes['field_date']) && $Model->hasField($attributes['field_date']))
{
$data[$Model->alias][$attributes['field_date']] = date('Y-m-d H:i:s');
}
foreach(am(array_keys($data[$Model->alias]), array('field', 'field_date', 'find', 'delete')) as $field)
{
unset($attributes[$field]);
}
if (!empty($attributes))
{
$data[$Model->alias] = am($data[$Model->alias], $attributes);
}
$Model->id = $id;
$deleted = $Model->save($data, false, array_keys($data[$Model->alias]));
if ($deleted && $cascade)
{
$Model->_deleteDependent($id, $cascade);
$Model->_deleteLinks($id);
}
return false;
}
return true;
}
/**
* Permanently deletes a record.
*
* @param object $Model Model from where the method is being executed.
* @param mixed $id ID of the soft-deleted record.
* @param boolean $cascade Also delete dependent records
* @return boolean Result of the operation.
* @access public
*/
function hardDelete(&$Model, $id, $cascade = true)
{
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
$deleted = $Model->del($id, $cascade);
$this->enableSoftDeletable($Model, 'delete', $onDelete);
$this->enableSoftDeletable($Model, 'find', $onFind);
return $deleted;
}
/**
* Permanently deletes all records that were soft deleted.
*
* @param object $Model Model from where the method is being executed.
* @param boolean $cascade Also delete dependent records
* @return boolean Result of the operation.
* @access public
*/
function purge(&$Model, $cascade = true)
{
$purged = false;
if ($Model->hasField($this->__settings[$Model->alias]['field']))
{
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
if ($this->__settings[$Model->alias]['field']) {
$purged = $Model->deleteAll(array($this->__settings[$Model->alias]['field'] => '1'), $cascade);
}
elseif(isset($this->__settings[$Model->alias]['field_date'])) {
$purged = $Model->deleteAll(array($this->__settings[$Model->alias]['field'].' !=' => null), $cascade);
}
$this->enableSoftDeletable($Model, 'delete', $onDelete);
$this->enableSoftDeletable($Model, 'find', $onFind);
}
return $purged;
}
/**
* Restores a soft deleted record, and optionally change other fields.
*
* @param object $Model Model from where the method is being executed.
* @param mixed $id ID of the soft-deleted record.
* @param $attributes Other fields to change (in the form of field => value)
* @return boolean Result of the operation.
* @access public
*/
function undelete(&$Model, $id = null, $attributes = array())
{
if ($Model->hasField($this->__settings[$Model->alias]['field']) ||
$Model->hasField($this->__settings[$Model->alias]['field_date']))
{
if (empty($id))
{
$id = $Model->id;
}
$data = array(
$Model->alias => array(
$Model->primaryKey => $id,
)
);
if (isset($this->__settings[$Model->alias]['field']) && $Model->hasField($this->__settings[$Model->alias]['field'])) {
$data[$Model->alias][$this->__settings[$Model->alias]['field']] = '0';
}
if (isset($this->__settings[$Model->alias]['field_date']) && $Model->hasField($this->__settings[$Model->alias]['field_date']))
{
$data[$Model->alias][$this->__settings[$Model->alias]['field_date']] = null;
}
if (!empty($attributes))
{
$data[$Model->alias] = am($data[$Model->alias], $attributes);
}
$onFind = $this->__settings[$Model->alias]['find'];
$onDelete = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
$Model->id = $id;
$result = $Model->save($data, false, array_keys($data[$Model->alias]));
$this->enableSoftDeletable($Model, 'find', $onFind);
$this->enableSoftDeletable($Model, 'delete', $onDelete);
return ($result !== false);
}
return false;
}
/**
* Set if the beforeFind() or beforeDelete() should be overriden for specific model.
*
* @param object $Model Model about to be deleted.
* @param mixed $methods If string, method (find / delete) to enable on, if array array of method names, if boolean, enable it for find method
* @param boolean $enable If specified method should be overriden.
* @access public
*/
function enableSoftDeletable(&$Model, $methods, $enable = true)
{
if (is_bool($methods))
{
$enable = $methods;
$methods = array('find', 'delete');
}
if (!is_array($methods))
{
$methods = array($methods);
}
foreach($methods as $method)
{
$this->__settings[$Model->alias][$method] = $enable;
}
}
/**
* Run before a model is about to be find, used only fetch for non-deleted records.
*
* @param object $Model Model about to be deleted.
* @param array $queryData Data used to execute this query, i.e. conditions, order, etc.
* @return mixed Set to false to abort find operation, or return an array with data used to execute query
* @access public
*/
function beforeFind(&$Model, $queryData)
{
if ($this->__settings[$Model->alias]['find'] &&
(
(isset($this->__settings[$Model->alias]['field']) && $Model->hasField($this->__settings[$Model->alias]['field'])) ||
(isset($this->__settings[$Model->alias]['field_date']) && $Model->hasField($this->__settings[$Model->alias]['field_date']))
)
)
{
$Db =& ConnectionManager::getDataSource($Model->useDbConfig);
$include = false;
if (!empty($queryData['conditions']) && is_string($queryData['conditions']))
{
$include = true;
$fields = array();
if (isset($this->__settings[$Model->alias]['field'])) {
$fields = array_merge($fields, array(
$Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field']),
$Db->name($this->__settings[$Model->alias]['field']),
$Model->alias . '.' . $this->__settings[$Model->alias]['field'],
$this->__settings[$Model->alias]['field']
));
}
if (isset($this->__settings[$Model->alias]['field_date'])) {
$fields = array_merge($fields, array(
$Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field_date']),
$Db->name($this->__settings[$Model->alias]['field_date']),
$Model->alias . '.' . $this->__settings[$Model->alias]['field_date'],
$this->__settings[$Model->alias]['field_date']
));
}
foreach($fields as $field)
{
if (preg_match('/^' . preg_quote($field) . '[\s=!]+/i', $queryData['conditions']) || preg_match('/\\x20+' . preg_quote($field) . '[\s=!]+/i', $queryData['conditions']))
{
$include = false;
break;
}
}
}
else if (empty($queryData['conditions']) ||
(isset($this->__settings[$Model->alias]['field']) &&
!in_array($this->__settings[$Model->alias]['field'],
array_keys($queryData['conditions'])) &&
!in_array($Model->alias . '.' . $this->__settings[$Model->alias]['field'],
array_keys($queryData['conditions']))
) ||
(isset($this->__settings[$Model->alias]['field_date']) &&
!in_array($this->__settings[$Model->alias]['field_date'],
array_keys($queryData['conditions'])) &&
!in_array($Model->alias . '.' . $this->__settings[$Model->alias]['field_date'],
array_keys($queryData['conditions']))
)
)
{
$include = true;
}
// pr($include);
if ($include)
{
if (empty($queryData['conditions']))
{
$queryData['conditions'] = array();
}
if (is_string($queryData['conditions']))
{
if (isset($this->__settings[$Model->alias]['field'])) {
$queryData['conditions'] = $Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field']) . '!= 1 AND ' . $queryData['conditions'];
}
elseif(isset($this->__settings[$Model->alias]['field_date'])) {
$queryData['conditions'] = $Db->name($Model->alias) . '.' . $Db->name($this->__settings[$Model->alias]['field_date']) . ' IS NULL AND ' . $queryData['conditions'];
}
}
else
{
if (isset($this->__settings[$Model->alias]['field'])) {
$queryData['conditions'][$Model->alias . '.' . $this->__settings[$Model->alias]['field']] = '!= 1';
}
elseif(isset($this->__settings[$Model->alias]['field_date'])) {
$queryData['conditions'][$Model->alias . '.' . $this->__settings[$Model->alias]['field_date']] = null;
}
}
}
}
return $queryData;
}
/**
* Run before a model is saved, used to disable beforeFind() override.
*
* @param object $Model Model about to be saved.
* @return boolean True if the operation should continue, false if it should abort
* @access public
*/
function beforeSave(&$Model)
{
if ($this->__settings[$Model->alias]['find'])
{
if (!isset($this->__backAttributes))
{
$this->__backAttributes = array($Model->alias => array());
}
else if (!isset($this->__backAttributes[$Model->alias]))
{
$this->__backAttributes[$Model->alias] = array();
}
$this->__backAttributes[$Model->alias]['find'] = $this->__settings[$Model->alias]['find'];
$this->__backAttributes[$Model->alias]['delete'] = $this->__settings[$Model->alias]['delete'];
$this->enableSoftDeletable($Model, false);
}
return true;
}
/**
* Run after a model has been saved, used to enable beforeFind() override.
*
* @param object $Model Model just saved.
* @param boolean $created True if this save created a new record
* @access public
*/
function afterSave(&$Model, $created)
{
if (isset($this->__backAttributes[$Model->alias]['find']))
{
$this->enableSoftDeletable($Model, 'find', $this->__backAttributes[$Model->alias]['find']);
$this->enableSoftDeletable($Model, 'delete', $this->__backAttributes[$Model->alias]['delete']);
unset($this->__backAttributes[$Model->alias]['find']);
unset($this->__backAttributes[$Model->alias]['delete']);
}
}
}
?>