Caution: You are browsing the legacy symfony 1.x part of this website.

Cómo escribir un comportamiento (*behavior*) de Propel

1.2
Symfony version
1.1
Language

traducido por arhak

Introducción

Cuando ocurre que se tiene que escribir dos veces el mismo método para dos clases del modelo de objetos de Propel, es tiempo de pensar en Comportamientos (Behaviors). Los comportamientos ofrecen una forma simple de extender varias clases del modelo de la misma forma, ya sea alterando métodos existentes o añadiendo nuevos. Utilizar comportamientos existentes es muy simple: Leer la parte relacionada en el Capítulo 8 del libro, y seguir las instrucciones escritas en el fichero README contenido en cada comportamiento. Pero si se quiere crear un nuevo comportamiento, entonces es necesario entender cómo funcionan.

Ejemplo base

Para ilustrar el proceso de crear un comportamiento, comencemos con un modelo ya extendido. Por ejemplo, imaginemos que por razones de seguridad, los registros (records) de la tabla article no deben ser removidos de la base de datos. A pesar de ellos, el método $article->delete() debe marcar los registros para que no sean retornados por una invocación a ArticlePeer::doSelect(), pero los datos correspondientes no deben ser borrados. Así es cómo se podría extender el modelo de Propel para implementar esta regla:

// en lib/model/Article.php
class Article extends BaseArticle()
{
  public function delete($con = null)
  {
    $this->setDeletedAt(time());
    $this->save($con);
  }
}
 
// en lib/model/ArticlePeer.php
class ArticlePeer extends BaseArticlePeer()
{
  public function doSelectRS(Criteria $criteria, $con = null)
  {
    $criteria->add(self::DELETED_AT, null, Criteria::ISNULL);
 
    return parent::doSelectRS($criteria, $con);
  }
}

Por supuesto, eso implica añadir un nuevo campo de tipo timestamp llamado deleted_at a la tabla article.

Nota: La razón por la cual se le aplica la extensión de método a doSelectRS() en lugar de doSelect() es porque el primero es utilizado no solo por doSelect(), sino también por doCount().

La combinación de un nuevo campo y métodos alterados le da al objeto Article un comportamiento "paranoico". Por ahora, la palabra "comportamiento" solo se refiere a un conjunto de métodos.

Adentrándose en Mixins

Ahora, imaginemos que se necesita mantener también los registros borrados de la tabla comment. En lugar de copiar los dos métodos anteriores en las clases Comment y CommentPeer, lo cual no sería D.R.Y., se debería refactorizar el código utilizado más de una vez en una nueva clase, e inyectarlo vía el sistema Mixins. El lector debería estar familiarizado con el concepto de Mixins y la clase sfMixer para entender lo que sigue, así que puede consultar el Capítulo 17 del libro de symfony si se está preguntando de qué se trata.

El primer paso es remover cualquier código de las clases del modelo, y añadirles ganchos (hooks) para permitirles ser extendidas.

// Paso 1
// en lib/model/Article.php
class Article extends BaseArticle()
{
  public function delete($con = null)
  {
    foreach (sfMixer::getCallables('Article:delete:pre') as $callable)
    {
      $ret = call_user_func($callable, $this, $con);
      if ($ret)
      {
        return;
      }
    }
 
    return parent::delete($con);
}
 
// en lib/model/ArticlePeer.php
class ArticlePeer extends BaseArticlePeer()
{
  public function doSelectRS(Criteria $criteria, $con = null)
  {
    foreach (sfMixer::getCallables('ArticlePeer:doSelectRS:doSelectRS') as $callable)
    {
      call_user_func($callable, 'ArticlePeer', $criteria, $con);
    }
 
    return parent::doSelectRS($criteria, $con);
  }
}
 
// en lib/model/Comment.php
class Comment extends BaseComment()
{
  public function delete($con = null)
  {
    foreach (sfMixer::getCallables('Comment:delete:pre') as $callable)
    {
      $ret = call_user_func($callable, $this, $con);
      if ($ret)
      {
        return;
      }
    }
 
    return parent::delete($con);
}
 
// en lib/model/CommentPeer.php
class CommentPeer extends BaseCommentPeer()
{
  public function doSelectRS(Criteria $criteria, $con = null)
  {
    foreach (sfMixer::getCallables('CommentPeer:doSelectRS:doSelectRS') as $callable)
    {
      call_user_func($callable, 'CommentPeer', $criteria, $con);
    }
 
    return parent::doSelectRS($criteria, $con);
  }
}

A continuación, se debe poner el código del comportamiento en una nueva clase, salvar esta clase en un directorio donde pueda ser autocargada (autoloaded):

// Paso 2
// en lib/ParanoidBehavior.php
class ParanoidBehavior
{
  public function preDelete($object, $con)
  {
    $object->setDeletedAt(time());
    $object->save($con);
 
    return true;
  }
 
  public function doSelectRS($class, Criteria $criteria, $con = null)
  {
    $criteria->add(constant("$class::DELETED_AT"), null, Criteria::ISNULL);
  }
}

Finalmente, se deben registrar los métodos de la nueva clase ParanoidBehavior en los ganchos de las clases Article y Comment:

// Paso 3
// en config/config.php
sfMixer::register('Article:delete:pre', array('ParanoidBehavior', 'preDelete'));
sfMixer::register('ArticlePeer:doSelectRS:doSelectRS', array('ParanoidBehavior', 'doSelectRS'));
sfMixer::register('Comment:delete:pre', array('ParanoidBehavior', 'preDelete'));
sfMixer::register('CommentPeer:doSelectRS:doSelectRS', array('ParanoidBehavior', 'doSelectRS'));

Gracias al poder de los Mixins, el código del comportamiento puede ser reusado en varios objetos del modelo.

Pero la tarea de añadir ganchos a las clases del modelo y registrar los métodos hacen este proceso más largo que una simple copia de código... Es aquí donde los Comportamientos de symfony vienen como gran ayuda.

Añadir ganchos al modelo automáticamente

Symfony puede añadir ganchos al modelo automáticamente. Para habilitar estos ganchos, basta con poner a true la propiedad AddBehaviors en el fichero propel.ini, como se indica a continuación:

propel.builder.AddBehaviors = true     // El valor por defecto es false

Se necesita reconstruir (rebuild) el modelo para que los ganchos sean insertados en las clases del modelo generadas:

$ php symfony propel-build-model

Los ganchos son añadidos a las clases Base, las que se encuentran bajo el directorio lib/model/om/. Por ejemplo, aquí se tienen un extracto de la clase generada BaseArticlePeer con los ganchos de comportamientos habilitados:

public static function doSelectRS(Criteria $criteria, $con = null)
{
  foreach (sfMixer::getCallables('BaseArticlePeer:doSelectRS:doSelectRS') as $callable)
  {
    call_user_func($callable, 'BaseArticlePeer', $criteria, $con);
  }
 
  // Resto del código
}

Eso es casi exactamente el mismo gancho que el añadido manualmente en ArticlePeer durante el paso 1. La diferencia es que el nombre del gancho registrado es BaseArticlePeer:doSelectRS:doSelectRS en lugar de ArticlePeer:doSelectRS:doSelectRS. Entonces se puede remover el código añadido a la clase personalizada durante el Paso 1. Esto significa que cuando los Comportamientos están habilitados en propel.ini no es necesario añadir ganchos manualmente dentro de las clases del modelo.

Como el nombre de los ganchos cambió (ahora todos tienen el prefijo Base), la forma en que los métodos del comportamiento Paranoico fueron registrados en el Paso 3 debe ser cambiada. Pero antes de hacerlo, observe la lista complete de ganchos añadidos:

// Ganchos añadidos a la clase base de objeto
[nombreClase]:delete:pre     // antes de la eliminación
[nombreClase]:delete:post    // después de la eliminación
[nombreClase]:save:pre       // antes de salvar
[nombreClase]:save:post      // después de salvar
[nombreClase]:[nombreMetodo] // dentro de __call() (permite nuevos métodos)
// Ganchos añadidos a la clase base Peer
[NombreClasePeer]:doSelectRS:doSelectRS
[NombreClasePeer]:doSelectJoin:doSelectJoin
[NombreClasePeer]:doSelectJoinAll:doSelectJoinAll
[NombreClasePeer]:doSelectJoinAllExcept:doSelectJoinAllExcept
[NombreClasePeer]:doUpdate:pre
[NombreClasePeer]:doUpdate:post
[NombreClasePeer]:doInsert:pre
[NombreClasePeer]:doInsert:post

Nota: Desde symfony 1.0, solamente existe un gancho relacionado con los métodos doSelect, en lugar de los cuatro ganchos descritos anteriormente. This explains why some behaviors work only with symfony 1.1 and not with symfony 1.0, which has an incomplete support for behaviors.

Añadiendo nuevos métodos

Uno de los ganchos que debe ser observado más de cerca: el que permite nuevos métodos en la clase objeto. Cuando los comportamientos están habilitados en propel.ini, todas las clases base de objeto generadas contienen un método __call() similar a este:

// en lib/model/om/BaseArticle.php
public function __call($method, $arguments)
{
  if (!$callable = sfMixer::getCallable('BaseArticle:'.$method))
  {
    throw new sfException(sprintf('Call to undefined method BaseArticle::%s', $method));
  }
 
  array_unshift($arguments, $this);
 
  return call_user_func_array($callable, $arguments);
}

Como se explica en el Capítulo 17 del libro de symfony, un gancho hubicado en __call() hace posible la adición de nuevos métodos en tiempo de ejecución. Por ejemplo, si se quiere añadir un método undelete() a la clase Article para permitir borrar la marca (flag) de deleted_at, comience por añadirlo a la clase del Comportamiento:

// En lib/ParanoidBehavior.php
public function undelete($object, $con)
{
  $object->setDeletedAt(null);
  $object->save($con);
}

Entonce, registre el nuevo método como sigue:

// en config/config.php
sfMixer::register('BaseArticle:undelete', array('ParanoidBehavior', 'undelete'));
// otros ganchos

Ahora, cada invocación a $article->undelete() hará una invocación a ParanoidBehavior::undelete($article).

Nota: Desafortunadamente, desde PHP 5, la invocación a métodos estáticos no puede ser capturada por un __call(). Esto significa que los comportamientos de symfony no pueden añadir nuevos métodos a las clases Peer.

Registrar ganchos en un solo paso

Todavía falta reescribir la forma en que se registran los otros ganchos para usar los nombres de ganchos Base, para ambas clases Article y Comment. Eso daría algo como:

// Paso 3
// en config/config.php
sfMixer::register('BaseArticle:undelete', array('ParanoidBehavior', 'undelete'));
sfMixer::register('BaseArticle:delete:pre', array('ParanoidBehavior', 'preDelete'));
sfMixer::register('BaseArticlePeer:doSelectRS:doSelectRS', array('ParanoidBehavior', 'doSelectRS'));
 
sfMixer::register('BaseComment:undelete', array('ParanoidBehavior', 'undelete'));
sfMixer::register('BaseComment:delete:pre', array('ParanoidBehavior', 'preDelete'));
sfMixer::register('BaseCommentPeer:doSelectRS:doSelectRS', array('ParanoidBehavior', 'doSelectRS'));

Pero este código no es muy D.R.Y., ya que se necesita repetir toda la lista de métodos para cada clase. ¡Imagine lo doloroso que sería si el comportamiento tuviese docenas de métodos! Sería mucho más eficiente si se pudiese separar el proceso de registrar en dos fases:

  1. Registrar los métodos del comportamiento en una lista de ganchos independientes de clase (class-agnostic).
  2. Para cada clase, transformar el gancho independiente de clase en ganchos reales y registrarlos utilizando el sistema de mixins.

Symfony provee una clase utilitaria, llamada sfPropelBehavior, la cual facilita este trabajo. Así es como el Paso 3 puede ser reescrito para tomar ventaja de dicha clase:

// Fase 1
// en config/config.php
sfPropelBehavior::registerMethods('paranoid', array(
  array('ParanoidBehavior', 'undelete')
));
sfPropelBehavior::registerHooks('paranoid', array(
  ':delete:pre'                => array('ParanoidBehavior', 'preDelete'),
  'Peer:doSelectRS:doSelectRS' => array('ParanoidBehavior', 'doSelectRS')
));
 
// Fase 2
// en lib/model/Article.php
sfPropelBehavior::add('Article', array('paranoid'));
 
// en lib/model/Comment.php
sfPropelBehavior::add('Comment', array('paranoid'));

Ambos métodos registerMethods y registerHooks esperan un nombre de lista de ganchos como primer parámetro. Este nombre es utilizado entonces como atajo cuando los métodos del comportamiento son añadidos a las clases del modelo. Observe cómo los nombres de gancho utilizados cuando se invoca registerHooks no contienen ninguna referencia a una clase específica del modelo (la parte BaseArticle del nombre del gancho fue removida).

Por otra parte, no es necesario especificar un nombre de método para los métodos añadidos por vía de registerMethods. El nombre del método en la clase del comportamiento es utilizado por defecto.

Solamente cuando la instrucción sfPropelBehavior::add() es ejecutada es que los ganchos son realmente registrados contra la clase sfMixer... con un nombre real de gancho. Como el primer parámetro de esta invocación es un nombre de clase del modelo, la clase sfPropelBehavior tiene todos los elementos para recrear los nombres completos del gancho (en este caso, concatenando la cadena Base con el nombre de la clase del modelo y el nombre del gancho del comportamiento).

Empaquetando un comportamiento en un plug-in

Para empaquetar el comportamiento dentro de una pieza de código verdaderamente reusable, lo mejor es crear un plugin.

Hay un convenio no excrito acerca de los nombres de los plugins de comportamientos. Deben ser prefijados con 'Propel' dado que solamente funcionan para este ORM, y deben terminar con 'BehaviorPlugin'. Entonces un buen nombre para nuestro comportamiento Paranoico podría ser 'myPropelParanoidBehaviorPlugin'.

Hasta ahora, solo hay dos ficheros para poner en el plugin: la clase ParanoidBehavior, y el código escrito en config/config.php para registrar los métodos y ganchos del comportamiento. El Capítulo 17 explica cómo organizar estos ficheros en una estructura de árbol de plugin:

plugins/
  myPropelParanoidBehaviorPlugin/
    lib/
      ParanoidBehavior.php    // la clase que contiene los métodos para ser mezclados (*mixed in*)
    config/
      config.php              // registrar los métodos del comportamiento

El fichero config.php de cada plugin instalado en un proyecto es ejecutado en cada petición (request), por tanto este es el lugar perfecto para registrar los métodos del comportamiento.

Para completar el plugin, se debe adicionar un fichero README en la raíz del directorio del plugin, con instrucciones de instalación y modo de empleo. Los mejores comportamientos también incluyen pruebas de unidad (unit tests).

Finalmente, adicione un package.xml (ya sea manualmente o mediante sfPackageMakerPlugin), empaquete el plugin con PEAR, y está listo para ser usado. También puede ser publicado en el sitio web de symfony.

Pasando un parámetro a un comportamiento

Un comportamiento bien diseñado no hace uso de valores clavados en el código (hard coded). En el ejemplo anterior del comportamiento Paranoico, el nombre de la columna deleted_at está clavado en el código y debería ser transformado en un parámetro.

Para pasar un parámetro a un comportamiento, se utiliza un arreglo asociativo como segundo parámetro a la invocación de sfPropelBehavior::add() en lugar de un arreglo ordinario, como se muestra a continuación:

sfPropelBehavior::add('Article', array('paranoid' => array(
  'column' => 'deleted_at'
)));

Entonces, para tomar el valor de este parámetro en la clase del comportamiento, se debe usar el registro sfConfig. El parámetro es almacenado en una entrada sfConfig compuesta así:

'propel_behavior_' . [BehaviorName] . '_' . [ClassName] . '_' . [ParameterName]
// en el ejemplo arriba, se toma el valor 'deleted_at' invocando
sfConfig::get('propel_behavior_paranoid_Article_column')

El problema es que los métodos del comportamiento no utilizan solamente nombres de columa. Utilizan varias versiones de estos nombres acorde con la operación que se pretenda:

Nombre de formato           | Ejemplo       | Utilizado en
----------------------------|---------------|-------------
`BasePeer::TYPE_FIELDNAME`  | `deleted_at`  | schema.yml
`BasePeer::TYPE_PHPNAME`    | `DeletedAt`   | Nombres de métodos
`BasePeer::TYPE_COLNAME`    | `DELETED_AT`  | Parámetros de criteria.

Entonces la clase del comportamiento necesitará una forma de traducir un nombre de campo de un formato a otro. Afortunadamente, la clase Base Peer generada de cada modelo provee un método estático translateFieldName(). Su sintaxis es bastante simple:

// translateFieldName($name, $origin_format, $dest_format)
// por ejemplo
$name = ArticlePeer::translateFieldName('deleted_at', BasePeer::TYPE_FIELDNAME, BasePeer::TYPE_COLNAME);

Entonces ahora se puede reescribir la clase ParanoidBehavior para tomar el parámetro column en cuenta:

class sfPropelParanoidBehavior
{
  public function preDelete($object, $con = null)
  {
    $class = get_class($object);
    $peerClass = get_class($object->getPeer());
 
    $columnName = sfConfig::get('propel_behavior_paranoid_'.$class.'_column', 'deleted_at');
    $method = 'set'.call_user_func(array($peerClass, 'translateFieldName'), $columnName, BasePeer::TYPE_FIELDNAME, BasePeer::TYPE_PHPNAME);
    $object->$method(time());
    $object->save();
 
    return true;
  }
 
  public function doSelectRS($class, $criteria, $con = null)
  {
    $columnName = sfConfig::get('propel_behavior_paranoid_'.$class.'_column', 'deleted_at');
    $criteria->add(call_user_func(array($class, 'translateFieldName'), $columnName, BasePeer::TYPE_FIELDNAME, BasePeer::TYPE_COLNAME), null, Criteria::ISNULL);
  }
}

Conclusión

Los comportamientos de Propel no son más que un conjunto de ganchos predefinidos, y una clase ayudante (helper) diseñada para facilitar el proceso de registrar varios ganchos en una sola instrucción. Si usted entiende los Mixins, no debería resultarle muy difícil crear su propios comportamientos. Asegúrese de que verifica plugins de comportamientos existentes antes de comenzar uno por su cuenta: hay ejemplo prácticos de la sintaxis de los comportamientos.

Nota del traductor: Los ejemplos carecen de traducción en lo referente a nombres de tablas, columnas y clases de objetos, debido a que ciertos nombres de columnas tienen que ser en inglés para que su funcionamiento por defecto funcione tal y como se dice en el ejemplo. Por ejemplo, las columnas created_at, updated_at, entre otras son manejadas por symfony y/o plugins automáticamente, lo cual no evita que puedan ser nombradas por ejemplo fecha_de_creacion y fecha_de_modificacion, solo que en tales casos habría que realizar algunos ajustes adicionales con los que no cuentan los ejemplos. Por otra parte, los objetos creados por el ORM contarán con getters y setters por lo cual resultaría bastante conveniente evitar la mezcla de idiomas en el nombre de dichas funciones.