Страницы

Поиск по вопросам

четверг, 4 октября 2018 г.

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

В июне 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: Пример рабочего кода данного примера.
Несмотря на то что это просто быстрый рефакторинг готов в кодревью и вопросам в комментариях.
Миграция:
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}}'); } }
Контроллер:
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
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; ?>


title) ?>


'btn btn-success']) ?>


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

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

title) ?>



['enctype' => 'multipart/form-data']]); ?>
field($model, 'message')->textarea(['rows' => 6]) ?> field($model, 'tagString')->input('text') ?> field((new \frontend\models\PostImage()), 'image[]')->fileInput(['multiple' => true, 'accept' => 'image/*']) ?>
isNewRecord ? 'Create' : 'Update', ['class' => $model->isNewRecord ? 'btn btn-success' : 'btn btn-primary']) ?>




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

title) ?>


$model->id], ['class' => 'btn btn-primary']) ?> $model->id], [ 'class' => 'btn btn-danger', 'data' => [ 'confirm' => 'Are you sure you want to delete this item?', 'method' => 'post', ], ]) ?>


$model, 'attributes' => [ 'id', 'message:ntext', ], ]) ?>

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

Комментариев нет:

Отправить комментарий