Поведение Yii2 Behaviors для сохранения связанных данных «многие ко многим» (many to many).

Мотивом к написанию данного материала послужила статья на Хабре Сохранение «много ко многим» в Yii2 через поведение.

Там все хорошо, но меня не устраивало: невозможность удалить все данные из таблицы связи, одна запись обязательно остается и чрезмерная перегруженность в пользу излишнего на мой взгляд стремления к универсализации. На вкус и цвет как говорится... Поэтому я рискнул написать свой вариант Behaviors Yii2 many to many.

Итак, что нам дано. Есть три таблицы: post, tag и post_has_tag, для них сгенерируем три модели Post, Tag и PostHasTag, таблицы например такие:

CREATE TABLE IF NOT EXISTS `post` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) DEFAULT NULL,
  `alias` varchar(255) NOT NULL,
  `text` text,
  `date_create` int(11) DEFAULT NULL,  
  `status` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `tag` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8;
CREATE TABLE IF NOT EXISTS `post_has_tag` (
  `post_id` int(11) NOT NULL,
  `tag_id` int(11) NOT NULL,
  PRIMARY KEY (`post_id`,`tag_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Код моделей приводить не буду, думаю, что генерация моделей с помощью Gii не предсталяет сложности. Последняя таблица представляет собой таблицу связи между постами и тегами, и у нее есть всего два поля: post_id и  tag_id, исходя из названий полей понятно что там должно храниться. Вот с этой таблицей мы и будем работать, поведение должно уметь отдавать нужные данные из этой таблицы, а также сохранять и при необходимости удалять их. Да, и в поведение в качестве параметров нужно передавать имя связи и имя атрибута модели.

Что бы хотелось: в первую очередь это простота использования, ну и чтобы все сохранялось, обновлялось, удалялось, транзакции чтобы были, вот собственно и все. Да, и речь здесь пойдет именно о типе связи «many to many» через таблицу связи, в которой только два столбца - значения первичных ключей связанных таблиц. Поведения для работы со связями один к одному и один ко многим мы напишем в следующий раз.

Первое что нужно сделать, это в модели Post обозначить связь многие ко многим через таблицу связи post_has_tag, согласно документации Yii2 это делается следующим образом:

public function getTags()
{
    return $this->hasMany(Tag::className(), ['id' => 'tag_id'])
        ->viaTable('post_has_tag', ['post_id' => 'id']);
}

Далее необходимо в той же модели Post объявить поведение, где tags - это имя вышеобозначенной связи, а tag_list - имя несуществующего пока атрибута модели, как он в модели потом появится будет рассказано чуть ниже, подключение очень простое:

public function behaviors()
{
    return [
        [
            'class' => \app\components\behaviors\ManyHasManyBehavior::className(),
            'relations' => [
                'tags' => 'tag_list',                   
            ],
        ],
    ]
}

Ну и чтобы закончить с моделью, в правилах валидации добавим safe - атрибут tag_list:

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

Подготовительные мероприятия для модели завершены, осталось подготовить представление. Для манипуляций с тегами, как и с другими сущностями, с которыми владелец связи соотносится как один ко многим или многие ко многим, удобно использовать JQUERY плагин Chosen. Для интеграции этого плагина с Yii2 есть несколько уже готовых решений, одно из которых мы и подключим в нашем отображении.

Где:

  • tag_list - имя атрибута модели
  • items - массив тегов, отсортированых по имени, и с помощью метода хелпера ArrayHelper::map приведеный к виду ключ (id) - значение (title):
Chosen::widget([
    'model' => $model,
    'attribute' => 'tag_list',
    'items' => ArrayHelper::map(
        Tag::find()->select('id, title')->orderBy('title')->asArray()->all(), 
        'id', 
        'title'
    ),
    'multiple' => true,
]);

Подготовительные работы закончены, пора приступать к разработке самого Поведения. Для начала объявим два свойства:

// Массив связей, который передается в объявлении поведения в модели
public $relations = [];
// Массив значений атрибутов модели, в нашем случае это tag_list
private $_values = [];

Далее создадим метод events() и назначим в нем обработчики событий. В нашем случае обработчик событий будет один - changeRelations.

public function events()
{
    return [
        ActiveRecord::EVENT_AFTER_INSERT => 'changeRelations',
        ActiveRecord::EVENT_AFTER_UPDATE => 'changeRelations',
        ActiveRecord::EVENT_BEFORE_DELETE => 'changeRelations',
    ];
}

Магический метод __get() будет возвращать массив айдишников тегов, принадлежащих соответствующему посту:

public function __get($name)
{
    if (isset($this->_values[$name])) {
        return $this->_values[$name];
    } else {
        $relation = $this->owner->getRelation(array_search($name, $this->relations));
        $relationModel = new $relation->modelClass();
        return $relation->select($relationModel->getPrimaryKey())->column();
    }
}

Магический метод __set() устанавливает приватное свойство $this->_values:

public function __set($name, $value)
{
    $this->_values[$name] = $value;
}

Далее перегрузим два метода из yii\base\Object canGetProperty() и canSetProperty() которые как раз разрешают читать из и писать в несуществующее свойство модели:

public function canGetProperty($name, $checkVars = true)
{
    return in_array($name, $this->relations) ?
        true : parent::canGetProperty($name, $checkVars);
}
public function canSetProperty($name, $checkVars = true, $checkBehaviors = true)
{
    return in_array($name, $this->relations) ?
        true : parent::canSetProperty($name, $checkVars, $checkBehaviors);
}

Ну и последний метод в нашем поведении, который сохраняет и удаляет данные из связанной модели. Метод документирован.

    public function changeRelations($event)
    {
        if (is_array($ownerPk = $this->owner->getPrimaryKey())) {
            throw new ErrorException("Составные первичные ключи не поддерживаются");
        }

        // Сохраняем данные в таблицу связи
        foreach ($this->relations as $relationName => $attributeName) {

            $relation = $this->owner->getRelation($relationName);
            $relationModel = new $relation->modelClass();

            // Если связь типа "многие ко многим"
            if (!empty($relation->via) && $relation->multiple) {
                // Имя таблицы связи
                list($junctionTable) = array_values($relation->via->from);
                // Имя первичного ключа владельца связи
                list($ownerColumn) = array_keys($relation->via->link);
                // Имя первичного ключа связанной таблицы
                list($relationPrimaryColumn) = array_keys($relation->link);
                // Имя поля таблицы связи для записи значений связанной таблицы
                list($junctionColumn) = array_values($relation->link);

                // Стартуем транзакцию
                $transaction = Yii::$app->db->beginTransaction();
                try {
                    // Если есть значения для записи
                    if (!empty($this->_values[$attributeName])) {  
                        // Значения из базы до сохранения
                        $oldValues = ArrayHelper::getColumn(ArrayHelper::toArray($this->owner->$relationName), $relationPrimaryColumn);
                        // Новые значения
                        $newValues = $this->_values[$attributeName]; 

                        // Массив новых значений для множественной вставки
                        $insert = [];
                        foreach ($newValues as $newValue) {
                            if (!in_array($newValue, $oldValues)) {
                                $insert[] = [$newValue, $ownerPk];
                            }
                        }

                        // Если есть что вставлять
                        if (count($insert)) {
                            Yii::$app->db->createCommand()
                                ->batchInsert($junctionTable, [$junctionColumn, $ownerColumn], $insert)
                                ->execute();
                        }

                        // Удаляем лишнее
                        Yii::$app->db->createCommand()
                            ->delete($junctionTable, $ownerColumn.'="'.$ownerPk.'" AND '.$junctionColumn.' NOT IN ('.implode(',',$newValues).')')
                            ->execute();                        
                    } else {                        
                        // Удаляем все связанные данные
                        Yii::$app->db->createCommand()
                            ->delete($junctionTable, $ownerColumn.'='.$ownerPk)
                            ->execute();
                    }
                    
                    $transaction->commit();
                } catch (\yii\db\Exception $ex) {
                    $transaction->rollback();
                    throw $ex;
                }

            } else {
                throw new ErrorException('Relationship type not supported.');
            }
        }
    }

В завершении полный код нашего поведения, который для примера можно поместить в папку components\behaviors текущего приложения:

namespace app\components\behaviors;

use Yii;
use yii\db\ActiveRecord;
use yii\base\ErrorException;
use yii\helpers\ArrayHelper;

/**
 * Class ManyHasManyBehavior
 * @package common\components\behaviors
 *
 * Usage:
 * 1. In your model, add the behavior and configure it:
 * public function behaviors()
 * {
 *     return [
 *         [
 *             'class' =--> \common\components\behaviors\ManyHasManyBehavior::className(),
 *             'relations' => [
 *                  'tags' => 'tag_items',                  
 *              ],
 *         ],
 *     ];
 * }
 * where 'tags' - name of relation, for example: 
 * public function getTags()
 * {
 *     return $this->hasMany(Tag::className(), ['id' => 'tag_id'])->viaTable('post_has_tag', ['post_id' => 'id']);
 * }
 * 'tag_list' - name of attribute (the attributes are created automatically in your model) 
 *
 * 2. In your model, add validation rules for the attributes created by the behavior, for example:
 * public function rules()
 * {
 *     return [
 *         [['tag_list'], 'safe']
 *     ];
 * }
 *
 * 3. In your view, create form fields for the attributes
 * 
 */

class ManyHasManyBehavior extends \yii\base\Behavior
{
    /**
     * List of relations.
     * @var array
     */
    public $relations = [];

    /**
     * List values of relation attributes.
     * @var array
     */
    private $_values = [];

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

    /**
     * Save all dirty (changed) relation values ($this->_values) to the database
     * @param $event
     * @throws ErrorException
     * @throws \yii\db\Exception
     */
    public function changeRelations($event)
    {
        if (is_array($ownerPk = $this->owner->getPrimaryKey())) {
            throw new ErrorException("This behavior does not support composite primary keys");
        }

        // Save relations data
        foreach ($this->relations as $relationName => $attributeName) {

            $relation = $this->owner->getRelation($relationName);
            $relationModel = new $relation->modelClass();

            // If the relation is many-to-many
            if (!empty($relation->via) && $relation->multiple) {
                // Table of junction
                list($junctionTable) = array_values($relation->via->from);
                // Column of owner table
                list($ownerColumn) = array_keys($relation->via->link);
                // Column of relation table
                list($relationPrimaryColumn) = array_keys($relation->link);
                // Column of junction table
                list($junctionColumn) = array_values($relation->link);

                $transaction = Yii::$app->db->beginTransaction();
                try {
                    if (!empty($this->_values[$attributeName])) {  
                        $oldValues = ArrayHelper::getColumn(ArrayHelper::toArray($this->owner->$relationName), $relationPrimaryColumn);
                        $newValues = $this->_values[$attributeName]; 

                        $insert = [];
                        foreach ($newValues as $newValue) {
                            if (!in_array($newValue, $oldValues)) {
                                $insert[] = [$newValue, $ownerPk];
                            }
                        }

                        if (count($insert)) {
                            Yii::$app->db->createCommand()
                                ->batchInsert($junctionTable, [$junctionColumn, $ownerColumn], $insert)
                                ->execute();
                        }

                        Yii::$app->db->createCommand()
                            ->delete($junctionTable, $ownerColumn.'="'.$ownerPk.'" AND '.$junctionColumn.' NOT IN ('.implode(',',$newValues).')')
                            ->execute();                        
                    } else {                        
                        Yii::$app->db->createCommand()
                            ->delete($junctionTable, $ownerColumn.'='.$ownerPk)
                            ->execute();
                    }
                    
                    $transaction->commit();
                } catch (\yii\db\Exception $ex) {
                    $transaction->rollback();
                    throw $ex;
                }

            } else {
                throw new ErrorException('Relationship type not supported.');
            }
        }
    }    

    /**
     * Returns a value indicating whether a property can be read.
     * We return true if it is one of our properties and pass the
     * params on to the parent class otherwise.
     * TODO: Make it honor $checkVars ??
     *
     * @param string $name the property name
     * @param boolean $checkVars whether to treat member variables as properties
     * @return boolean whether the property can be read
     * @see canSetProperty()
     */
    public function canGetProperty($name, $checkVars = true)
    {
        return in_array($name, $this->relations) ?
            true : parent::canGetProperty($name, $checkVars);
    }

    /**
     * Returns a value indicating whether a property can be set.
     * We return true if it is one of our properties and pass the
     * params on to the parent class otherwise.
     * TODO: Make it honor $checkVars and $checkBehaviors ??
     *
     * @param string $name the property name
     * @param boolean $checkVars whether to treat member variables as properties
     * @param boolean $checkBehaviors whether to treat behaviors' properties as properties of this component
     * @return boolean whether the property can be written
     * @see canGetProperty()
     */
    public function canSetProperty($name, $checkVars = true, $checkBehaviors = true)
    {
        return in_array($name, $this->relations) ?
            true : parent::canSetProperty($name, $checkVars, $checkBehaviors);
    }

    /**
     * Returns the value of an object property.
     * Get it from our local temporary variable if we have it,
     * get if from DB otherwise.
     *
     * @param string $name the property name
     * @return mixed the property value
     * @see __set()
     */
    public function __get($name)
    {
        if (isset($this->_values[$name])) {
            return $this->_values[$name];
        } else {
            $relation = $this->owner->getRelation(array_search($name, $this->relations));
            $relationModel = new $relation->modelClass();
            return $relation->select($relationModel->getPrimaryKey())->column();
        }
    }

    /**
     * Sets the value of a component property. The data is passed
     *
     * @param string $name the property name or the event name
     * @param mixed $value the property value
     * @see __get()
     */
    public function __set($name, $value)
    {
        $this->_values[$name] = $value;
    }
}

Подводя итог вышенаписанному можно сазать, что мы получили поведение согласно нашим требованиям. Простое в использовании и выполняющее все, что было задумано :).