Мотивом к написанию данного материала послужила статья на Хабре Сохранение «много ко многим» в 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; } }
Подводя итог вышенаписанному можно сазать, что мы получили поведение согласно нашим требованиям. Простое в использовании и выполняющее все, что было задумано :).