Расширяем стандартный ORM в Kohana для работы с pivot tables

21 апреля 2011 г.

Недавно начал изучать 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
       )
    )
 );

Здесь:

  1. model — это модель на которую мы хотим ссылаться;
  2. foreign_key — внешний ключ значение которого берется из первичного ключа текущей модели;
  3. far_key — внешний ключ для второй модели;
  4. through — имя промежуточной таблицы;
  5. _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: Подход не претендует на элегантность, но для меня работает, и надеюсь будет полезен еще кому то.

Теги: рубрика PHP, Сайтостроение
  • Похожие статьи
  • Предыдущие из рубрики