Si alguna vez os habéis parado a ver el código generado por el modelo, podréis observar que la clase Base es más o menos así:

<?php 
abstract class BaseItem extends sfDoctrineRecord
{
    public function setTableDefinition()
    {
       // Aquí el código de definición de la tabla
    }
 
    public function setUp()
    {
       // Aquí las relaciones y los behaviours
    }
}
?>

Algo que siempre me había llamado la atención de este código es, ¿por qué la clase base extiende de sfDoctrineRecord en lugar de hacerlo directamente de Doctrine_Record?

Esta pregunta no tiene una única respuesta, ya que son varias funcionalidades las que añade este wrapper:

  • Funcionalidades específicas para objetos I18n. Añade un proxy al objeto Translate de manera que si al acceder a un atributo del objeto no se encuentra, intentará buscarlo entre los de su objeto Translate asociado (para la cultura definida en ese momento) antes de lanzar una exception (algo parecido a esto)
  • Getter y Setter para las relaciones de objeto, de manera que podamos tratar “igual” tanto a las relaciones como los atributos de las tablas, una funcionalidad al más estilo Propel, :)
  • Hidratación específica para atributos Date y Timestamp. sfDoctrineRecord añade un método, sfDoctrineRecord::getDateTimeObject, que permite obtener los campos de fecha y timestamp no como una cadena de texto, sino como un objeto DateTime. De manera análoga, sfDoctrineRecord::setDateTimeObject() permite fijar el valor de un campo date o timestamp a partir de un campo DateTime en lugar de una cadena de texto correctamente formateada, algo muy útil en aplicaciones internacionalizadas donde las fechas se pueden insertar de diferentes maneras y requieren un trato previo en función de la cultura del usuario.

Hoy se me ha planteado una curiosa situación: necesitaba definir el modelo de datos necesario para unas noticias que iban en varios idiomas y cada una necesitaba tener por URL el título sluggabilizado(¿?) en ese idioma.

Cuando necesito internacionalizar los campos de una tabla uso el behaviour I18 de Doctrine. Por otro lado, cuando necesito URL amigables acudo al behaviour Sluggable. En este caso el problema es que los behaviours están anidados, esto es, la tabla noticias debe ser i18n pero a su vez, el slug se debe construir a partir del campo título internacionalizado.

Después de todo este jaleo, la solución ha resultado ser más simple de lo que me espera:

NewsItem:
  actAs:
    I18n:
      fields: [title, body]
      actAs:
        Sluggable:
          unique: true
          fields:  [title]
          canUpdate: true
  columns:
    title:            string(255)
    body:             clob
    published_at:     date

Pongamonos en situación, tenemos un listado generado por el AdminGenerator. Decidimos añadir una nueva columna a la lista, algo tremendamente fácil con sólo añadir un nuevo getter al objeto del modelo pero …. (siempre hay un pero) no podemos ordenar por ese nuevo campo, :( .

Bueno, esa situación era hasta hoy, ya que intentaré exponer lo tremendamente sencillo que es conseguir ordenar por una columna virtual.

En primer lugar, y para trabajar sobre un ejemplo “real” consideremos el archi-conocido caso en el que tenemos un modelo de datos formado por usuarios (sfGuardUser) y sus correspondientes perfiles (Profile) guardando una relación de 1:1 y teniendo la tabla Profile la siguiente estructura:

Profile:
  columns:
    full_name: { type: string(255) }
    email:     { type: string(255) }

Cómo era la situación hasta hoy

Consideremos el caso hipotético de que para el desarrollo de una aplicación tenemos que mostrar un listado con todos los usuarios con las siguientes columnas: nombre de usuario, nombre real y email. Hasta hoy, el proceso a seguir era,

1) creamos dos nuevos getter en el objeto sfGuardUser para las dos nuevas columnas virtuales:

// lib/model/sfGuardUser.class.php
<?
class sfGuardUser extends BasesfGuardUser{
 
  public function getFullName()
  {
    $this->Profile->full_name;
  }
 
  public function getEmail()
  {
    $this->Profile->email;
  }
?>

Te podías haber ahorrado definir estos getters si en lugar de esto hubieses creado directamente un proxy del objeto Profile, aunque eso es otra historia

2) Modificamos el archivo generator.yml para que muestre estos dos nuevas columnas virtuales:

      list:
        title:   Listado de Usuarios
        display: [=username, full_name, email]

3) El
Listado de Usuarios con columnas virtuales
pero no podríamos ordenar ni por full_name ni email … hasta hoy! Vamos allá!

Desvirtualizando los campos virtuales

Lo primero que debemos hacer es desvirtualizar los campos que eran virtuales, para ello cambiamos el método que utiliza el AdminGenerator para recoger el listado de elementos:

  list:
    title:   Listado de Usuarios
    display: [=username, full_name, email]
    table_method: doSelectAdmin

debiendo de crear este método en el objeto de la tabla correspondiente

// lib/model/sfGuardUserTable.class.php
<?php
class sfGuardUserTable extends Doctrine_Table{
 
  public static function doSelectAdmin()
  {
    return Doctrine_Query::create()
      ->select('u.username as username, p.full_name as full_name, p.email as email')
      ->from('sfGuardUser u')
      ->leftJoin('u.Profile p');
  }
}
?>

Notemos que el nombre de los campos de la sentencia select coincide con el nombre de los campos en list.display.

Después de esto tendríamos el mismo resultado que en el caso anterior, sin poder seguir ordenando por estas dos nuevas columnas.

Ya no es virtual, ahora es real

Para que symfony sustituya el texto de la cabecera de la tabla por un enlace debemos de indicarle de manera explícita que estos campos son reales y no virtuales. Esto se lo indicamos en el generator.yml:

#generator.yml
fields:
  full_name: { is_real: true }
  email:       { is_real: true }

Resulta curioso que este parámetro no venga en ningún sitio en la documentación de symfony (o al menos, yo no lo he sabido encontrar).

Con esto conseguiremos que el texto de la cabecera (i.e: “Email”, “Nombre”) sea ahora un enlace, aunque si haces click verás que no ocurre nada, esto se debe a que symfony pone una traba más que hay que sortear.

Sí, mis campos son reales y además son “ordenables”

Finalmente hay que indicarle a symfony que sí que queremos poder ordenar por esa columna, por lo que será necesario reescribir un método:

<?php
// /modules/sfGuardUser/actions/actions.class.php
 
class sfGuardUserActions extendes autosfGuardUserActions{
 
    protected function isValidSortColumn($column)
    {
      return in_array($column, array('username', 'full_name', 'email');
    }
}
?>

Puesto que no se muy bien como justificar una introducción a este post (de hecho tampoco sé si el título es el más adecuado), voy a poneros simplemente en situación.

Tenemos instalado el plugin sfDoctrineGuardPlugin y necesitamos guardar información “extra” sobre nuestros usuarios, por ejemplo, el email y el nombre. En este caso, y de acuerdo con las indicaciones de @jwage, crearíamos una nueva tabla Profile que guarda una relación 1:1 con sfGuardUser, tal que así:

Profile:
  columns:
    sf_guard_user_id: integer(4)
    full_name: string(255)
    email_address: string(255)
  relations:
    User:
      class: sfGuardUser
      local: sf_guard_user_id
      foreign: id
      foreignType: one

Hasta aquí, nada nuevo bajo el sol. Ahora, supongamos que queremos acceder al nombre de un usuario, el código para esto sería:

  $user->Profile->full_name;

Sencillo, ¿no? Pero, puesto que la relación es de 1:1, en el fondo de nuestro corazón nos gustaría que esto,

  $user->full_name;

bastase para obtener ese valor. ¿Cómo lo conseguimos?

Solución rápida y para toda la familia
La forma más rápida y obvia de conseguir lo anterior es también la más sencilla, basta con esto

// lib/model/doctrine/sfDoctrineGuardPlugin/sfGuardUser.class.php
 
  public function getFullName()
  {
    return $this->Profile->full_name;
  }

pero, ¿qué ocurre si también queremos recoger el email?¿y una dirección?¿y el teléfono?¿y …? De acuerdo a este planteamiento tendremos que crear un nuevo getter en el objeto sfGuardUser por cada atributo en el objeto Profile. Por suerte, existe una solución “mejor”.

La solución “mágica”
PHP dispone de algo muy interesante llamado métodos mágicos. La solución que se muestra más abajo está basada en la utilización que hace Doctrine, en su objeto Doctrine_Record, del método mágico __get($fieldName).

// lib/model/sfGuardUser.class.php
class sfGuardUser extends sfDoctrineRecord
{
  public function get($fieldName, $type = true)
  {
    try{
      parent::get($fieldName, $type = true);
    }catch(exception $e){
      $this->Profile->$fieldName;
    }
  }
}

Aunque no entraré a explicar en detalle que es todo lo que ocurre, grosso modo, cada vez que se pide un atributo al objeto sfGuardUser busca entre los suyos propios y en caso de que no lo encuentre, lo intentará buscar entre los del su Profile. En caso de que tampoco los encuentre, saltará una excepción.

Algunas ventajas
Aparte de las ventajas obvias de poder obtener el valor de los atributos del objeto Profile de manera transparente, también podemos utilizar en el admin generator para mostrar estos campos, por ejemplo, en el listado:

  list:
    display: [username, full_name, email]
Symfony y Desarrollo Web © Copyright 2009, All Rights Reserved.