Alex Zhang’s Blog

Please call me X.

Yii 2.0 ActiveRecord 详解

The ActiveRecord class in Yii provides an object oriented interface (aka ORM) for accessing database stored data. Similar structures can be found in most modern frameworks like Laravel, CodeIgniter, Smyfony and Ruby. Today, we’ll go over the implementation in Yii 2.0 and I’ll show you some of the more advanced features of it.

数据库网络

Model class intro

The Yii ActiveRecord is an advanced version of the base yii\base\Modelwhich is the foundation of the Model-View-Controller architecture. I’ll quickly explain the most important functionality that ActiveRecord inherits from the Model class:

Attributes

The business data is held in attributes. These are publicly available properties of the model instance. All the attributes can conveniently be assigned massively by assigning any array to the attributes property of a model. This works because the base Component class (the base of almost everything in Yii 2.0) implements the __set() method which in turn calls the setAttributes() method in the Model class. The same goes for retrieving; all attributes can be retrieved by getting the attributes property. Again, built upon the Component class which implements __get() which calls the getAttributes() in the Model class. Models also supply attribute labels which are used for display purposes which makes using them in forms on pages easier.

Validation

Data passed in the model from user input should be checked to see that they satisfy your business logic. This is done by specifying rules() which would normally hold one or more validators for each attribute. By default, only attributes which are considered ‘safe’, meaning they have a validation rule defined for them, can be assigned massively.

Scenarios

The scenarios allow you to to define different ‘scenarios’ in which you’ll use a model allowing you to change the way it validates and handles its data. The example in the documentation describes how you can use it in a FormModel (which also extends the Model class) by specifying different validation rules for both user registration and login simply by defining two different scenarios in one Model.

Creating an ActiveRecord model

An ActiveRecord instance represents a row in a database table, therefore we need a database. In this article I’ll use the database design in the picture below as as an example. It’s a simple structure for blog articles. Authors can have multiple articles which can have multiple tags. The articles are related through an N:M relation to the tags because we want to be able to show related articles based on the tags. The ‘articles’ table will get our focus because it has the most interesting set of relations.

数据库结构图

I’ve used the Gii extension to generate the model based on the table and it’s relations. Here’s what is generated for the articles table from the database structure just by clicking a few buttons:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
namespace app\models;

use Yii;

/**
* This is the model class for table "articles".
*
* @property integer $ID
* @property integer $AuthorsID
* @property string $LastEdited
* @property string $Published
* @property string $Title
* @property string $Description
* @property string $Content
* @property string $Format
*
* @property Authors $authors
* @property Articlestags[] $articlestags
*/
class Articles extends \yii\db\ActiveRecord
{
    /**
    * @inheritdoc
    */
    public static function tableName()
    {
        return 'articles';
    }

    /**
    * @inheritdoc
    */
    public function rules()
    {
        return [
            [['AuthorsID'], 'integer'],
            [['LastEdited', 'Title', 'Description', 'Content'], 'required'],
            [['LastEdited', 'Published'], 'safe'],
            [['Description', 'Content'], 'string'],
            [['Format'], 'in', 'range' => ['MD', 'HTML']],
            [['Title'], 'string', 'max' => 250],
        ];
    }

    /**
    * @inheritdoc
    */
    public function attributeLabels()
    {
        return [
            'ID' => Yii::t('app', 'ID'),
            'AuthorsID' => Yii::t('app', 'Authors ID'),
            'LastEdited' => Yii::t('app', 'Last Edited'),
            'Published' => Yii::t('app', 'Published'),
            'Title' => Yii::t('app', 'Title'),
            'Description' => Yii::t('app', 'Description'),
            'Content' => Yii::t('app', 'Content'),
            'Format' => Yii::t('app', 'Format'),
        ];
    }

    /**
    * @return \yii\db\ActiveQuery
    */
    public function getAuthors()
    {
        return $this->hasOne(Authors::className(), ['ID' => 'AuthorsID']);
    }

    /**
    * @return \yii\db\ActiveQuery
    */
    public function getArticlestags()
    {
        return $this->hasMany(Articlestags::className(), ['ArticlesID' => 'ID']);
    }
}

The properties listed in the comment before the class definition show which attributes are available on every instance. It is good to notice that because of the relation definitions (defined in this class), you also get properties for the related data; one Authors $authors and multiple Articlestags[] $articlestags.

The tableName() function defines which database table is related to this Model. This allows a decoupling of the class name from the actual table name.

The rules() define the validation rules for the model. There are no scenarios defined so there is only one set of rules. It’s quite readable; showing which fields are required and which require an integer or string. There are quite a few core validators available which suit most people’s needs.

The attributeLabels() function supplies labels to be shown for each attribute should it be used in views. I chose to make mine i18n compatible from Gii which added all the calls to Yii::t(). This basically means that translation of the labels, which end up in the rendered pages, will be much easier later on should we need it. Finally, the getAuthors() and getArticlestags() functions define the relation of this table to other tables.

Note: I was quite surprised to find the ‘Format’ attribute was missing completely from the properties, validators and labels. Turns out that Gii doesn’t support ENUMs. Besides MySQL (and its forks) only PostgreSQL supports it and therefore it wasn’t implemented. I had to add them manually.

Completing the model

You can probably see that the generated Articles class only has relations defined for the foreign key tables. The N:M relation from Articles to Tags won’t be generated automatically so we’ll have to define that by hand.

The relations are all returned as instances of yii\db\ActiveQuery. To define a relation between Articles and Tags we’ll need to use the ArticlesTags as a junction table. In ActiveQuery, this is done by defining a via table. ActiveQuery has two methods you can use for this:

  • via() allows you to use an already defined relation in the class to define the relation.
  • viaTable() alternatively allows you to define a table to use for a relation.

The via() method allows you to use an already defined relation as via table. In our example, however, the ArticlesTags table holds no information that we care for so I’ll use the viaTable() method.

1
2
3
4
5
6
7
8
/**
* @return \yii\db\ActiveQuery
*/
public function getTags()
{
    return $this->hasMany(Tags::className(), ['ID' => 'TagsID'])
                ->viaTable(Articlestags::tableName(), ['ArticlesID' => 'ID']);
}

Now that we’ve defined all the relations, we can start using the model.

Using the model

I’ll quickly create some database content by hand.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$phpTag = new \app\models\Tags();       //Create a new tag 'PHP'
$phpTag->Tag = 'PHP';
$phpTag->save();

$yiiTag = new \app\models\Tags();       //Create a new tag 'Yii'
$yiiTag->Tag = 'Yii';
$yiiTag->save();

$author = new \app\models\Authors();    //Create a new author
$author->Name = 'Arno Slatius';
$author->save();

$article = new \app\models\Articles();  //Create an article and link it to the author
$article->AuthorsID = $author->ID;
$article->Title = 'Yii 2.0 ActiveRecord';
$article->Description = 'Arno Slatius dives into the Yii ActiveRecord class';
$article->Content = '... the article ...';
$article->LastEdited = new \yii\db\Expression('NOW()');
$article->save();

$tagArticle = new \app\models\ArticlesTags();   //Link the 'PHP' tag to the article
$tagArticle->ArticlesID = $article->ID;
$tagArticle->TagsID = $phpTag->ID;
$tagArticle->save();

$tagArticle = new \app\models\ArticlesTags();   //Link the 'Yii' tag to the article
$tagArticle->ArticlesID = $article->ID;
$tagArticle->TagsID = $yiiTag->ID;
$tagArticle->save();

It should be noted that the code above has some assumptions which I’ll explain;

  • The result of save(), a boolean, isn’t evaluated. This isn’t wise normally, because Yii will actually call validate() on the class before actually saving it in the database. The database INSERT won’t be executed should any of the validation rules fail.
  • You might notice that the ID attributes of the various instances are used while they are not set. This can be done safely because the save() call will INSERT the data and get assigned the primary key back from the database and make the ID property value valid.
  • The $article->LastEdited is a DateTime value in the database. I want to insert the current datetime by calling the NOW() SQL function on it. You can do this by using the Expression class which allows the usage of various SQL expressions with ActiveRecord instances.

You can then retrieve the article again from the database;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Look up our latest article
$article = \app\models\Articles::findOne(['Title'=>'Yii 2.0 ActiveRecord']);

//Show the title
echo $article->Title;

//The related author, there is none or one because of the hasOne relation
if (isset($article->authors)) {
    echo $article->authors->name
}

//The related tags, always an array because of the hasMany relations
if (isset($article->tags)) {
    foreach($article->tags as $tag) {
        echo $tag->Tag;
    }
}

New and advanced usages

The Yii ActiveRecord, as I’ve described it so far, is straight forward. Let’s make it interesting and go into the new and changed functionality in Yii 2.0 a bit more.

Dirty attributes

Yii 2.0 introduced the ability to detect changed attributes. For ActiveRecord, these are called dirty attributes because they require a database update. This ability now by default allows you to see which attributes changed in a model and to act on that. When, for example, you’ve massively assigned all the attributes from a form POST you might want to get only the changed attributes:

1
2
3
4
5
6
7
8
9
10
11
12
13
//Get a attribute => value array for all changed values
$changedAttributes = $model->getDirtyAttributes();

//Boolean whether the specific attribute changed
$model->isAttributeChanged('someAttribute');

//Force an attribute to be marked as dirty to make sure the record is
// updated in the database
$model->markAttributeDirty('someAttribute');

//Get on or all old attributes
$model->getOldAttribute('someAttribute');
$model->getOldAttributes();

Arrayable

The ActiveRecord, being extended from Model, now implements the \yii\base\Arrayable trait with it’s toArray() method. This allows you to convert the model with attributes to an array quickly. It also allows for some nice additions.

Normally a call to toArray() would call the fields() function and convert those to an array. The optional expand parameter of toArray() will additionally call extraFields() which dictates which fields will also be included.

These two fields methods are implemented by BaseActiveRecord and you can implement them in your own model to customize the output of the toArray() call.

I’d like, in my example, to have the extended array contain all the tags of an article available as a comma separated string in my array output as well;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function extraFields()
{
    return [
        'tags'=>function() {
            if (isset($this->tags)) {
                $tags = [];
                foreach($this->tags as $articletag) {
                    $tags[] = $articletag->Tag;
                }
                return implode(', ', $tags);
            }
        }
    ];
}

And then get an array of all the fields and this extra field from the model;

1
2
//Returns all the attributes and the extra tags field
$article->toArray([], ['tags']);

Events

Yii 1.1 already implemented various events on the CActiveRecord and they’re still there in Yii 2.0. The ActiveRecord life cycle in the Yii 2.0 guide shows very nicely how all these events are fired when using an ActiveRecord. All the events are fired surrounding the normal actions of your ActiveRecord instance. The naming of the events is quite obvious so you should be able to figure out when they are fired; afterFind(), beforeValidate(), afterValidate(), beforeSave(), afterSave(), beforeDelete(), afterDelete().

In my example, the LastEdited attribute is a nice way to demonstrate the use of an event. I want to make sure LastEdited always reflects the last time the article was edited. I could set this on two events; beforeSave() and beforeValidate(). My model rules define LastEdited as a required attribute so we need to use the beforeValidate() event to make sure it is also set on new instances of the model;

1
2
3
4
5
6
7
8
public function beforeValidate($insert)
{
    if (parent::beforeValidate($insert)) {
        $this->LastEdited = new \yii\db\Expression('NOW()');
        return true;
    }
    return false;
}

Note that with all of these events, you should call the parent event handler. Returning false (or nothing!) from a before event in these functions stops the action from happening.

Behavior

Behavior can be used to enhance the functionality of an existing component without modifying its code. It can also respond to the events in the component that it was attached to. They behave similar to the traits introduced in PHP 5.4. Yii 2.0 comes with a number of available behaviors;

  • yii\behaviors\AttributeBehavior allows you to specify attributes which need to be updated on a specified event. You can, for example, set an attribute to a value based on an unnamed function on a BEFORE_INSERT event.

  • yii\behaviors\BlameableBehavior does what you’d expect; blame someone. You can set two attributes; a createdByAttribute and updatedByAttribute which will be set to the current user ID when the object is created or updated.

  • yii\behaviors\SluggableBehavior allows you to automatically create a URL slug based on one of the attributes to another attribute in the model.

  • yii\behaviors\TimestampBehavior will allow you to automatically create and update the time stamp in a createdAtAttribute and updatedAtAttribute in your model.

You can probably see that these have some practical applications in my example as well. Assuming that the person currently logged in to the application is the actual author of an article, I could use the BlameableBehavior to make them the author and I can also use the TimestampBehaviour to make sure the LastEdited attribute stays up to date. This would replace my previous implementation of the beforeValidate() event in my model. This is how I attached the behaviors to my Articles model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function behaviors()
{
    return [
        [
            'class' => \yii\behaviors\BlameableBehavior::className(),
            'createdByAttribute' => 'AuthorID',
            'updatedByAttribute' => 'AuthorID',
            ],
        [
            'class' => \yii\behaviors\TimestampBehavior::className(),
            'createdAtAttribute' => false,    //or 'LastEdited'
            'updatedAtAttribute' => 'LastEdited',
            'value' => new \yii\db\Expression\Expression('NOW()'),
        ],
    ];
}

I assume here of course that the creator and the editor of the article is the same person. Since I don’t have a created timestamp field, I chose not to use it by setting the createdAtAttribute to false. I could of course also set this to ‘LastEdited’.

Transactional operations

The last feature I want to touch is the possibility to automatically force the usage of transactions in a model. With the enforcement of foreign keys also comes the possibility for database queries to fail because of that. This can be handled more gracefully by wrapping them in a transaction. Yii allows you to specify operations that should be transactional by implementing a transactions() function in your model that specifies which operations in which scenarios should be enclosed in a transaction. Note that you should return a rule for the SCENARIO_DEFAULT if you want this to be done by default on operations.

1
2
3
4
5
6
7
8
9
public function transactions()
{
    return [
        //always enclose updates in a transaction
        \yii\base\Model::SCENARIO_DEFAULT => self::OP_UPDATE,       
        //include all operations in a transaction for the 'editor' scenario
        'editor' => self::OP_INSERT | self::OP_UPDATE | self::OP_DELETE,
    ];
}

Conclusion

The Yii ActiveRecord class already made ORM handling very simple, Yii 2.0 builds upon this great base and extends it even further. The flexibility is huge due to the possibility to define different usage scenarios, attach behaviors and use events.

These are some features in the ActiveRecord that I’ve found most useful over time and most welcome with the arrival of Yii 2.0. Did you miss a feature of ActiveRecord, or perhaps feel that Yii ActiveRecord is missing a great feature from another framework? Please let us know in the comments!

摘自: SitePoint

作者: Arno Slatius