Страницы

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

суббота, 30 ноября 2019 г.

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

#yii2 #activerecord


В июне 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. Очень хочется посмотреть на лучшие практики в реальных сложных проектах.
    


Ответы

Ответ 1



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

Ответ 2



Как работают сеттеры в этом примере? Откуда берутся значения, передаваемые сеттерам ($tags, $cover, $images...)? Это PHP-магия. В базовом классе переопределены методы __get и __set, которые преобразуют чтение и запись атрибутов $foo->bar = $baz->qwe в вызовы методов $foo->setBar($baz->getQwe()). Если магия не нравится, то можно вызывать методы и напрямую. По сути, это банальные геттеры и сеттеры. В некоторых языках есть свойства (геттеры и сеттеры, объединённые в единую сущность), но PHP их не поддерживает. В какой момент пишутся данные из связанных моделей (теги, изображения, главное изображение) в базу данных? Когда вызывается метод save у ActiveRecord. В этот момент данные валидируются и, если валидация проходит, то записываются данные этой ActiveRecord и всех связанных. Чего не хватает в этом коде, чтобы он заработал? На этот вопрос невозможно ответить, пока вы не скажете, что именно не работает. Можно посмотреть комментарии, там указан какой-то баг: https://sohabr.net/habr/post/226103/ Судя по всему, популярностью эта статья не пользовалась, поэтому не понимаю желания полагаться на неё при обучении. Отдельно хотелось бы попросить ссылок на репозитории серьезных проектов, использующих Yii2. Очень хочется посмотреть на лучшие практики в реальных сложных проектах. Есть пример yii2-shop от одного из авторов фреймворка.

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

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