Расширяем стандартный ORM в Kohana для работы с pivot tables
Недавно начал изучать framework Kohana (версии 3.1). При работе со стандартной ORM потребовалось хранить дополнительные значения в промежуточных таблицах (pivot tables). Эти таблицы создаются для организации связи многие-ко-многим и содержат значения ключей связываемых таблиц.
Следует отметить что в версии 3.0 метод add имеет такую возможность (правда не понятно как извлечь данные оттуда, гугл и беглый просмотр кода не помогли).<.p>
// Метод add в kohana 3.0.* public function add($alias, ORM $model, $data = NULL) { ...
В версии 3.1 метод изменили, сделав возможность добавлять сразу несколько связей, для чего нужно передать массив ключей или экземпляров класса модели с которыми хотим связать.
Как видим возможность добавлять данные пропала. Обращаемся к гуглу и видим предложения создать дополнительную модель для промежуточной таблицы и работать с дополнительными полями через нее (пруф). Такое решение имеет ряд недостатков, во первых это дополнительный класс который не особо то и нужен, во вторых реализация ORM в kohan’е требует наличия в таблице автоинкриментного первичного ключа, которого в промежуточной таблице нет и быть не должно. Поэтому ниже можно найти мой костыль решающий данную проблему.
Исходя из того факта что мы будем работать с таблицей я для начала решил добавить описание ее полей. Подходящим для этого местом, на мой взгляд, является описание связи (связь has_many «trough»).
Вот так это выглядит у меня:
// Relationships protected $_has_many = array ( ... 'permissions' => array ( 'model' => 'security_acl', 'foreign_key' => 'group_id', 'far_key' => 'acl_id', 'through' => 'security_group_acl', '_table_columns' => array ( 'value' => 0, // column => default value ) ) );
Здесь:
- model — это модель на которую мы хотим ссылаться;
- foreign_key — внешний ключ значение которого берется из первичного ключа текущей модели;
- far_key — внешний ключ для второй модели;
- through — имя промежуточной таблицы;
- _table_columns — добавленное мной описание дополнительных полей промежуточной таблицы, колонки таблицы описываются как пары ключ => значение по умолчанию.
Далее нужно определиться с операциями добавления, чтения и удаления. Я решил не переопределять стандартные функции для работы со связями, а дописать свои, для чего перенес класс ORM в директорию классов приложения (для справки: в kohan’а используется каскадная файловая структура. Она сначала ищет файлы в директориях приложения, потом в директориях подключенных модулей, и уж потом в системных директориях). После чего добавил следующий код:
/** * функция создания / обновления связи * * @param string $alias имя связи * @param mixed $far_keys модель, первичный ключ (или массив из этих значений) для организации связи * @param null $data массив из пар колонка => значение * @return ORM */ public function alias_set_values($alias, $far_keys, $data = NULL) { // если передали модель то извлекаем первичный ключ $far_keys = ($far_keys instanceof ORM) ? $far_keys->pk() : $far_keys; if (NULL == $data) { $data = array(); } // формируем вспомогательный запрос для проверки существования связи $check_query = DB::select('COUNT(*) as total') ->from($this->_has_many[$alias]['through']) ->where($this->_has_many[$alias]['foreign_key'], '=', $this->pk()) ->and_where($this->_has_many[$alias]['far_key'], '=', ':far_key'); foreach ((array)$far_keys as $key) { // определим есть ли связь $total = $check_query->param(':far_key', $key) ->execute($this->_db) ->get('total'); // если нет создаем, в случае если данные не передали то // будут установлены дефолтные значения if (0 == $total) { $this->_alias_create($alias, $key, $data); } else { // если есть, а данных нет то обновлять нечем (спорный момент, // вдруг кто то захочет сбросить в дефолт, но я решил так) if (0 == count($data)) throw new Kohana_Exception('set the data for update alias'); // обновляем запись $this->_alias_update($alias, $key, $data); } } return $this; } /** * Функция создания связи многие-ко-многим с дополнительными полями * @param string $alias имя связи * @param mixed $far_keys модель, первичный ключ (или массив из этих значений) для организации связи * @param null $data массив из пар колонка => значение * @return void */ protected function _alias_create($alias, $far_key, array $data) { // извлекаем из описания списки колонок и их значений list($extra_columns, $extra_values) = $this->_get_extra_fields($this->_has_many[$alias]['_table_columns'], $data, false); // объединяем первичный ключ (составной) с дополнительными колонками $columns = array_merge(array($this->_has_many[$alias]['foreign_key'], $this->_has_many[$alias]['far_key']), $extra_columns); // создаем запись $foreign_key = $this->pk(); DB::insert($this->_has_many[$alias]['through'], $columns) ->values(array_merge(array($foreign_key, $far_key), $extra_values)) ->execute($this->_db); } /** * Функция обновления связи многие-ко-многим с дополнительными полями * @param string $alias имя связи * @param mixed $far_keys модель, первичный ключ (или массив из этих значений) для организации связи * @param null $data массив из пар колонка => значение * @return void */ protected function _alias_update($alias, $far_key, array $data) { // извлекаем из описания списки колонок и их значений list($extra_columns, $extra_values) = $this->_get_extra_fields($this->_has_many[$alias]['_table_columns'], $data, true); // обновляем запись DB::update($this->_has_many[$alias]['through']) ->set(array_combine($extra_columns, $extra_values)) ->where($this->_has_many[$alias]['foreign_key'], '=', $this->pk()) ->and_where($this->_has_many[$alias]['far_key'], '=', $far_key) ->execute($this->_db); } /** * Функция объединяет данные из описания связи с переданными данными * (в расчет берутся только колонки из описания таблицы) * если параметр strict == true то значения по умолчанию игнорируются * @param array $table_columns массив дефолтных данных для извлечения * @param array $data массив переданных данных * @param bool $strict флаг указывающий брать или не брать дефолтные значения */ protected function _get_extra_fields(array $table_columns, array $data, $strict = false) { $extra_column = array(); $extra_values = array(); foreach ($table_columns as $column => $default_value) { if (array_key_exists($column, $data)) { $extra_column[] = $column; $extra_values[] = $data[$column]; } elseif (!$strict) { $extra_column[] = $column; $extra_values[] = $default_value; } } return array($extra_column, $extra_values); }
Есть одна публичная функция для вставки и обновления (операция определяется автоматически) и несколько вспомогательных функций о назначении которых можно судить по комментариям в коде.
Далее для извлечения данных используется следующий код:
/** * Функция извлечения данных из промежуточной таблицы * @param string $alias название связи * @param mixed $far_keys ссылки для извлечения (аналогично функции вставки) * @param null $columns список колонок для извлечения * @return Database_Result */ public function alias_get_values($alias, $far_keys, $columns = NULL) { // извлекаем первичный ключ если передали модель $far_keys = ($far_keys instanceof ORM) ? $far_keys->pk() : $far_keys; // определяем операцию в зависимости от количества запрашиваемых связей $far_keys_op = (is_array($far_keys)) ? 'IN' : '='; // если не указаны колонки то извлекаем все указанные в описании if (NULL == $columns) { $columns = array_keys($this->_has_many[$alias]['_table_columns']); } // собственно запрос на извлечение return DB::select_array($columns) ->from($this->_has_many[$alias]['through']) ->where($this->_has_many[$alias]['foreign_key'], '=', $this->pk()) ->and_where($this->_has_many[$alias]['far_key'], $far_keys_op, $far_keys) ->execute($this->_db); }
Думаю с этим кодом тоже все ясно. И для целостности восприятия подхода определим метод удаления связи который просто вызывает стандартный метод:
public function alias_remove($alias, $far_keys) { $this->remove($alias, $far_keys); }
Пример использования подхода для работы со списками прав доступа группы (код из модели описывающей группу (роль) пользователя). Функции установки прав, удаления и чтения. Код $permissions = model_security_acl::get_by_name($permissions); преобразует список прав переданных по имени (при необходимости) в список объектов модели описывающей права. Дополнительные комментарии думаю излишни. Собственно сам код:
public function permission_allow($permissions) { if (!is_array($permissions)) { $permissions = func_get_args(); } $this->_set_permission(security::PERMISSION_ALLOWED, $permissions); return $this; } public function permission_deny($permissions) { if (!is_array($permissions)) { $permissions = func_get_args(); } $this->_set_permission(security::PERMISSION_DENIED, $permissions); return $this; } protected function _set_permission($value, array $permissions) { if (!$this->_loaded) throw new Kohana_Exception('model :model must be loaded for perform :action action', array(':model' => get_class($this), ':action' => __FUNCTION__)); $permissions = model_security_acl::get_by_name($permissions); $this->alias_set_values('permissions', $permissions, array('value' => $value)); } public function permission_remove($permissions) { if (!$this->_loaded) throw new Kohana_Exception('model :model must be loaded for perform :action action', array(':model' => get_class($this), ':action' => __FUNCTION__)); if (!is_array($permissions)) { $permissions = func_get_args(); } $permissions = model_security_acl::get_by_name($permissions); $this->alias_remove('permissions', $permissions); return $this; } public function permission_check($permissions) { if (!$this->_loaded) throw new Kohana_Exception('model :model must be loaded for perform :action action', array(':model' => get_class($this), ':action' => __FUNCTION__)); if (!is_array($permissions)) { $permissions = func_get_args(); } $permissions = model_security_acl::get_by_name($permissions); $flags = $this->alias_get_values('permissions', $permissions, array('acl_id', 'value'))->as_array('acl_id', 'value'); $result = array(); foreach ($permissions as $acl) { if (array_key_exists($acl->pk(), $flags)) { $result[$acl->pk()] = $flags[$acl->pk()]; } else { $result[$acl->pk()] = security::PERMISSION_NOT_SET; } } return $result; }
PS: Подход не претендует на элегантность, но для меня работает, и надеюсь будет полезен еще кому то.