Недавно начал изучать 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: Подход не претендует на элегантность, но для меня работает, и надеюсь будет полезен еще кому то.