Открыть список
Как стать автором
Обновить

Doctrine, расширяем возможности любимого ORM-фреймворка! Часть 1.b (I18n, модификация быстрого доступа к переводимым атрибутам)

Doctrine ORM
В прошлой статье я рассматривал один из способов быстрого доступа к переводимым атрибутам. Для того что бы понять о чем вообще идет речь, настоятельно рекомендуется прочитать укзаную статью перед этой ) Для тех, кто уже читал, напомню, что основной цимес состоял в искусственных гетерах и сетерах через hasAccessorMutator(), которые в свою очередь оверрайдились через __call()-функцию шаблона. Очевидный минус этого подхода это невозможность использовать __call() в других шаблонах, что не есть хорошо. Есть более красивый и эффективный способ реализовать такой доступ, причем он изначально был предусмотрен в Doctrine чуть ли не с самых первых версий — это фильтры атрибутов. И снова под катом много кода и текста.

Быстрый доступ к переводимым атрибутам через фильры


Как же все-таки фильтры используются в Doctrine_Record? Не вдаваясь в продробности, примерная схема выглядит таким образом:
  • $record->attribute — скрипт запрашивает некий атрибут
  • вызывается метод Doctrine::__get('attribute'), который вызывает Doctrine_Record::get('attribute'), а он в свою очередь метод Doctrine::_get('attribute').
  • в процессе этой очереди происходят проверки на наличие accessor'ов и mutator'ов (та же очередь соответстует и сеттерам), наличия ассоциаций и др. и в самом конце вызывается прелюбопытнейшая конструкция...

// код из Doctrine_Record::_get() в самом конце метода
foreach ($this->_table->getFilters() as $filter) {
try {
$value = $filter->filterGet($this, $fieldName);
$success = true;
} catch (Doctrine_Exception $e) {}
}
if ($success) {
return $value;
} else {
throw $e;
}

как видно, для любого аттрибута (в том числе и не существующего) делается вызов filterGet() для каждого экземпляра фильтра, назначеного для записи!

Что это может значить для нас? А то, что мы можем использовать родной метод доступа к атрибутам через внешний класс фильтра. По умолчанию в Doctrine_Record установлен только 1 фильтр — Doctrine_Record_Filter_Compound, который отслеживает наличие заданого атрибута, и если его не существует — кидает исключение. Это исключение ловится в Doctrine_Record и либо переходит к следующему фильтру, либо повторно его вызывает, если фильтры закончились. Все достаточно просто и наша задача сводится к тому что бы реализовать свой фильтр, который будет перехватывать вызов атрибутов, которые мы укажем в инициализации и возвращать значения соответствующих полей ассоциации Translation для нашей записи, а для других кидать исключение. Как видно из вставки кода (см. выше), запись вызывает фильтры по порядку и ловит их исключеня либо результат для того, что бы перейти к следующему фильру. Попробуем реализовать такой фильтр.

Фильтр


Для начала, что бы создать какой-либо фильтр, нужно создать класс пронаследованый из Doctrine_Record_Filter и реализовать 2 абстрактных метода Doctrine_Record_Filter::filterGet() и Doctrine_Record_Filter::filterSet(). Как видно из примерной схемы работы геттеров и сеттеров (см. выше) запись вызывает фильтры по порядку, а а случае каких-то ошибок мы должны кидать исключение.

Я не буду на этот раз вдаваться в цепочки рассуждений как надо писать классы, а приведу сразу готовый код фильтра, а потом займемся анализом что и зачем нужно.
/**
* EasyAccess package filter. Implements access to record's properties as for translated in I18n.
*
* Can be used as a part of Of_ExtDoctrine_I18n_Helper system, or as stand-alone filter both.
*
* @author OmeZ
* @version 1.0
* @license www.opensource.org/licenses/lgpl-license.php LGPL
* @package Of_ExtDoctrine_I18n_EasyAccess
*/
class Of_ExtDoctrine_I18n_EasyAccess_Filter extends Doctrine_Record_Filter {

/**
* Fields
*
* @var array
*/
protected $_fields = array();

/**
* Language
*
* @var string
*/
protected $_language;

/**
* @var Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface
*/
protected $_owner;

/**
* Constructs new filter with options
*
* @param array $options
* @return void
*/
public function __construct(array $options) {
if (isset($options['fields'])) $this->setFields($options['fields']);
if (isset($options['language'])) $this->setLanguage($options['language']);
if (isset($options['owner'])) $this->setOwner($options['owner']);
}

/**
* Returns owner of filter
*
* @return Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface
*/
public function getOwner() {
return $this->_owner;
}

/**
* Sets owner for filter
*
* @param Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface $owner
* @return Of_ExtDoctrine_I18n_EasyAccess_Filter
*/
public function setOwner(Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface $owner) {
$this->_owner = $owner;
return $this;
}

/**
* Returns fields aliases for filter
*
* @return array
*/
public function getFields() {
return $this->_fields;
}

/**
* Sets fields aliases for filter
*
* @param $fields
* @return Of_ExtDoctrine_I18n_EasyAccess_Filter
*/
public function setFields($fields) {
$this->_fields = (array)$fields;
return $this;
}

/**
* Returns default language for filter
*
* @return string
*/
public function getLanguage() {
if ($this->_language !== null) {
return $this->_language;
} elseif ($this->_owner) {
return $this->_owner->getLanguage();
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/Exception.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_Exception('Impossible to detect language');
}
}

/**
* Sets language to filter
*
* @return Of_ExtDoctrine_I18n_EasyAccess_Filter
*/
public function setLanguage($language) {
$this->_language = $language;
return $this;
}

/**
* Returns value of translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @return mixed
*/
public function filterGet(Doctrine_Record $record, $name) {
return $this->getTranslation($record, $name, $this->getLanguage());
}

/**
* Sets value to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param mixed $value
* @return void
*/
public function filterSet(Doctrine_Record $record, $name, $value) {
return $this->setTranslation($record, $name, $value, $this->getLanguage());
}

/**
* Language dependent getter to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param string $language
* @param boolean $return_first_found
* @return mixed
*/
public function getTranslation(Doctrine_Record $record, $name, $language = null, $return_first_found = true) {
if (in_array($name, $this->_fields)) {
$language = empty($language)?(string)$this->getLanguage():(string)$language;
if ($record->hasRelation('Translation')) {
if ($record->Translation->contains($language)) {
return $record->Translation[$language][$name];
} elseif ($return_first_found && $record->Translation->count()) {
foreach ($record->Translation as $translation)
if (!empty($translation[$name]))
return $translation[$name];
return null;
} else return null;
} else return null;
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/NotTranslatableException.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_NotTranslatableException("Field {$name} is not marked as easy getter to tranlsatable attribute in ".get_class($record));
}
}

/**
* Language dependent setter to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param mixed $value
* @param string $language
* @return void
*/
public function setTranslation(Doctrine_Record $record, $name, $value, $language = null) {
if (in_array($name, $this->_fields)) {
$language = empty($language)?(string)$this->getLanguage():(string)$language;
if ($record->hasRelation('Translation')) {
$record->Translation[$language][$name] = $value;
}
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/NotTranslatableException.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_NotTranslatableException("Field {$name} is not marked as easy setter to tranlsatable attribute in ".get_class($record));
}
}

}


Analysis time!


Конструктор

/**
* Constructs new filter with options
*
* @param array $options
* @return void
*/
public function __construct(array $options) {
if (isset($options['fields'])) $this->setFields($options['fields']);
if (isset($options['language'])) $this->setLanguage($options['language']);
if (isset($options['owner'])) $this->setOwner($options['owner']);
}

Здесь мы проводим инициализацию нашего фильтра, и передаем ему список опций, в которых содержится информация о полях, языке, и загадочном владельце фильтра. Рассмотрим опции поближе (интерфейс аналогичный приведенному в предыдущей статье):
  • 'fields' — список атрибутов которые мы будем отслеживать. Забегая вперед скажу что если мы укажем несуществующий атрибут и обратимся к нему — фильтр кинет исключение
  • 'language' — язык по умолчанию, который мы будем использовать для доступа к аттрибутам
  • 'owner' — загадочный владелец фильтра. Эта опция используется для того что бы указать какой-нибудь объект (интерфейса Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface), к которому будет обращаться фильтр что бы получить язык, если он не был до этого задан. Но об этом потом


Методы установки и получения опций

Это набор методов (get/set)Fields(), (get/set)Language(), (get/set)Owner(). В принцие здесь ничего нетревиального, потом подробно рассматривать все не буду. Остановлюсь только getLanguage(), т.к. в нем опять появляется загадочный владелец:
/**
* Returns default language for filter
*
* @return string
*/
public function getLanguage() {
if ($this->_language !== null) {
return $this->_language;
} elseif ($this->_owner) {
return $this->_owner->getLanguage();
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/Exception.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_Exception('Impossible to detect language');
}
}

Как видно, owner нам нужен исключительно для того, что бы запросить язык, если его собственное значение не указано. Это сделано для того что бы позволить этому фильтру интегрироваться в другую структуру. В моем случае это модифицированый шаблон Of_ExtDoctrine_I18n_EasyAccess_Helper, аналог которого я рассматривал в предыдущей статье. Owner должен следовать интерфейсу Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface, в котором содержится только один публичный метод getLanguage()
interface Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface {

/**
* Returns language for inherited components
*
* @return string
*/
public function getLanguage();

}


Методы фильтра

Вот в конце концов и подобрались к самим методам, которые реализуют необходимый функционал фильтра.
/**
* Returns value of translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @return mixed
*/
public function filterGet(Doctrine_Record $record, $name) {
return $this->getTranslation($record, $name, $this->getLanguage());
}

/**
* Sets value to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param mixed $value
* @return void
*/
public function filterSet(Doctrine_Record $record, $name, $value) {
return $this->setTranslation($record, $name, $value, $this->getLanguage());
}

/**
* Language dependent getter to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param string $language
* @param boolean $return_first_found
* @return mixed
*/
public function getTranslation(Doctrine_Record $record, $name, $language = null, $return_first_found = true) {
if (in_array($name, $this->_fields)) {
$language = empty($language)?(string)$this->getLanguage():(string)$language;
if ($record->hasRelation('Translation')) {
if ($record->Translation->contains($language)) {
return $record->Translation[$language][$name];
} elseif ($return_first_found && $record->Translation->count()) {
foreach ($record->Translation as $translation)
if (!empty($translation[$name]))
return $translation[$name];
return null;
} else return null;
} else return null;
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/NotTranslatableException.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_NotTranslatableException("Field {$name} is not marked as easy getter to tranlsatable attribute in ".get_class($record));
}
}

/**
* Language dependent setter to translatable attribute
*
* @param Doctrine_Record $record
* @param string $name
* @param mixed $value
* @param string $language
* @return void
*/
public function setTranslation(Doctrine_Record $record, $name, $value, $language = null) {
if (in_array($name, $this->_fields)) {
$language = empty($language)?(string)$this->getLanguage():(string)$language;
if ($record->hasRelation('Translation')) {
$record->Translation[$language][$name] = $value;
}
} else {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/NotTranslatableException.php';
throw new Of_ExtDoctrine_I18n_EasyAccess_NotTranslatableException("Field {$name} is not marked as easy setter to tranlsatable attribute in ".get_class($record));
}
}

Видно, что они практически не отличаются от accessor'ов и mutator'ов из прошлой части и состоят из 2х методов фильтрации и 2х искусственных геттеров и сеттеров. Я их сделал потому что хотелось бы оставить возможность получить значение перевода для произвольного языка при случае.

Также в геттере появилась возможность отключать или включать возможность поиска первого переведенного значения в случае, когда перевод для текущего языка пуст. По умолчанию он включен, но надо будет как-нибудь вынести его в дополнительную опцию.

Подключение фильтра


Создадим запись аналогичную той, что в первой части и подключим фильтр.
class Product extends Doctrine_Record {

public function setTableDefinition() {
//....
$this->hasColumn('name', 'string', 255);
$this->hasColumn('description', 'string');
//....
}

public function setUp() {

$this->actAs('I18n', array(
'fields'=>array('name', 'description')
));

$i18nFilter = new Of_ExtDoctrine_I18n_EasyAccess_Filter(array(
'language'=>'en',
'fields'=>array('name', 'description') // используем все доступные для перевода поля
));
$this->getTable()->unshiftFilter($i18nFilter);
}

}

$record = new Product();
$record->description = 'my description'; // аналогично $record->Translation['en']->description

echo $record->description; // вывод аналогичен echo $record->Translation['en']->description

При доступе к аттрибуту «description» сработает фильтр и вернется значение для английского языка, а вот если обратится к неуказаному атрибуту фильтр кинет исключение, а следующий за ним фильтр Doctrine_Record_Filter_Compound скажет нам о том, что такого атрибута не существует
try {
echo $record->lol; // кинет эксепшн
} catch (Of_ExtDoctrine_I18n_EasyAccess_NotTranslatableException $e) {
//...
}

Аналогично будет и для записи в атрибут, я ее рассматривать не буду.

Интеграция с шаблоном


Раньше я рассматривал доступ к аттрибутам через шаблон Of_ExtDoctrine_I18n_Template. Попробуем изменить его таким образом что бы вместо hasAccessorMutator использовался наш фильтр. Попутно изменю и название, что бы оформить нашу систему в один пакет Of_ExtDoctrine_I18n_EasyAccess. Сразу привожу исходный код класса.
/**
* Temlate implements extrabehavior for standard Doctrine I18n template.
*
* @author OmeZ
* @version 1.7
* @license www.opensource.org/licenses/lgpl-license.php LGPL
* @package Of_ExtDoctrine_I18n_EasyAccess
*/
class Of_ExtDoctrine_I18n_EasyAccess_Helper extends Doctrine_Template implements Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface {

protected $_options = array(
'language'=>null,
'fields'=>null,
'disableFilter'=>false,
);

/**
* Holds default language for all behaviors
*
* @var string
*/
static protected $_defaultLanguage;

/**
* Holds language for current model behavior
*
* @var string
*/
protected $_language;

/**
* EasyAccess filter
*
* @var Of_ExtDoctrine_I18n_EasyAccess_Filter
*/
protected $_easyaccess_filter;

public function setUp() {

$language = $this->getOption('language');
if ($language) $this->setLanguage($language);

// Adds filter for access to properties, this can be used as stand-alone plugin
if (!$this->getOption('disableFilter')) {
require_once 'Of/ExtDoctrine/I18n/EasyAccess/Filter.php';
$this->_easyaccess_filter = new Of_ExtDoctrine_I18n_EasyAccess_Filter(array(
'owner'=>$this,
'language'=>null, // this value will make filter access to template getLanguage() method
'fields'=>$this->getOption('fields', array())
));
$this->_table->unshiftFilter($this->_easyaccess_filter);
}

// adds listener to manage Doctrine_Query hydrations. Will add translatable props as keys in
// array when HYDRATE_ARRAY, or mapped values in records. This can be used as stand-alone plugin
}

/**
* Returns default language for all behaviors
*
* @return string
*/
static public function getDefaultLanguage() {
return (string)self::$_defaultLanguage;
}

/**
* Sets default language for all behaviors
*
* @param string $language
* @return void
*/
static public function setDefaultLanguage($language) {
self::$_defaultLanguage = $language;
}

/**
* Returns current language behavior
*
* @return void
*/
public function getLanguage($without_static = false) {
if ($this->_language === null && !$without_static) return self::getDefaultLanguage();
else return (string)$this->_language;
}

/**
* Sets current behavior language
*
* @param $language
* @return string
*/
public function setLanguage($language) {
$this->_language = $language;
}
}

В шаблоне практически ничего не изменлось, мы просто избавились от методов чтения/записи в Translation, т.к. они перенесены в фильтр, а также метода __call() потому что он уже не нужен по тем же причинам.

Установка фильтра в таблицу происходит в методе setUp() как и раньше, добавлена опция disableFilter если по каким-то причинам нам нужно отлючить фильтр. В качестве владельца фильтра мы устанавливаем наш шаблон и передаем пустое заначение языка, что позволяет шаблону контролировать текущее значение языка фильтра, метод Of_ExtDoctrine_I18n_EasyAccess_OwnerInterface::getLanguage() у нас уже реализован. Все, остается подключить шаблон в нашей демо-записи и провести тесты
class Product extends Doctrine_Record {

public function setTableDefinition() {
//....
$this->hasColumn('name', 'string', 255);
$this->hasColumn('description', 'string');
//....
}

public function setUp() {

$this->actAs('I18n', array(
'fields'=>array('name', 'description')
));

$this->actAs(new Of_ExtDoctrine_I18n_EasyAccess_Helper(array(
'fields'=>array('name', 'description') // используем все доступные для перевода поля
)));

}

}

$record = new Product();

$record->description = 'my description'; // аналогично $record->Translation['en']->description
echo $record->description; // вывод аналогичен echo $record->Translation['en']->description

$record->setLanguage('ru'); // установим русский язык по дефолту

echo $record->description; // выведет 'my description', т.к. перевода для русского нет и фильтр ищет первый доступный перевод


Заключение


Такая реализация доступа гораздо удобнее, и, как оказалось, гораздо быстрее способа описаного ранее. Следующим шагом будет возможность доступа к атрибутам при различных методах гидрации, например в Doctrine::HYDRATE_ARRAY, т.к. там не создается объектов и мы все равно будем вынуждены пользоваться Translation-ассоциацией (в данном случае вложеным массивом).

Все классы одним архивом можно найти здесь

PS. Внимательный читатель наверняка мог заметить, что в фильтре повторяется обвяз для полей и языка, описаный в шаблоне. Это было использовано для того, что бы можно было использовать фильтр как stand-alone компонент не прибегая к шаблону, что я считаю хорошим тоном для подобного рода систем.

В этой статье код был подсвечен при помощи Source Code Highlighter.
Теги:i18nDoctrine_I18nбыстрый доступфильтрыDoctrine_Template
Хабы: Doctrine ORM
Всего голосов 10: ↑6 и ↓4 +2
Просмотры1.5K

Похожие публикации

Full-stack разработчик
до 160 000 ₽Робот КарлМожно удаленно
PHP / Bitrix программист
от 140 000 до 200 000 ₽Articul TechnologyМожно удаленно
Junior frontend разработчик
от 55 000 до 80 000 ₽CSSSRМожно удаленно
Ведущий PHP разработчик (Удаленно)
от 230 000 ₽WEBINARМожно удаленно
Senior backend developer (PHP)
от 300 000 ₽Финолаб.руМожно удаленно

Лучшие публикации за сутки