В июне 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/*']) ?>
Представление 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; } }
Комментариев нет:
Отправить комментарий