Building a Behavior with CakePHP
I’ve been meaning to create a deleted_at
behavior, and today we’ll go over that.
Creating Plugin Scaffolding
I normally place non-application code in a plugin. Most extensions to your core logic - behaviors, components, helpers - fall into this category. You can normally tell if it is pluginizable if you can imagine reusing the logic within the context of a CMS and an Issue Tracker :)
Lets create the followin directory structure:
cd path/to/app
mkdir -p app/Plugin/DeletedAt/Model/Behavior
Next, we’ll initialize our plugin as a git repository. We’re doing this with the aim of having the plugin within hosted Packagist:
cd app/Plugin/DeletedAt
touch Model/Behavior/empty
git init
git add Model/Behavior/empty
git commit -m "Initial commit"
git push origin master
The above assumes you created a repository on github to push your code to. Github is where most CakePHP code exists, and it would be beneficial to the community to continue to use a single type of version control+code repository. Obviously, you can and should change this according to your needs.
And now we’ll make this a FriendsOfCake-approved plugin using the steps from the first CakeAdvent post:
cd path/to/app
git clone git@github.com:FriendsOfCake/travis.git vendor/travis
export COPYRIGHT_YEAR=2013
export GITHUB_USERNAME="josegonzalez"
export PLUGIN_PATH="Plugin/DeletedAt"
export PLUGIN_NAME="DeletedAt"
export REPO_NAME="cakephp-deleted-at"
export YOUR_NAME="Jose Diaz-Gonzalez"
./vendor/travis/setup.sh
rm -rf vendor/travis
cd Plugin/DeletedAt
git add .
git commit -m "FriendsOfCake support"
git push origin master
At this point, you should be able to enable support for the plugin within TravisCI, Packagist, and Coveralls.
Creating a simple Behavior
We’ll first need to create the proper files. We will have both a DeletedAtBehavior.php
and a DeletedAtBehaviorTest.php
. Lets do that:
cd app/Plugin/DeletedAt
mkdir -p Test/Case/Model/Behavior
touch Model/Behavior/DeletedAtBehavior.php
touch Test/Case/Model/Behavior/DeletedAtBehaviorTest.php
The initial contents of each are pretty simple:
<?php
App::uses('ModelBehavior', 'Model');
class DeletedAtBehavior extends ModelBehavior {
}
?>
<?php
App::uses('Model', 'Model');
App::uses('AppModel', 'Model');
require_once CAKE . 'Test' . DS . 'CASE' . DS . 'Model' . DS . 'models.php';
class DeletedAtBehaviorTest extends CakeTestCase {
}
?>
Finally, lets enable our plugin so that we can run tests. Add the following to your bootstrap.php
:
<?php
CakePlugin::load('DeletedAt');
?>
Now lets run tests!
cd path/to/app
Console/cake test DeletedAt AllDeletedAt --stderr
You should see exactly 1 failure. We have no tests! But this is good. We now have a barebones behavior, tests that properly fail, and a goal in mind: fully passing tests for our new DeletedAt
behavior.
Commit your changes and read the next section.
Writing tests
For our behavior, we want to be able to:
- Mark records as
deleted_at
with a timestamp - Un-delete records
We’ll store this state within a deleted_at
field on the record. It will be of type datetime
, and if it is null, then the record is not deleted, otherwise we know when it was soft-deleted.
We’ll need a fixture to represent our test model. We should create it using the following:
cd app/Plugin/DeletedAt
mkdir -p Test/Fixture
touch Test/Fixture/DeletedUserFixture.php
Fixture classes are used to mock out test schemas in the database. They are useful for testing both real-world cases - using the database schema of your production tables - as well as for test-scenarios - as we will use for our plugin.
Fixture classes require two class attributes: $fields
and $records
. The $fields
attribute is used to define the schema for the mocked out table. The $records
attribute is an array of records to insert into your database. The $records
attribute should have values specified for each field in $fields
, otherwise the behavior would be unknown. We’ll use the following for our fixture:
<?php
App::uses('CakeTestFixture', 'TestSuite/Fixture');
class DeletedUserFixture extends CakeTestFixture {
public $fields = array(
'id' => array('type' => 'integer', 'key' => 'primary'),
'user' => array('type' => 'string', 'null' => true),
'password' => array('type' => 'string', 'null' => true),
'created' => 'datetime',
'updated' => 'datetime',
'deleted' => array('type' => 'datetime', 'null' => true),
);
public $records = array(
array('user' => 'mariano', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31', 'deleted' => '2007-03-18 10:45:31'),
array('user' => 'nate', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:18:23', 'updated' => '2007-03-17 01:20:31', 'deleted' => null),
array('user' => 'larry', 'password' => '5f4dcc3b5aa765d61d8327deb882cf99', 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31', 'deleted' => null),
);
}
?>
Now lets write a test just for our sanity. We need to prepare our test class with the following:
- A
$fixtures
property to notify PHPUnit as to what fixtures to load for our tests - A
setUp()
method to execute before each test. We’ll setup our model here. - A
tearDown()
method to execute after each test. We’ll destroy our model here to ensure the next test case has a clean environment.
I’ve taken the liberty of writing these for you, and you can copy the following into your DeletedAtBehavior
test file:
<?php
public $fixtures = array(
'plugin.deleted_at.deleted_user'
);
public function setUp() {
parent::setUp();
$this->DeletedUser = ClassRegistry::init('User');
$this->DeletedUser->useTable = 'deleted_users';
$this->DeletedUser->Behaviors->load('DeletedAt.DeletedAt');
}
public function tearDown() {
unset($this->DeletedUser);
parent::tearDown();
}
?>
Now lets add a test. We’ll find all deleted
and non-deleted
records:
<?php
public function testFindDeleted() {
$records = $this->DeletedUser->find('all', array(
'conditions' => array('deleted <>' => null)
));
$this->assertEqual(1, count($records));
}
public function testFindNonDeleted() {
$records = $this->DeletedUser->find('all', array(
'conditions' => array('deleted' => null)
));
$this->assertEqual(2, count($records));
}
?>
Running Console/cake test DeletedAt AllDeletedAt --stderr
should give you a single passing test! Yay! Now lets write some real model code.
Custom Finds
To simplify our logic, we will not be overriding the build-in Model::delete()
method. Instead, we’ll do the following:
- Add a custom finder to find deleted and non-deleted records
- Add a custom method to softdelete and un-softdelete records
Here is some code to handle custom finds in a behavior. It comes from my earlier post on embedding custom finds within behaviors, with relevant updates for 2.x.
<?php
public $mapMethods = array(
'/findDeleted/' => 'findDeleted',
'/findNon_deleted/' => 'findNonDeleted',
);
public function setup(Model $model, $config = array()) {
$model->_findMethods['deleted'] = true;
$model->_findMethods['non_deleted'] = true;
}
public function findDeleted(&$model, $functionCall, $state, $query, $results = array()) {
if ($state == 'before') {
if (empty($query['conditions'])) {
$query['conditions'] = array();
}
$query['conditions']["{$model->alias}.deleted <>"] = null;
return $query;
}
return $results;
}
public function findNonDeleted(&$model, $functionCall, $state, $query, $results = array()) {
if ($state == 'before') {
if (empty($query['conditions'])) {
$query['conditions'] = array();
}
$query['conditions']["{$model->alias}.deleted"] = null;
return $query;
}
return $results;
}
?>
Now that we have our custom finds in place, let’s modify our tests to use them:
<?php
public function testFindDeleted() {
$records = $this->DeletedUser->find('deleted');
$this->assertEqual(1, count($records));
}
public function testFindNonDeleted() {
$records = $this->DeletedUser->find('non_deleted');
$this->assertEqual(2, count($records));
}
?>
Running Console/cake test DeletedAt AllDeletedAt --stderr
should give us two passing tests!
Deleting records
Now we’ll add two custom methods. Create the following tests:
<?php
public function testSoftdelete() {
$this->DeletedUser->softdelete(1);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(1, count($deleted));
$this->assertEqual(2, count($nonDeleted));
$this->DeletedUser->softdelete(2);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(2, count($deleted));
$this->assertEqual(1, count($nonDeleted));
$this->DeletedUser->softdelete(3);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(3, count($deleted));
$this->assertEqual(0, count($nonDeleted));
}
public function testUnDelete() {
$this->DeletedUser->undelete(3);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(1, count($deleted));
$this->assertEqual(2, count($nonDeleted));
$this->DeletedUser->undelete(2);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(1, count($deleted));
$this->assertEqual(2, count($nonDeleted));
$this->DeletedUser->undelete(1);
$deleted = $this->DeletedUser->find('deleted');
$nonDeleted = $this->DeletedUser->find('non_deleted');
$this->assertEqual(0, count($deleted));
$this->assertEqual(3, count($nonDeleted));
}
?>
Running tests now should give you two successes - our previous tests - and two failures - the new tests. The new tests fail because CakePHP will map undelete
and softdelete
to database methods if they don’t exist - which is useful in some cases, but in our case, we’ll implement the methods.
The logic for these methods is below. Feel free to extend them to your hearts content:
<?php
public function softdelete(Model $model, $id = null) {
if ($id) {
$model->id = $id;
}
if (!$model->id) {
return false;
}
$deleteCol = 'deleted';
if (!$model->hasField($deleteCol)) {
return false;
}
$db = $model->getDataSource();
$now = time();
$default = array('formatter' => 'date');
$colType = array_merge($default, $db->columns[$model->getColumnType($deleteCol)]);
$time = $now;
if (array_key_exists('format', $colType)) {
$time = call_user_func($colType['formatter'], $colType['format']);
}
if (!empty($model->whitelist)) {
$model->whitelist[] = $deleteCol;
}
$model->set($deleteCol, $time);
return $model->saveField($deleteCol, $time);
}
public function undelete(Model $model, $id = null) {
if ($id) {
$model->id = $id;
}
if (!$model->id) {
return false;
}
$deleteCol = 'deleted';
if (!$model->hasField($deleteCol)) {
return false;
}
$model->set($deleteCol, null);
return $model->saveField($deleteCol, null);
}
?>
Now lets run tests using Console/cake test DeletedAt AllDeletedAt --stderr
. You should get the following output:
Commit your changes and push to github. We’re done!
Going Further
Any of the following things would be cool to see:
- Moving the softdeletion code to
Model::delete()
and having two consecutivedelete()
calls actually delete the record - Configuration for the
deleted
column. - Tracking deletion state over time within a different table.
Of course, you are free to continue with this plugin as you wish! Hopefully the above post clarified some things regarding writing testable CakePHP code, creating plugins, and using/abusing Behaviors within CakePHP.