1 December 2014

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

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

Когда становилось ясно, что в сети еще нет поведений для работы с этим типом связи, тогда нужный код писался на событии «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 и рекомендуйте его знакомым, ведь есть еще те кто о нем не слышал и продолжает «городить костыльные велосипеды».
Tags:yiiyii2active recordbehaviorsmany-to-manymany to manyar
Hubs: Yii
+10
29.8k 127
Comments 26