![](https://habrastorage.org/files/bbc/479/335/bbc47933552e4c90846c80e731b06ddf.jpg)
Когда становилось ясно, что в сети еще нет поведений для работы с этим типом связи, тогда нужный код писался на событии «after save» и с напутствием «ну работает же» отправлялся в репозиторий.
Лично меня не устраивал такой расклад событий. Я решил написать то самое волшебное поведение, которого так не хватает в официальной сборке Yii2.
Установка
Устанавливаем через Composer:
php composer require --prefer-dist voskobovich/yii2-many-many-behavior "~3.0"
Или добавляем в composer.json своего проекта в раздел «require»:
"voskobovich/yii2-many-many-behavior": "~3.0"
Выполняем:
php composer update
Исходники на GitHub.
Как пользоваться?
Для примера возьмем популярный вид связи Публикация и Категории.
Подключаем поведение к Публикации.
class Post extends ActiveRecord
{
...
public function rules()
{
return [
[['category_ids'], 'each', 'rule' => ['integer']],
...
];
}
public function behaviors()
{
return [
[
'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
'relations' => [
'category_ids' => 'categories',
],
],
];
}
public function getCategories()
{
return $this->hasMany(Category::className(), ['id' => 'category_id'])
->viaTable('{{%post_has_category}}', ['post_id' => 'id']);
}
public static function listAll($keyField = 'id', $valueField = 'name', $asArray = true)
{
$query = static::find();
if ($asArray) {
$query->select([$keyField, $valueField])->asArray();
}
return ArrayHelper::map($query->all(), $keyField, $valueField);
}
...
}
Поведение создаст в модели новый атрибут category_ids. Он будет принимать массив первичных ключей категорий пришедший с формы или по API.
Поведение можно настроить на работу сразу с несколькими связями. Например, Публикация может иметь связь с Категориями, Тегами, Юзерами, Картинками и т.д.
'relations' => [
'category_ids' => 'categories',
'user_ids' => 'users',
'tag_ids' => 'tags',
...
]
Все созданные поведением атрибуты необходимо упомянуть в правилах валидации. Старайтесь писать осмысленные правила, а не указывать их в группу «safe» и готово.
Теперь создадим в представлении поле для выбора категорий.
<?= $form->field($model, 'category_ids')->dropDownList(Category::listAll(), ['multiple' => true]) ?>
Я давно использую метод listAll() в своих проектах и сейчас появилась возможность им поделиться. Он отлично подходит для заполнений мультиселектов в формах и фильтрах GridView.
Все, после этих манипуляций Категории должны без проблем привязываться к Публикации.
А что с оптимизацией и безопасностью?
Запрос на формирование списка первичных ключей происходит только в момент чтения свойства, а не при выборке модели. Пока не обратитесь к нему — запрос не уйдет.
Вся логика поведения по управлению связями обернута в транзакцию.
Дальше больше
Довольно часто задача выходит за рамки стандартного «сохранить\получить» связанные модели. Для таких задач в поведении предусмотрены расширенные настройки.
Кастомные геттеры и сеттеры
Часто, для различных js плагинов, нужно уметь отдавать данные в JSON или строку вида «1,2,3,4». Настраиваем поведение:
public function behaviors()
{
return [
[
'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
'relations' => [
'category_ids' => [
'categories',
'fields' => [
'json' => [
'get' => function($value) {
return JSON::encode($value);
},
'set' => function($value) {
return JSON::decode($value);
},
],
'string' => [
'get' => function($value) {
return implode(',', $value);
},
'set' => function($value) {
return explode(',', $value);
},
],
],
]
],
],
];
}
С данной конфигурацией у модели появится 3 новых атрибута category_ids, category_ids_json и category_ids_string. Как видно из конфигурации, можно не только изменять формат исходящих данных, но и обрабатывать входящие в атрибут данные. Например распарсить строку или JSON в массив первичных ключей.
Открыть в документации.
Управление значениями полей связующей таблицы
Часто связь содержит не только первичные ключи, но и дополнительную информацию. Например: дату создания или порядок сортировки. На этот случай поведение тоже можно настроить:
public function behaviors()
{
return [
[
'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
'relations' => [
'category_ids' => [
'categories',
'viaTableValues' => [
'status_key' => PostHasCategory::STATUS_ACTIVE,
'created_at' => function() {
return new \yii\db\Expression('NOW()');
},
'is_main' => function($model, $relationName, $attributeName, $relatedPk) {
// Первая в категория будет главной
return array_search($relatedPk, $model->category_ids) === 0;
},
],
]
],
],
];
}
Открыть в документации.
Установка значения по умолчанию для осиротевших моделей
Понимаю, заголовок звучит странно, но это нужная штука.
Дело в том, что поведение умеет работать не только со связью «многие ко многим», но и с «один ко многим».
В первом случае, записи из связующей таблицы просто удаляются и на их место записываются новые.
Во втором типе связи подразумевается, что сперва нужно сделать связанные модели сиротами (отвязать), а потом приютить их обратно (привязать).
В результате некоторые модели могут так и остаться сиротами и их нужно помещать в некий «Архив». Как раз для настройки владельца всех осиротевших записей и создан параметр default. Если его не указывать, тогда у записей в связующем поле останется null.
public function behaviors()
{
return [
[
'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
'relations' => [
'category_ids' => [
'categories',
'default' => 17,
]
],
],
];
}
Открыть в документации.
Условие удаления из связующей таблицы
Часто в связующей таблицы хранятся записи одной структуры но разных типов.
Например: в таблице product_has_attachment лежат фото и прайсы товара. Для каждого типа вложения настроена своя связь.
Но что будет, если мы добавим новый прайс к товару? Все записи из таблицы product_has_attachment связанные с этим товаром будут уничтожены и на их место запишутся старые прайсы + новый.
Но… но… ведь там же были не только прайсы, а еще и фото… черт!
Чтобы такого не произошло, нужно настроить поведение:
class Product extends ActiveRecord
{
...
public function behaviors()
{
return [
[
'class' => \voskobovich\behaviors\ManyToManyBehavior::className(),
'relations' => [
'image_ids' => [
'images',
'viaTableValues' => [
'type_key' => ProductHasAttachment::TYPE_IMAGE,
],
'customDeleteCondition' => [
'type_key' => ProductHasAttachment::TYPE_IMAGE,
],
],
'priceList_ids' => [
'priceLists',
'viaTableValues' => [
'type_key' => ProductHasAttachment::TYPE_PRICE_LIST,
],
'customDeleteCondition' => [
'type_key' => ProductHasAttachment::TYPE_PRICE_LIST,
],
]
],
],
];
}
public function getImages()
{
return $this->hasMany(Attachment::className(), ['id' => 'attachment_id'])
->viaTable('{{%product_has_attachment}}', ['product_id' => 'id'], function ($query) {
$query->andWhere([
'type_key' => ProductHasAttachment::TYPE_IMAGE,
]);
return $query;
});
}
public function getPriceLists()
{
return $this->hasMany(Attachment::className(), ['id' => 'attachment_id'])
->viaTable('{{%product_has_attachment}}', ['product_id' => 'id'], function ($query) {
$query->andWhere([
'type_key' => ProductHasAttachment::TYPE_PRICE_LIST,
]);
return $query;
});
}
...
}
Таким образом при добавлении нового прайс-листа будут затронуты только список прайс-листов, картинки же останутся не тронутыми.
Открыть в документации.
Данная статья отражает лишь часть функционала поведения.
За более точной информацией я рекомендую смотреть в документацию.
Тем более, статью я обновляю реже чем README репозитория.
Я искренне надеюсь, что мое поведение делает работу с связями легче и проще.
Если это так, ставьте звезды на github и рекомендуйте его знакомым, ведь есть еще те кто о нем не слышал и продолжает «городить костыльные велосипеды».