Soft Deletable Behavior

By Mariano Iglesias aka "mariano"
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.

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


  1. Create a file named soft_deletable.php in your app/models/behaviors folder using the contents provided below.
  2. 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:

  1. 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'.
  2. 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'.
  3. 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.
  4. 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(01)));
        
$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($datafalsearray_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($Modelfalse);

        
$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($Modelfalse);

            
$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($Modelfalse);

            
$Model->id $id;
            
$result $Model->save($datafalsearray_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($Modelfalse);
        }

        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 344

CakePHP team comments Author comments

Comment

1 what about...

Mariano, great code!

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:

$this->Article->override(false, array('delete', 'find'));
$this->Article->del(1); 

but I think it can be handy and useful...

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
posted Wed, Apr 18th 2007, 08:45 by Stefano

Bug

2 Conditions are ignored when not using array syntax

If I do something like $this->findAll('User.id = 1'); in my User model, this condition gets ignored when my model acts as SoftDelete.
posted Thu, Apr 26th 2007, 02:15 by Daniel Hofstetter

Comment

3 Cascading soft delete

Thanks a lot for contributing this behavior.

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
posted Tue, Jun 5th 2007, 18:09 by Billy Gunn

Comment

4 Soft Deletable Behavior updated

@all: I have updated this behavior and renamed it to Soft Deletable. Issue reported by Daniel Hofstetter has been fixed, new methods have been added (purge() to permanently delete all soft deleted records, hardDelete() to permanently delete a record), override() has been changed to enableSoftDeletable, and recursivity is now supported (issue reported by Billy Gunn). Check it out!
posted Mon, Nov 26th 2007, 14:31 by Mariano Iglesias

Comment

5 related models

I don't suppose there is any way to write the beforeFind to modify other models that are related to this one? For example if I soft delete a comment, and do a read on a Post which has many Comments.. is there a way to not find the Soft deleted comments.

I haven't been able to figure that one out (except adding conditions to association definition)
posted Fri, Feb 1st 2008, 19:46 by Rob Conner

Comment

6 a bug

Hey mariano.. there is 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!!!!!";
posted Wed, Feb 13th 2008, 15:58 by Rob Conner

Login to Submit a Comment