Как работать со связями Active Record в Yii2 (populateRelation, link и т.п.)?

1,00
р.
В июне 2014 года на Хабрахабре была статья с описанием работы с связями Active Record в Yii2. Через месяц статья исчезла, но осталась в многочисленных копиях, например здесь.
Как и некоторые другие разработчики, (см. здесь и здесь) я попытался разобраться в примере, который приводит автор этой статьи. И вот уже несколько дней меня не покидает ощущение, что чего-то в той статье не хватает.
Нормального описания подхода я так и не нашел (разве что наметки здесь). Зачитал официальное руководство до дыр, но приемы записи связанны моделей в БД описаны очень скупо, чтение документации по Active Record тоже не особо помогло.
И все-таки хочется разобраться с этим кодом, с этим подходом, понять, какие возможности заложены в фреймворк, чтобы не городить огород поверх имеющегося.
Код из статьи
Модель
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) } } } }
Контроллер
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, ]) } }
Вопросы по коду:
Как работают сеттеры в этом примере? Откуда берутся значения, передаваемые сеттерам ($tags, $cover, $images...)? В какой момент пишутся данные из связанных моделей (теги, изображения, главное изображение) в базу данных? Чего не хватает в этом коде, чтобы он заработал?
Отдельно хотелось бы попросить ссылок на репозитории серьезных проектов, использующих Yii2. Очень хочется посмотреть на лучшие практики в реальных сложных проектах.

Ответ
Overview
В контроллере:
$post->load(Yii::$app->request->post() выполняет загрузку модели. Yii::$app->request->post() возвращает массив, вида ['MyFormName[key]' => 'value]. В load для каждого свойства модели с именем key устанавливается значение, если для них существуют правила валидации rules и данное поле прописано в сценарии.
В модели:
getTags это релейшен. Служит для связей ActiveRecord моделей. setTags хоть и выглядит как сеттер (сеттер, это функция вызываемая при обращении к несуществующему свойству), но в данном случае это просто функция. Она сохраняет связанную модель с помощью populateRelation и увеличивает текущий счетчик $this->tags_count. К слову о проблеме публичных свойств, счетчик можно увеличить напрямую, без вызова метод setTags.

Как работают сеттеры в этом примере? Откуда берутся значения, передаваемые сеттерам ($tags, $cover, $images...)?
setCover, setTags, setImages вызываются внутри модели c обычной передачей параметров. Полагаю, они должны быть приватными и не вызываться из клиентского кода (контроллера).
В какой момент пишутся данные из связанных моделей (теги, изображения, главное изображение) в базу данных?
В момент вызова populateRelation в модели происходит заполнение связанного релейшена. Сохранение происходит в контроллере в момент save модели.
Чего не хватает в этом коде, чтобы он заработал?
Написать всё заново, используя этот код только в качестве примера. Вам ведь нужен опыт, и это избавит вас от необходимости думать о функциях вроде setTagsString, которые нигде не используются и вносят только путаницу. Также будет понятно что для работы метода getRelatedRecords у вас должны быть заполнены таблицы images и tags.
Я рекомендую взять этот раздел документации и по очереди пройтись по каждым функциям. Это долго, по всем я сам ещё не прошелся, но это позволит получить максимально полное представление о возможностях ActiveRecord фреймворка, не копаясь в коде сомнительного качества.

Update: Пример рабочего кода данного примера.
Несмотря на то что это просто быстрый рефакторинг готов в кодревью и вопросам в комментариях.
Миграция:
<?php<br>use yii\db\Migration
class m151122_155133_create_tables extends Migration { public function up() { $tableOptions = null if ($this->db->driverName === 'mysql') { // http:/tackoverflow.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}}') } }
Контроллер:
<?php<br>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.') } } }
Представление index.php
<?php<br>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 ?>

<?= Html::encode($this->title) ?>


<?= Html::a('Create Post', ['create'], ['class' => 'btn btn-success']) ?>


<?= GridView::widget([ 'dataProvider' => $dataProvider, 'filterModel' => $searchModel, 'columns' => [ ['class' => 'yii\grid\SerialColumn'],
'id', 'message:ntext',
['class' => 'yii\grid\ActionColumn'], ], ]) ?>

Представление create.php
<?php<br>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 ?>

<?= Html::encode($this->title) ?>



<?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/*']) ?>
<?= Html::submitButton($model->isNewRecord ? 'Create' : 'Update', ['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']) ?>

<?php ActiveForm::end() ?>


Представление view.php
<?php<br>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 ?>

<?= Html::encode($this->title) ?>


<?= 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', ], ]) ?>


<?= DetailView::widget([ 'model' => $model, 'attributes' => [ 'id', 'message:ntext', ], ]) ?>

Модель CreatePostForm
<?php<br>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
<?php<br>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
<?php<br>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
<?php<br>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
<?php<br>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
<?php<br>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 } }