Если вам приходилось работать с 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|компонентов]] без необходимости изменения дерева наследования. После прикрепления поведения к компоненту, его методы и свойства «внедряются» в компонент, и становятся доступными так же, как если бы они были объявлены в самом классе компонента. Кроме того, поведение может реагировать на события, создаваемые компонентом, что позволяет тонко настраивать или модифицировать обычное выполнение кода компонента.
Наше поведение должно реагировать на два события:
- После создания модели (EVENT_AFTER_INSERT)
- После изменения модели (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() скрипт проходит по всем описаным связям, делает ряд важных проверок и далее в два шага сохраняет связь:
- Удаляет старые связи в которых есть иденификатор нашей модели
- Записывает новые
Для удаления старых связей используется один запрос в БД и, благодаря 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