Creating model behaviors

There are many similar solutions in today's web applications. Leading products, such as Google's Gmail, are defining nice UI patterns. One of these is soft delete . Instead of a permanent deletion with tons of confirmations, Gmail allows the user to immediately mark messages as deleted and then easily undo it. The same behavior can be applied to any object, such as blog posts, comments, and so on.

Let's create a behavior that will allow marking models as deleted, restoring models, and selecting not yet deleted models, deleted models, and all models. In this recipe we'll follow a test-driven development approach to plan the behavior and test if the implementation is correct.

Getting ready

Carry out the following steps:

  1. Create a database and add a post table to your database:
    CREATE TABLE `post` (
      `id` int(11) NOT NULL auto_increment,
      `text` text,
      `title` varchar(255) default NULL,
      `is_deleted` tinyint(1) NOT NULL default '0',
      PRIMARY KEY  (`id`)
    )
  2. Configure Yii to use this database in your primary application (protected/config/main.php).
  3. Make sure the test application has the same settings (protected/config/test.php).
  4. Uncomment the fixture component in the test application settings.
  5. Use Gii to generate the Post model.

How to do it...

  1. Let's prepare a test environment, first starting with defining fixtures for the Post model in protected/tests/fixtures/post.php:
    <?php
    return array(
      array(
        'id' => 1,
        'title' => 'post1',
        'text' => 'post1',
        'is_deleted' => 0,
      ),
      array(
        'id' => 2,
        'title' => 'post2',
        'text' => 'post2',
        'is_deleted' => 1,
      ),
      array(
        'id' => 3,
        'title' => 'post3',
        'text' => 'post3',
        'is_deleted' => 0,
      ),
      array(
        'id' => 4,
        'title' => 'post4',
        'text' => 'post4',
        'is_deleted' => 1,
      ),
      array(
        'id' => 5,
        'title' => 'post5',
        'text' => 'post5',
        'is_deleted' => 0,
      ),
    );  
  2. We need to create a test case, protected/tests/unit/soft_delete/SoftDeleteBehaviorTest.php:
    <?php
    class SoftDeleteBehaviorTest extends CDbTestCase
    {
      protected $fixtures = array(
        'post' => 'Post',
      );
    
      function testRemoved()
      {
        $postCount = Post::model()->removed()->count();
        $this->assertEquals(2, $postCount);
      }
    
      function testNotRemoved()
      {
        $postCount = Post::model()->notRemoved()->count();
        $this->assertEquals(3, $postCount);
      }
      function testRemove()
      {
        $post = Post::model()->findByPk(1);
        $post->remove()->save();
    
        $this->assertNull(Post::model()->notRemoved()->findByPk(1));
      }
    
      function testRestore()
      {
        $post = Post::model()->findByPk(2);
        $post->restore()->save();
    
        $this->assertNotNull(Post::model()->notRemoved()->findByPk(2));
      }
    
      function testIsDeleted()
      {
        $post = Post::model()->findByPk(1);
        $this->assertFalse($post->isRemoved());
    
        $post = Post::model()->findByPk(2);
        $this->assertTrue($post->isRemoved());
      }
    }
  3. Now, we need to implement behavior, attach it to the model, and make sure the test passes. Create a new directory under protected/extensions named soft_delete. Under this directory create SoftDeleteBehavior.php. Let's attach the behavior to the Post model first:
    class Post extends CActiveRecord
    {
      // …
      
      public function behaviors()
      {
        return array(
          'softDelete' => array(
            'class' => 'ext.soft_delete.SoftDeleteBehavior'
          ),
        );
      }
    
      // …
  4. Now, let's implement protected/extensions/soft_delete/SoftDeleteBehavior.php:
    <?php
    class SoftDeleteBehavior extends CActiveRecordBehavior
    {
      public $flagField = 'is_deleted';
    
      public function remove()
      {
        $this->getOwner()->{$this->flagField} = 1;
        return $this->getOwner();
      }
    
      public function restore()
      {
        $this->getOwner()->{$this->flagField} = 0;
        return $this->getOwner();
      }
    
      public function notRemoved()
      {
        $criteria = $this->getOwner()->getDbCriteria();
        $criteria->compare($this->flagField, 0);
        return $this->getOwner();
      }
    
      public function removed()
      {
        $criteria = $this->getOwner()->getDbCriteria();
        $criteria->compare($this->flagField, 1);
        return $this->getOwner();
      }
    
      public function isRemoved()
      {
        return (boolean)$this->getOwner()->{$this->flagField};
      }
    }
  5. Run the test and make sure it passes.
  6. That's it. We've created reusable behavior and can use it for all future projects by just connecting it to a model.

How it works...

Let's start with the test case. Since we want to use a set of models, we are defining fixtures. Fixture set is put into the DB each time the test method is executed. To use fixtures, the test class should be inherited from CDbTestCase and have protected $fixtures declared:

protected $fixtures = array(
  'post' => 'Post',
);

In the preceding definition, post is the name of the file with fixture definitions and Post is the name of the model to which fixtures will be applied.

First, we are testing removed and notRemoved custom named scopes. We should limit the find result to removed items only, and then to non-removed items. Since we know which data we will get from fixtures, we can test to get a count of removed and non-removed items such as the following:

$postCount = Post::model()->removed()->count();
$this->assertEquals(2, $postCount);

Then we test the remove and restore methods. The following is the remove method test:

$post = Post::model()->findByPk(1);
$post->remove()->save();

$this->assertNull(Post::model()->notRemoved()->findByPk(1));

We are getting the item by id, removing it, and then trying to get it again using the notRemoved named scope. Since it's removed, we should get null as the result.

Finally, we are testing the isRemoved method that just returns the corresponding column value as Boolean.

Now, let's move to the interesting implementation details. Since we are implementing the Active Record model behavior, we need to extend from CActiveRecordBehavior. In behavior we can add our own methods that will be mixed into the model that behavior is attached to. We are using it to add the remove/restore/isRemoved methods and removed/notRemoved named scopes:

public function remove()
{
  $this->getOwner()->{$this->flagField} = 1;
  return $this->getOwner();
}
public function removed()
{
  $criteria = $this->getOwner()->getDbCriteria();
  $criteria->compare($this->flagField, 1);
  return $this->getOwner();
}

In both methods we are using the getOwner method to get the object the behavior is attached to. In our case it's a model, so we can work with its data or change its finder criteria. We are returning the model instance to allow chained method calls such as the following:

$post->remove()->save();

There's more...

There are a couple of things that should be mentioned in this recipe.

CActiveRecordBehavior and CModelBehavior

Sometimes we need to get some more flexibility in a behavior, such as reacting to model events. Both CActiveRecordBehavior and CModelBehavior are adding event-like methods we can override to handle model events. For example, if we need to handle cascade delete in a behavior, we can do it by overriding the afterDelete method.

More behavior types

Behavior can be attached not only to a model but also to any component. Each behavior inherits from the CBehavior class so we can use its methods as follows:

  • The getOwner method to get the component that the behavior is attached to
  • The getEnabled and setEnabled methods to check if behavior is enabled and set its state
  • The attach and detach methods can be correspondingly used to initialize behavior and clean up temporary data created during behavior usage

See also

  • The Making extensions distribution-ready recipe
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.141.244.153