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

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

Время на прочтение16 мин
Количество просмотров1.7K
В прошлой статье я рассматривал один из способов быстрого доступа к переводимым атрибутам. Для того что бы понять о чем вообще идет речь, настоятельно рекомендуется прочитать укзаную статью перед этой ) Для тех, кто уже читал, напомню, что основной цимес состоял в искусственных гетерах и сетерах через 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.
Теги:
Хабы:
+2
Комментарии1

Публикации

Истории

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн