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

Если вам приходилось работать с 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 и рекомендуйте его знакомым, ведь есть еще те кто о нем не слышал и продолжает «городить костыльные велосипеды».
Поделиться публикацией

Похожие публикации

Комментарии 26
    +1
    Спасибо за статью. Как раз недавно начал изучать Yii2 и сегодня мне надо было сохранять связь many-to-many.
    У меня не захотел устанавливаться пакет. Поборол вот таким образом:
    "require": {
        ...
        "voskobovich/yii2-many-many-behavior": "@dev"
    }
    
      0
      Какая именно была ошибка? Можно попробовать разобраться.
      0
      Правильно ли я понял, что для каждого релейшна необходимо создавать дополнительный атрибут модели? Не думаю, что это удобно.
        0
        Ты правильно понял и я с тобой полностью согласен.
        Через пару дней устраню это неудобство и сделаю поведение более гибкое)
          0
          Возможно, из аналогичного решения для YII1 удастся почерпнуть идеи. Был только полжительный опыт использования данного экстеншна.
            0
            К сожалению (или к счастью) Yii2 сильно отличается от первой Yii1. Другая реализация связей и перенимать от туда что-либо сложно)
            Давайте я выкачу новую версию, а аудитория уже выскажет свое мнение)
        0
        А почему стиль фигурных скобок (вернее их отсутствие) из Yii1 +)?
          0
          Что ты имеешь ввиду?
            0
            да вообще никакие современные стандарты код стайла не используются. слившиеся со скобками if'ы, if'ы без скобок, переменные не по psr-2.
              0
              Мне не нравится современные кодстайлы. Читать такой код не совсем удобно.
              Я считаю, что если PHP позволяет опустить скобки — значит этим нужно пользоваться.
              Чем меньше символов в файле, тем быстрее и легче он анализируется глазами, разве не так?
              Точно так же и пространство между строками кода. Чем просторнее и логичнее разделен на блоки код, тем меньше напрягается зрение для анализа и понимания алгоритма.

              Когда я писал поведение, я не думал его публиковать вот и оформил не по стандартам.
              Со следующим обновлением сделаю форматирование кода по стандарту)
                0
                Про отступы между строк согласен, про фигурные скобки нет. Даже Yii team перешли на скобки и на то есть причины.
                  0
                  А можно услышать эти самые причины по которым они перешли на скобки?

                  Я считаю, что если уж и ставить скобки, то только так, чтобы они были на одной вертикали.
                  Потому что искать открывающую скобку по диагонали при теле if`a в 20 и больше задача для глаз не из легких.
                  С вертикальным размещением все на порядок проще + IDE подсвечивает. Веди вверх взгляд и ищи светящуюся скобку)
                  Что скажешь?
                    0
                    Как минимум если в условие, записанное без фигурных скобок, потребуется добавить еще одно выражение (строку) — придется не забыть добавить те самые фигурные скобки, иначе можно долго ловить баги (проверено на практике). IDE подсвечивает фигурные скобки (парные) если даже все выражение записано в одну строку +)
                      0
                      Я лишь говорю что, если фигурные скобки расположены на одной вертикали, то проще найти их пару. Согласен?
                      Если взять во внимание, что условия это примерно 65% всего кода, то простота нахождения пары условий должна быть максимальной)

                      Про забывания поставить скобки и баги… не замечал. IDE же форматирует код так, что не заметить такую оплошность нельзя (PhpStorm).

                      Ты можешь писать как угодно и каким хочешь почерком, главное чтобы потом было удобно читать)
                      Я студент и мне знакома ситуация, когда на паре писал и было все понятно, а при подготовке к экзамену материшься на свой же почерк)
                        0
                        Я лишь говорю что, если фигурные скобки расположены на одной вертикали, то проще найти их пару. Согласен?

                        Неа. Не вижу особой разницы.

                        IDE же форматирует код так, что не заметить такую оплошность нельзя (PhpStorm).

                        Я вот про это:
                        if($a == 5)
                            $b = 10;
                            $c = 15;
                        


                        главное чтобы потом было удобно читать)

                        На все 100 +)
                          0
                          Мой PhpStorm пишет вот так:
                          if($a == 100)
                          	$a = 300;
                          $b = 5;
                          

                          если писать сперва первую строку, потом вторую строку и третью)

                          Дискуссия хороша, но не имеет смысла. Против общепризнанных стандартов я идти не собираюсь)
                          Я тоже бываю не прав и сейчас как раз такая ситуация.
                          Раз я пишу публичное расширение для проекта то должен соблюдать кодстайл принятый в проекте!
                  0
                  Не так. Текст воспринимается тем быстрее и легче, чем он будет более стандартен и, соотвественно, привысен. В китайском, например, символов очень мало. Но ведь это не делает тексты на китайском более простыми к восприятию для тех, кто на китайском не пишет.
                0
                Отсутствие фигурных скобок github.com/yiisoft/yii2/blob/master/framework/base/Action.php#L83
              0
              Спасибо за начинание! Очень не хватает такого поведения, а раз за разом повторять один и тот же код не хочется.

              А почему не github? Вероятность, того что вам помогут с разработкой на github'е гораздо выше чем на Bitbucket'e

              Лучше скачать/обновить только новую зависимость, а не все зависимости:
              # php composer.phar update voskobovich/yii2-many-many-behavior
              


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

              P.S. Жду рабочую новую версию :) Попробовал, что такое делать pull-реквесты на Bitbucket'е.
                0
                Не подумал, что мне могут помочь с разработкой. На Bitbucket у меня все приватные репо и по традиции…
                Но, я перееду на github, раз такое дело)

                И с версиями я тоже намудрил, делаю тэгами, а надо ветками.
                Все исправлю!
                  0
                  Попытался использовать первую версию поведения, мне не хватает возможности сохранить пустой список — т.е. удалить текущие связи, а новых не создавать (т.к. пришел пустой массив id'шников). Также удобно было бы, чтобы была возможность для новых моделей иницализировать список связей вручную (например, я перешел по ссылке «создать продукт в этой категории»).
                    0
                    Не совсем понял, второй пункт «Также удобно было бы, чтобы была возможность для новых моделей иницализировать список связей вручную (например, я перешел по ссылке «создать продукт в этой категории»).» — можно еще пример?

                    Про удаление связей вообще — хорошая идея. Учтем.
                    Сейчас я работаю над поддержкой остальных типов связи: has many и has one.
                    Залью код в новую ветку.

                    Переехал на GitHub: ManyToManyBehavior. Буду ждать PullRequest`ов.
                      0
                      Usecase: Я нахожусь в списке категорий товаров (у меня товар может быть сразу в нескольких категориях, many-to-many), рядом с каждой категорией есть кнопка добавить новый товар в категорию, нажимая на которую, я попадаю на страницу заполнения сведений о новом товаре. В этой форме есть возможность указать категории, к которым будет принадлежать товар, т.к. при переходе по ссылке я указал номер «первой категории» я бы хотел ее сразу показать в этом списке (т.е. фактически, вручную добавить «первую категорию» в список категорий новой модели товара).
                        0
                        Отличный пример. Только как в этом может помочь поведение?
                        Передайте ID первой категории GET параметром и потом перед отображением формы, в контроллере, запишите его
                        $productModel->categories_list[] = $_GET['cat_id']
                        

                        Это позволит сразу выделить первую категорию в мульти-select`e, а сохраниться она в базу уже после сохранения товара)
                          0
                          Я так это и хочу сделать, я это к тому, чтобы была возможность так делать.
                            0
                            Архитектура этого не запрещает)
                            Поведение просто сохраняет массив ID к нужной связи, а как его формировать — уже дело каждого)

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

              Самое читаемое