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


