Soft Deletable Behavior

by 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:

<?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:

<?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:

<?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:

<?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:

<?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:

<?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:

<?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:

<?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:

<?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:

<?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.

Report

More on Behaviors

Advertising

Comments

  • miCRoSCoPiCeaRthLinG posted on 12/16/10 11:56:18 PM
    Hello,
    Has anyone been able to make this work with Cake 1.3.x? What code alterations did you have to do? Won't work for me at all... and I'm not familiar with the structural differences between Cake 1.2 and 1.3 (I started out just a couple of months back with 1.3) - to make changes myself.

    Also if you know of any other such behaviour that will work out-of-the-box with Cake 1.3 please share.

    Thanks,
    Sourjya
  • jonsibley posted on 10/17/10 06:25:43 PM
    Has anyone tested this on Cake 1.3?

    Just curious how many modifications need to be made to get it to work.

    Thanks,
    Jonathan
  • tadfisher posted on 06/16/10 07:00:11 PM
    Just a heads-up: deleteAll is not overridden by this behavior. So if you're relying on this to soft-delete for deleteAll, you'll have to override the deleteAll method in your model.
  • bwelfel posted on 06/01/10 08:29:20 PM
    Just wanted to know if there are any plans for supporting pagination as requested by Jasmin (Feb 24, 2010) :-) Thanks for the great work!
  • Cairlinn posted on 02/24/10 03:08:17 AM
    Is there a way to make this work with pagination as well?
  • balrog2000 posted on 09/27/09 10:46:59 AM
    I upload complete Behavior that allows us to use only 1 field if we base on field_date.

    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($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);
                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($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'] && 
                (
                    (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($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']);
            }
        }
    }
    ?>
  • Freight posted on 08/21/09 04:40:29 AM
    I have catched the idea from the WhoDidIt-Behavior and inserted some code to the SoftDeletable-Behavior to extend functionality. Now the user_id of deleting user should be saved in the field defined by "field_by". defualt is "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;
    }
  • lucasrcosta posted on 07/24/09 09:52:36 AM
    I guess it wouldn't be possible to force the Model::del($id) function to return "true", when the update by SoftUploadableBehavior is performed successfully?
    I'm trying to use this behavior in my app... It's working almost fine, but after deleting (softdeleting) an entry, it asks me for the Delete view (Controller::delete()). How can I redirect to the index??!

    tks,
    Ana

    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.
  • Severiano posted on 07/01/09 01:45:37 PM
    I'm trying to use this behavior in my app... It's working almost fine, but after deleting (softdeleting) an entry, it asks me for the Delete view (Controller::delete()). How can I redirect to the index??!

    tks,
    Ana
  • infest696 posted on 06/17/09 09:06:44 AM
    In the current Cake Version all operators used in conditions moved to the 'key' side. So you have to change the before find method and move the operators to get the behavior working correctly.
    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';
                    }
    • bazz posted on 11/19/10 04:06:50 AM
      [quote] In the current Cake Version all operators used in conditions moved to the 'key' side. So you have to change the before find method and move the operators to get the behavior working correctly.
      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';
                      }
      [end quote]
      This code seems to be part of the latest available version on SF. There's a bug in the beforeFind function, it won't get the not-softdeleted records if there are no conditions on the model level.

      should look like this:

      $queryData['conditions'] = $Model->alias . '.' . $this->__settings[$Model->alias]['field'] . '!= 1';
      instead of

      $queryData['conditions'][$Model->alias . '.' . $this->__settings[$Model->alias]['field'] . ' !='] = '1'; 
  • peteleco posted on 04/22/09 03:41:29 PM

    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();
    }
    ?>
    • bunter posted on 03/26/10 07:40:10 AM
      Hi,

      I tried your 'find enhancer' and it worked properly. But only after i removed the 22nd line.

      Model Class:

      <?php 
      (...)
      $this->$tableModelInfo['className']->actsAs);
      (...)
      ?>

      Was this only a typo or is something missing in your code now?

      Best regards
  • aidan posted on 12/26/08 11:48:26 PM
    If you're using a later version of Cake 1.2.x.x, you'll notice your SQL looks like: WHERE Model.deleted = '!= 1'

    To fix this, change line 279 (Revision 38) to:
    $queryData['conditions'][] = $Model->alias . '.' . $this->__settings[$Model->alias]['field'] . ' != 1';
    ?>
  • aidan posted on 12/10/08 09:42:53 PM
    Note:

    In your model, it's $actsAs = array('SoftDeletable') ... not 'SoftDelete' as shown.
  • danwalton posted on 11/26/08 04:54:14 AM
    When 'find'ing, it correctly excludes deleted models but all associated models come back - deleted or not. Giving the Associated model the behaviour also makes no difference.
    • joecritch posted on 02/02/09 05:13:43 AM
      When 'find'ing, it correctly excludes deleted models but all associated models come back - deleted or not. Giving the Associated model the behaviour also makes no difference.
      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?
      • jwswj posted on 02/16/09 06:18:04 AM
        [p]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.[p]
  • kanten posted on 11/04/08 04:27:01 AM
    Hi,

    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?
  • Kumazatheef posted on 10/31/08 01:28:19 PM
    This is getting really nit-picky and other ways to work around this, but I threw in a quick line to set the deleted field to 0 for me, rather than relying on the db to default it:

    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;
    }
  • ZiziTheFirst posted on 07/19/08 04:27:06 AM
    I guess it wouldn't be possible to force the Model::del($id) function to return "true", when the update by SoftUploadableBehavior is performed successfully?

    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?
    • Kumazatheef posted on 10/31/08 01:43:24 PM
      I guess it wouldn't be possible to force the Model::del($id) function to return "true", when the update by SoftUploadableBehavior is performed successfully?

      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?

      Agreed, this screws some things up.
  • ZiziTheFirst posted on 07/19/08 04:04:12 AM
    Good work, Mariano, exactly what I was looking for!

    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!;)
  • crowned_ruffian posted on 06/05/08 11:49:26 AM
    I'm new to cake, but I think the reason soft deletes broke when I upgraded to the newest version (1.2.0.7125) was because of the new query syntax described by gwoo:

    "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']))
    {
        $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';
    }
    to
    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';
    }
  • rtconner posted on 02/13/08 03:58:01 PM
    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!!!!!";
  • rtconner posted on 02/01/08 07:46:43 PM
    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)
  • mariano posted on 11/26/07 02:31:06 PM
    @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!
  • Divagater posted on 06/05/07 06:09:27 PM
    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
  • dho posted on 04/26/07 02:15:28 AM
    If I do something like $this->findAll('User.id = 1'); in my User model, this condition gets ignored when my model acts as SoftDelete.
  • garubi posted on 04/18/07 08:45:34 AM
    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
login to post a comment.