Сохранение «много ко многим» в Yii2 через поведение

в 16:38, , рубрики: active record, yii, yii2

Сохранение «много ко многим» в Yii2 через поведение - 1 Если вам приходилось работать с Yii2, наверняка возникала ситуация, когда нужно было сохранить связь «много ко многим».

Когда становилось ясно, что в сети еще нет поведений для работы с этим типом связи, тогда нужный код писался на событии «after save» и с напутствием «ну работает же» отправлялся в репозиторий.

Лично меня не устраивал такой расклад событий. Я решил написать то самое волшебное поведение, которого так не хватает в официальной сборке Yii2.

Установка

Устанавливаем через Composer:

php composer.phar require --prefer-dist voskobovich/yii2-many-many-behavior "*"

Или добавляем в composer.json своего проекта в раздел «require»:

"voskobovich/yii2-many-many-behavior": "*"

Выполняем:

# php composer.phar update

Исходники: yii2-many-many-behavior.

Как пользоваться?

Создаем в модели новый атрибут:

public $users_list = array();

Он будет хранить массив идентификаторов которые нужно привязать к нашей модели.
На эти свойства вешаются поля формы:

<?= $form->field($model, 'users_list')
      ->dropDownList(User::getListData(), ['multiple' => true]) ?>

Далее добавляем это свойство в правила валидации:

public function rules()
{
    return [
        [['users_list'], 'safe']
    ];
}

Это нужно, чтобы позволить заполнять его с формы через setAttributes().

Внимание!

Стоит учесть, что в массиве, который возвращает getAttributes(), не будет свойства «users_list». Для того, чтобы он там появился, нужно переопределить метод getAttributes() вашей модели вот так:

public function getAttributes($names = null, $except = [])
{
    $attributes = parent::getAttributes($names = null, $except = []);
    return array_replace($attributes, [
        'users_list' => $this->users_list
    ]);
}

Дальше нужно подключить поведение в модель:

public function behaviors()
{
    return [
        [
            'class' => voskobovichbehaviorsMtMBehavior::className(),
            'relations' => [
                'users' => 'users_list',
                'tasks' => [
                    'tasks_list',
                    function($tasksList) {
                        return array_rand($tasksList, 2);
                    }
                ]
            ],
        ],
    ];
}

В этом примере описано две связи: «users» и «tasks».
В первую связь будет сохранен массив, который придет в атрибут «users_list» с формы, а во вторую связь будет сохранено только два случайных идентификатора из массива «tasks_list».
Надеюсь, понятно.

Как работает?

Опытные разработчики могут дальше не читать, а для начинающих — рассказываю.

Что такое поведение? Вот определение из официальной документации:

Поведения (behaviors) — это экземпляры класса [[yiibaseBehavior]] или класса, унаследованного от него. Поведения, также известные как примеси, позволяют расширять функциональность существующих [[yiibaseComponent|компонентов]] без необходимости изменения дерева наследования. После прикрепления поведения к компоненту, его методы и свойства «внедряются» в компонент, и становятся доступными так же, как если бы они были объявлены в самом классе компонента. Кроме того, поведение может реагировать на события, создаваемые компонентом, что позволяет тонко настраивать или модифицировать обычное выполнение кода компонента.

Наше поведение должно реагировать на два события:

  1. После создания модели (EVENT_AFTER_INSERT)
  2. После изменения модели (EVENT_AFTER_UPDATE)

На оба события один и тот же обработчик, так как логика одинаковая.

Объявляем события и обработчик в нашем поведении.
Метод events() вызывается фреймворком и заставляет поведение «работать».

/**
 * Events list
 * @return array
 */
public function events()
{
    return [
        ActiveRecord::EVENT_AFTER_INSERT => 'saveRelations',
        ActiveRecord::EVENT_AFTER_UPDATE => 'saveRelations',
    ];
}

/**
 * Save relations value in data base
 * @param $event
 * @throws ErrorException
 * @throws yiidbException
 */
public function saveRelations($event)
{
    $component = $event->sender;
    $safeAttributes = $component->safeAttributes();

    foreach($this->relations as $relationName => $source)
    {
        if(array_search($relationName, $safeAttributes) === NULL)
            throw new ErrorException("Relation "{$relationName}" must be safe attributes");

        if(is_array($component->getPrimaryKey()))
            throw new ErrorException("This behavior not supported composite primary key");

        $relation = $component->getRelation($relationName);

        if(empty($relation->via))
            throw new ErrorException("Attribute "{$relationName}" is not relation");

        list($junctionTable) = array_values($relation->via->from);
        list($relatedColumn) = array_values($relation->link);
        list($junctionColumn) = array_keys($relation->via->link);

        // Get relation keys of attribute name
        if(is_string($source) && isset($component->{$source}))
            $relatedPkCollection = $component->{$source};
        elseif(is_array($source))
        {
            list($attributeName, $callback) = $source;

            if(isset($component->{$attributeName})) {
                $relatedPkCollection = (array)call_user_func($callback, $component->{$attributeName});
                $component->{$attributeName} = $relatedPkCollection;
            }
        }

        // Save relations data
        if(!empty($relatedPkCollection))
        {
            $transaction = Yii::$app->db->beginTransaction();
            try
            {
                $connection = Yii::$app->db;
                $componentPk = $component->getPrimaryKey();

                // Remove relations
                $connection->createCommand()
                    ->delete($junctionTable, "{$junctionColumn} = :id", [':id' => $componentPk])
                    ->execute();

                // Write new relations
                $junctionRows = array();
                foreach($relatedPkCollection as $relatedPk)
                    array_push($junctionRows, [$componentPk, $relatedPk]);

                $connection->createCommand()
                    ->batchInsert($junctionTable, [$junctionColumn, $relatedColumn], $junctionRows)
                    ->execute();

                $transaction->commit();
            }
            catch(yiidbException $ex)
            {
                $transaction->rollback();
            }
        }
    }
}

В обработчике событий saveRelations() скрипт проходит по всем описаным связям, делает ряд важных проверок и далее в два шага сохраняет связь:

  1. Удаляет старые связи в которых есть иденификатор нашей модели
  2. Записывает новые

Для удаления старых связей используется один запрос в БД и, благодаря batchInsert(), для записи новых используется тоже один запрос.
Обработка каждой связи обернута в транзакцию для безопасного сохранения данных.
При исключении связь просто не сохранится, пользователь не увидит ошибки.

С обновлением и сохранением разобрались, но как заполнить наш атрибуты «users_list» при выборке модели из базы в следующий раз?
Здесь нам поможет событие «После выборки» (EVENT_AFTER_FIND), которое будет после выборки модели из базы подтягивать все перечисленные связи и по ним заполнять наши атрибуты.

Добавляем еще одно событие в events():

public function events()
{
    return [
        ActiveRecord::EVENT_AFTER_INSERT => 'saveRelations',
        ActiveRecord::EVENT_AFTER_UPDATE => 'saveRelations',
        ActiveRecord::EVENT_AFTER_FIND   => 'loadRelations'
    ];
}

Пишем обработчик loadRelations():

public function loadRelations($event)
{
    $component = $event->sender;
    list($primaryKey) = $component::primaryKey();

    foreach($this->relations as $relationName => $source)
    {
        if(is_array($source))
            list($attributeName) = $source;
        else
            $attributeName = $source;

        $relation = $component->getRelation($relationName);

        if(!is_null($relation))
        {
            $relatedModels = $relation->indexBy($primaryKey)->all();
            $component->{$attributeName} = array_keys($relatedModels);
        }
    }
}

Снова же проходимся по массиву объявленных связей, выгружаем по ним модели. При помощи indexBy() формируем выборку так, чтобы primary key моделей были в ключах коллекции. Далее, используя array_keys(), получаем ключи коллекции и присваиваем их нашим созданным свойствам. Таким образом мы восстанавливаем значения свойств модели и получаем на форме правильно выделенные пункты в multi select.

Стоит учесть, что при выгрузке нашей модели выбираются и записи по связям. Так что рекомендую использовать жадную загрузку для уменьшения количества запросов к базе.

На этом у меня все.

Спасибо за внимание!

Автор: rafic

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js