In June 2014 on Habrahabr there was an article describing work with Active Record links in Yii2 . A month later, the article disappeared, but remained in numerous copies, for example here .

Like some other developers (see here and here ) I tried to understand the example given by the author of this article. And for several days now the feeling that something in this article is not enough leaves me.

I did not find a normal description of the approach (except for basting here ). I read the official manual before the holes, but the methods of recording related models in the database are described very sparingly , reading the documentation for Active Record also did not help much.

And nevertheless, I want to deal with this code, with this approach, to understand what possibilities are inherent in the framework so as not to fence the garden over the existing one.

Code from article

Model

class Post extends ActiveRecord { // Будем использовать транзакции при указанных сценариях public function transactions() { return [ self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE, ]; } public function getTags() { return $this->hasMany(Tag::className(), ['id' => 'tag_id']) ->viaTable('post_tag', ['post_id' => 'id']); } public function setTags($tags) { $this->populateRelation('tags', $tags); $this->tags_count = count($tags); } // Сеттер для получения тегов из строки, разделенных запятой public function setTagsString($value) { $tags = []; foreach (explode(',' $value) as $name) { $tag = new Tag(); $tag->name = $name; $tags[] = $tag; } $this->setTags($tags); } public function getCover() { return $this->hasOne(Image::className(), ['id' => 'cover_id']); } public function setCover($cover) { $this->populateRelation('cover', $cover); } public function getImages() { return $this->hasMany(Image::className(), ['post_id' => 'id']); } public function setImages($images) { $this->populateRelation('images', $images); if (!$this->isRelationPopulated('cover') && !$this->getCover()->one()) { $this->setCover(reset($images)); } } public function loadUploadedImages() { $images = []; foreach (UploadedFile::getInstances(new Image(), 'image') as $file) { $image = new Image(); $image->name = $file->name; $images[] = $image; } $this->setImages($images); } public function beforeSave($insert) { if (!parent::beforeSave($insert)) { return false; } // В beforeSave мы сохраняем связанные модели // которые нужно сохранить до основной, т.е. нужны их ИД // Не волнуйтесь о транзакции т.к. мы настроили, // она будет начата при вызове метода `insert()` и `update()` // Получаем все связанные модели, те что загружены или установлены $relatedRecords = $this->getRelatedRecords(); if (isset($relatedRecords['cover'])) { $this->link('cover', $relatedRecords['cover']); } return true; } public function afterSave($insert) { // В afterSave мы сохраняем связанные модели // которые нужно сохранять после основной модели, т.к. нужен ее ИД // Получаем все связанные модели, те что загружены или установлены $relatedRecords = $this->getRelatedRecords(); if (isset($relatedRecords['tags'])) { foreach ($relatedRecords['tags'] as $tag) { $this->link('tags', $tag); } } if (isset($relatedRecords['images'])) { foreach ($relatedRecords['images'] as $image) { $this->link('images', $image); } } } } 

Controller

 class PostController extends Controller { public function actionCreate() { $post = new Post(); if ($post->load(Yii::$app->request->post())) { // Сохраняем загруженные файлы $post->loadUploadedImages(); if ($post->save()) { return $this->redirect(['view', 'id' => $post->id]); } } return $this->render('create', [ 'post' => $post, ]); } } 

Code questions:

  1. How do setters work in this example? Where do the values ​​passed to setters come from ($ tags, $ cover, $ images ...)?
  2. At what point is the data written from related models (tags, images, main image) to the database?
  3. What is missing in this code to make it work?

Separately, I would like to ask for links to repositories of serious projects using Yii2. I'd like to look at the best practices in real complex projects.

  • Cited a fully working example of this code. The only thing that changed was the cover field in the table of posts for the is_cover flag in the table of images. Not good when tables have no foreign keys. - Andrey Kolomensky

2 answers 2

Overview

In the controller:

$post->load(Yii::$app->request->post() loads the model. Yii::$app->request->post() returns an array of the form ['MyFormName[key]' => 'value] . In the load for each property of the model with the name key , the value is set if they have validation rules and this field is written in the script.

In the model:

getTags is relaying. Serves for ActiveRecord model connections. setTags looks like a setter (a setter, this is a function called when accessing a non-existent property), but in this case it is just a function. It saves the associated model with populateRelation and increments the current $this->tags_count . Speaking about the problem of public properties, the counter can be increased directly, without calling the setTags method.


How do setters work in this example? Where do the values ​​passed to setters come from ($ tags, $ cover, $ images ...)?

setCover , setTags , setImages are called inside the model with the usual passing of parameters. I suppose they should be private and not called from client code (controller).

At what point is the data written from related models (tags, images, main image) to the database?

At the time of the call of the populateRelation in the model, the associated relaying is filled. Saving occurs in the controller at the moment of save model.

What is missing in this code to make it work?

Write it all over again, using this code as an example only. You do need experience, and this will save you from having to think about functions like setTagsString , which are not used anywhere and only confuse you. It will also be clear that for the getRelatedRecords method to work, you need to have the images and tags tables filled in.

I recommend taking this section of the documentation and go through each function in turn. This is a long time, I haven’t walked through everything myself, but this will allow you to get the most complete picture of the capabilities of the ActiveRecord framework without digging into the code of dubious quality.


Update: An example of the working code of this example.

Despite the fact that this is just a quick refactoring is ready in the codream and questions in the comments.

Migration:

 <?php use yii\db\Migration; class m151122_155133_create_tables extends Migration { public function up() { $tableOptions = null; if ($this->db->driverName === 'mysql') { // http://stackoverflow.com/questions/766809/whats-the-difference-between-utf8-general-ci-and-utf8-unicode-ci $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB'; } $this->createTable('{{%post}}', [ 'id' => $this->primaryKey(), 'message' => $this->text(), 'tags_count' => $this->integer(2)->notNull()->defaultValue(0) ], $tableOptions); $this->createTable('{{%tag}}', [ 'id' => $this->primaryKey(), 'name' => $this->string(32)->notNull(), 'UNIQUE INDEX `UNQ_tag__name` (`name`)', ], $tableOptions); $this->batchInsert('{{%tag}}', ['name'], [ ['tag1'], ['tag2'], ]); $this->createTable('{{%post_tag}}', [ 'id' => $this->primaryKey(), 'post_id' => $this->integer(11)->notNull(), 'tag_id' => $this->integer(11)->notNull(), 'FOREIGN KEY `FK_post_tag__post_id` (post_id) REFERENCES post(id) ON UPDATE RESTRICT ON DELETE RESTRICT', 'FOREIGN KEY `FK_post_tag__tag_id` (tag_id) REFERENCES tag(id) ON UPDATE RESTRICT ON DELETE RESTRICT', ], $tableOptions); $this->createTable('{{%post_image}}', [ 'id' => $this->primaryKey(), 'post_id' => $this->integer(11)->notNull(), 'image' => $this->string(128)->notNull(), 'is_cover' => $this->boolean()->defaultValue(0), 'FOREIGN KEY `FK_post_image__post_id` (post_id) REFERENCES post(id) ON UPDATE RESTRICT ON DELETE RESTRICT', ], $tableOptions); } public function down() { $this->dropTable('{{%post_image}}'); $this->dropTable('{{%post_tag}}'); $this->dropTable('{{%tag}}'); $this->dropTable('{{%post}}'); } } 

Controller:

 <?php namespace frontend\controllers; use frontend\models\CreatePostForm; use frontend\models\Post; use frontend\models\PostSearch; use Yii; use yii\base\Exception; use yii\web\Controller; use yii\web\NotFoundHttpException; /** * PostController implements the CRUD actions for Post model. */ class PostController extends Controller { /** * Lists all Post models. * * @return mixed */ public function actionIndex() { $searchModel = new PostSearch(); $dataProvider = $searchModel->search(Yii::$app->request->queryParams); return $this->render('index', [ 'searchModel' => $searchModel, 'dataProvider' => $dataProvider, ]); } /** * Displays a single Post model. * * @param integer $id * * @return mixed */ public function actionView($id) { return $this->render('view', [ 'model' => $this->findModel($id), ]); } /** * Creates a new Post model. * If creation is successful, the browser will be redirected to the 'view' page. * * @return mixed */ public function actionCreate() { $model = new CreatePostForm(); if ($model->load(Yii::$app->request->post()) && $model->validate()) { if (!$model->createNewPost()) { throw new Exception('Failed to save CreatePostForm'); } return $this->redirect(['view', 'id' => $model->id]); } return $this->render('create', [ 'model' => $model, ]); } /** * Finds the Post model based on its primary key value. * If the model is not found, a 404 HTTP exception will be thrown. * * @param integer $id * * @return Post the loaded model * @throws NotFoundHttpException if the model cannot be found */ protected function findModel($id) { if (($model = Post::findOne($id)) !== null) { return $model; } else { throw new NotFoundHttpException('The requested page does not exist.'); } } } 

View index.php

 <?php use yii\helpers\Html; use yii\grid\GridView; /* @var $this yii\web\View */ /* @var $searchModel frontend\models\PostSearch */ /* @var $dataProvider yii\data\ActiveDataProvider */ $this->title = 'Posts'; $this->params['breadcrumbs'][] = $this->title; ?> <div class="post-index"> <h1><?= Html::encode($this->title) ?></h1> <p> <?= Html::a('Create Post', ['create'], ['class' => 'btn btn-success']) ?> </p> <?= GridView::widget([ 'dataProvider' => $dataProvider, 'filterModel' => $searchModel, 'columns' => [ ['class' => 'yii\grid\SerialColumn'], 'id', 'message:ntext', ['class' => 'yii\grid\ActionColumn'], ], ]); ?> </div> 

View create.php

 <?php use yii\helpers\Html; use yii\widgets\ActiveForm; /* @var $this yii\web\View */ /* @var $model frontend\models\Post */ /* @var $form yii\widgets\ActiveForm */ $this->title = 'Create Post'; $this->params['breadcrumbs'][] = ['label' => 'Posts', 'url' => ['index']]; $this->params['breadcrumbs'][] = $this->title; ?> <div class="post-create"> <h1><?= Html::encode($this->title) ?></h1> <div class="post-form"> <?php $form = ActiveForm::begin(['options' => ['enctype' => 'multipart/form-data']]); ?> <?= $form->field($model, 'message')->textarea(['rows' => 6]) ?> <?= $form->field($model, 'tagString')->input('text') ?> <?= $form->field((new \frontend\models\PostImage()), 'image[]')->fileInput(['multiple' => true, 'accept' => 'image/*']) ?> <div class="form-group"> <?= Html::submitButton($model->isNewRecord ? 'Create' : 'Update', ['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']) ?> </div> <?php ActiveForm::end(); ?> </div> </div> 

View view.php

 <?php use yii\helpers\Html; use yii\widgets\DetailView; /* @var $this yii\web\View */ /* @var $model frontend\models\Post */ $this->title = $model->id; $this->params['breadcrumbs'][] = ['label' => 'Posts', 'url' => ['index']]; $this->params['breadcrumbs'][] = $this->title; ?> <div class="post-view"> <h1><?= Html::encode($this->title) ?></h1> <p> <?= Html::a('Update', ['update', 'id' => $model->id], ['class' => 'btn btn-primary']) ?> <?= Html::a('Delete', ['delete', 'id' => $model->id], [ 'class' => 'btn btn-danger', 'data' => [ 'confirm' => 'Are you sure you want to delete this item?', 'method' => 'post', ], ]) ?> </p> <?= DetailView::widget([ 'model' => $model, 'attributes' => [ 'id', 'message:ntext', ], ]) ?> </div> 

Model CreatePostForm

 <?php namespace frontend\models; use Yii; use yii\helpers\ArrayHelper; use yii\web\UploadedFile; class CreatePostForm extends Post { /** * Храним тут строку. Приватная потому что нам нужен сеттер, и мы за компанию прописываем геттер чтобы нельзя было писать напрямую в свойство, минуя сеттер. * * @var string */ private $_tagString; /** * Load отработает только для тех полей для которых прописаны правила валидации. * * @return array */ public function rules() { return ArrayHelper::merge([ ['tagString', 'string'] ], parent::rules()); } /** * Стараемся в контроллере не работать напрямую с ActiveRecord. * * @return bool */ public function createNewPost() { $this->loadUploadedImages(); return $this->save(); } /** * Просто получаем теги, которые ввел пользователь на форме * * @return string */ public function getTagString() { return $this->_tagString; } /** * Сохраняем картинки. Тут только запись названий в бд, запись файлов не производится. * * @see http://www.yiiframework.com/doc-2.0/guide-input-file-upload.html */ private function loadUploadedImages() { $images = []; foreach (UploadedFile::getInstances(new PostImage(), 'image') as $file) { $image = new PostImage(); $image->image = $file->name; $images[] = $image; } $this->setImages($images); } /** * Сохряняем связи картинок и для первой картинки устанавливаем флаг is_cover. Тут в примере только создание, но если бы было обновление вызов setCover бы * не произошел. * * @param PostImage[] $images */ private function setImages($images) { $this->populateRelation('images', $images); if (!$this->isRelationPopulated('cover') && !$this->getCover()->one()) { $this->setCover(reset($images)); } } /** * Сохраняем главную картинку поста. * * @param PostImage $cover */ private function setCover($cover) { $cover->is_cover = true; $this->populateRelation('cover', $cover); } /** * Записываем строчку тегов, полученную от пользователя в связанную таблицу. * * @param string $tagString */ public function setTagString($tagString) { $this->_tagString = $tagString; $this->saveTagsToRelation(); } /** * Сохраняем теги в связанной таблице и увеличиваем счетчик */ private function saveTagsToRelation() { $tags = []; /** * Пример с viaTable в релейшене Post::getTags подразумевал что теги только выбираются, но не создаются. */ foreach (explode(',', $this->_tagString) as $name) { $tag = Tag::find()->where(['name' => trim($name)])->one(); if (!$tag) { continue; } $tags[] = $tag; } $this->populateRelation('tags', $tags); $this->tags_count = count($tags); } } 

Post model

 <?php namespace frontend\models; use Yii; /** * This is the model class for table "post". * * @property integer $id * @property string $message * @property integer $tags_count * * @property PostImage $image * @property Tag[] $tags */ class Post extends \yii\db\ActiveRecord { /** * @inheritdoc */ public static function tableName() { return 'post'; } /** * @inheritdoc */ public function rules() { return [ ['tags_count', 'integer'], [['message'], 'string'], ]; } /** * @inheritdoc */ public function attributeLabels() { return [ 'id' => 'ID', 'message' => 'Message', ]; } /** * @inheritdoc */ public function transactions() { return [ self::SCENARIO_DEFAULT => self::OP_INSERT | self::OP_UPDATE, ]; } /** * @return \yii\db\ActiveQuery */ public function getCover() { return $this->hasOne(PostImage::className(), ['post_id' => 'id']) ->andWhere(['is_cover' => true]); } /** * @return \yii\db\ActiveQuery */ public function getImages() { return $this->hasMany(PostImage::className(), ['post_id' => 'id']); } /** * @return \yii\db\ActiveQuery */ public function getTags() { return $this->hasMany(Tag::className(), ['id' => 'tag_id']) ->viaTable('post_tag', ['post_id' => 'id']); } /** * В afterSave мы сохраняем связанные модели, которые нужно сохранять после основной модели, т.к. нужен ее ИД. * * @param bool $true * @param array $changedAttributes */ public function afterSave($true, $changedAttributes) { $relatedRecords = $this->getRelatedRecords(); if (isset($relatedRecords['cover'])) { $this->link('cover', $relatedRecords['cover']); } if (isset($relatedRecords['tags'])) { foreach ($relatedRecords['tags'] as $tag) { $this->link('tags', $tag); } } if (isset($relatedRecords['images'])) { foreach ($relatedRecords['images'] as $image) { $this->link('images', $image); } } } } 

PostImage model

 <?php namespace frontend\models; use Yii; /** * This is the model class for table "post_image". * * @property integer $id * @property integer $post_id * @property string $image * @property integer $is_cover * * @property Post $post */ class PostImage extends \yii\db\ActiveRecord { /** * @inheritdoc */ public static function tableName() { return 'post_image'; } /** * @inheritdoc */ public function rules() { return [ [['post_id', 'image'], 'required'], [['post_id', 'is_cover'], 'integer'], [['image'], 'string', 'max' => 128], [['post_id'], 'unique'], [['post_id'], 'exist', 'skipOnError' => true, 'targetClass' => Post::className(), 'targetAttribute' => ['post_id' => 'id']], ]; } /** * @inheritdoc */ public function attributeLabels() { return [ 'id' => 'ID', 'post_id' => 'Post ID', 'image' => 'Image', 'is_cover' => 'Is Cover', ]; } /** * @return \yii\db\ActiveQuery */ public function getPost() { return $this->hasOne(Post::className(), ['id' => 'post_id']); } } 

PostTag model

 <?php namespace frontend\models; use Yii; /** * This is the model class for table "post_tag". * * @property integer $id * @property integer $post_id * @property integer $tag_id * * @property Post $post * @property Tag $post0 */ class PostTag extends \yii\db\ActiveRecord { /** * @inheritdoc */ public static function tableName() { return 'post_tag'; } /** * @inheritdoc */ public function rules() { return [ [['post_id', 'tag_id'], 'required'], [['post_id', 'tag_id'], 'integer'], [['post_id'], 'exist', 'skipOnError' => true, 'targetClass' => Post::className(), 'targetAttribute' => ['post_id' => 'id']], [['tag_id'], 'exist', 'skipOnError' => true, 'targetClass' => Tag::className(), 'targetAttribute' => ['tag_id' => 'id']], ]; } /** * @inheritdoc */ public function attributeLabels() { return [ 'id' => 'ID', 'post_id' => 'Post ID', 'tag_id' => 'Tag ID', ]; } /** * @return \yii\db\ActiveQuery */ public function getPost() { return $this->hasOne(Post::className(), ['id' => 'post_id']); } /** * @return \yii\db\ActiveQuery */ public function getPost0() { return $this->hasOne(Tag::className(), ['id' => 'post_id']); } } 

Tag Model

 <?php namespace frontend\models; use Yii; /** * This is the model class for table "tag". * * @property integer $id * @property string $name * * @property PostTag[] $postTags */ class Tag extends \yii\db\ActiveRecord { /** * @inheritdoc */ public static function tableName() { return 'tag'; } /** * @inheritdoc */ public function rules() { return [ [['name'], 'required'], [['name'], 'string', 'max' => 32], [['name'], 'unique'], ]; } /** * @inheritdoc */ public function attributeLabels() { return [ 'id' => 'ID', 'name' => 'Name', ]; } /** * @return \yii\db\ActiveQuery */ public function getPostTags() { return $this->hasMany(PostTag::className(), ['post_id' => 'id']); } } 

PostSearch Model

 <?php namespace frontend\models; use Yii; use yii\base\Model; use yii\data\ActiveDataProvider; use frontend\models\Post; /** * PostSearch represents the model behind the search form about `frontend\models\Post`. */ class PostSearch extends Post { /** * @inheritdoc */ public function rules() { return [ [['id'], 'integer'], [['message'], 'safe'], ]; } /** * @inheritdoc */ public function scenarios() { // bypass scenarios() implementation in the parent class return Model::scenarios(); } /** * Creates data provider instance with search query applied * * @param array $params * * @return ActiveDataProvider */ public function search($params) { $query = Post::find(); // add conditions that should always apply here $dataProvider = new ActiveDataProvider([ 'query' => $query, ]); $this->load($params); if (!$this->validate()) { // uncomment the following line if you do not want to return any records when validation fails // $query->where('0=1'); return $dataProvider; } // grid filtering conditions $query->andFilterWhere([ 'id' => $this->id, ]); $query->andFilterWhere(['like', 'message', $this->message]); return $dataProvider; } } 
  • Thanks for the detailed answer. I delve into it. Doesn't it make it difficult for you to remove generated Gii and not very useful comments like @inheritdoc? Distract from the essence of the answer. - Denis Khvorostin
  • Thoughts about (while a bit far from the essence of the question, but nonetheless). The PostTag model is not needed. And the PostImage model explicitly asks to be renamed. Plus potentially: images can refer not only to articles, but one image can be found in several articles. Those. many-to-many communication. - Denis Khvorostin
  • @DenisKhvorostin The PostTag table PostTag is to use Tag tags not only for posts, but also for other entities. In the framework of this example, it is really redundant, but without it it would be impossible to set an example of how viaTable works. Postimage can also be made reusable for other entities, although personally I would wait for duplication to occur. Avatars, photos and pictures for posts, though Images , but storing and working with them will most likely be conducted in different ways. A little later I will clean the answer from the empty models. - Andrey Kolomensky
  • one
    Do not forget in the afterSave method to make a call to the method of the inherited class, as indicated in the documentation> When overriding this method, make sure that the event is triggered. `public function afterSave ($ insert, $ changedAttributes) {parent :: afterSave ($ insert, $ changedAttributes); // ... here is your code} ' - Vladimir Jucov

How do setters work in this example? Where do the values ​​passed to setters come from ($ tags, $ cover, $ images ...)?

This is php magic. In the base class, the __get and __set methods are redefined, which convert the reading and writing of the attributes $foo->bar = $baz->qwe to calls of the methods $foo->setBar($baz->getQwe()) . If you don't like magic, you can call methods directly.

In fact, these are banal getters and setters. Some languages ​​have properties (getters and setters combined into a single entity), but PHP does not support them.

At what point is the data written from related models (tags, images, main image) to the database?

When the save method is called on ActiveRecord . At this point, the data is validated and, if validation passes, then the data of this ActiveRecord and all related data are recorded.

What is missing in this code to make it work?

This question cannot be answered until you say what exactly does not work.

You can see the comments, there is a bug: https://sohabr.net/habr/post/226103/

Apparently, this article did not enjoy popularity, so I do not understand the desire to rely on it when learning.

Separately, I would like to ask for links to repositories of serious projects using Yii2. I'd like to look at the best practices in real complex projects.

There is an example yii2-shop from one of the authors of the framework.

  • I see in this example what I would like to see in my project (there are also several related models and I want to minimize the code in the controllers). I understand the magic of setters and getters in PHP; I don’t understand how it works here. After all, a setter needs to be addressed somewhere in order for the “magic” to work. - Denis Khvorostin
  • @DenisKhvorostin Well, you have the $post variable, you can read and change properties with it just using these magic methods. - Athari
  • Ok I understand it. Here I look at the controller and see how the data from \ Yii :: $ app-> request-> post () ['Post'] is loaded into this same $ post. It's all clear. But I am not sure that this is enough for the related models to be filled too. Particularly interesting in this regard is the setTagsString () method, which is like a setter, but not even a setter. At least in my case, the related models are not filled. $ this-> getRelatedRecords () in beforeSeve () and afterSave () do not return anything, in fact, magic does not happen. - Denis Khvorostin
  • @DenisKhvorostin Try to be like a debugger, check if setTagsString works, etc. - Athari