Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages showcasing Symfony with Docker, APIs, queues & async tasks, Webpack, SPAs, etc.

Día 6: Más acerca del Modelo

1.4 / Doctrine
Symfony version
1.2
Language ORM

Ayer fue un gran día. Aprendiste como crear URLs amigables y como usar el framework Symfony para automatizar un montón de cosas por ti.

Hoy, mejoraremos el sitio web Jobeet afinando el código aquí y allá. En el proceso, aprenderás más acerca de todas las características que hemos introducido durante los primeros cinco dias de este tutorial.

El Objeto Query de Doctrine

De los requisitos del día 2:

"Cuando un usuario llega al sitio de Jobeet, verá una lista de los puestos de trabajos activos."

Pero hasta ahora, todos los puestos de trabajo serán mostrados, sea que estén activos o no:

// apps/frontend/modules/job/actions/actions.class.php
class jobActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $this->jobeet_jobs = Doctrine::getTable('JobeetJob')
      ->createQuery('a')
      ->execute();
  }
 
  // ...
}

Un puesto de trabajo activo es uno que fue envíado hace menos de 30 días. El método Doctrine_Query::execute() ejecutará una petición contra la base de datos. En el código anterior, no hemos especificado ninguna condición lo que significa que todos los registros son obtenidos de la base de datos.

Cambiemos para que solo seleccione los puestos de trabajo activos:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.created_at > ?', date('Y-m-d h:i:s', time() - 86400 * 30));
 
  $this->jobeet_jobs = $q->execute();
}

Depurando por Doctrine el SQL generado

Como no escribiste ninguna sentencia SQL a mano, el Doctrine cuidará de las diferencias que hay entre los motores de base de datos y generará las sentencias SQL optimizadas para el motor de la base de datos que elejíste el día 3. Pero algunas veces, es de gran ayuda para ver el SQL generado por el Doctrine; por ejemplo, para depurar una consulta que no funciona como esperamos. En el entorno dev, symfony registra esas consultas (junto a otras muchas más) en el directorio log/. Hay un archivo log para cada combinacion de aplicación y entorno. El archivo que estámos buscando es frontend_dev.log:

# log/frontend_dev.log
Dec 04 13:58:33 symfony [info] {sfDoctrineLogger} executeQuery : SELECT 
j.id AS j__id, j.category_id AS j__category_id, j.type AS j__type, 
j.company AS j__company, j.logo AS j__logo, j.url AS j__url, 
j.position AS j__position, j.location AS j__location, 
j.description AS j__description, j.how_to_apply AS j__how_to_apply, 
j.token AS j__token, j.is_public AS j__is_public, 
j.is_activated AS j__is_activated, j.email AS j__email, 
j.expires_at AS j__expires_at, j.created_at AS j__created_at, 
j.updated_at AS j__updated_at FROM jobeet_job j 
WHERE j.created_at > ? (2008-11-08 01:13:35)

Puedes ver por tí mismo que Doctrine tiene una claúsula where para la columna created_at (WHERE j.created_at > ?).

note

La cadena ? en la consulta indica que Doctrine genera una sentencia preparada. El valor actual de ? ('2008-11-08 01:13:35' en el ejemplo anterior) es pasado durante la ejecución de la consulta y escapado apropiadamente por el motor de la base de datos. El uso de sentencias preparadas dramáticamente reduce tu exposición a los ataques de inyecciones SQL.

Esto esta bueno, pero es bastante molesto tener que cambiar del navegador , al IDE, y el archivo log cada vez que necesitas probar un cambio. Gracias a la barra web de depuración de symfony, toda la información que necesitas esta también disponible dentro de la comodidad de tu navegador:

Sentencias SQL en la web debug toolbar

Serialización de Objetos

Aún si el código anterior funciona, esta lejos de ser perfecto ya que no toma en cuenta algunos requisitos del día 2:

"Un usuario puede volver a re-activar y extender la validez de un puesto de trabajo por 30 días extra..."

Pero ya que el código anterior solo se basa en el valor de created_at, y porque esta columna almacena el día de creación, no podemos satisfacer el requisito anterior.

Pero si recuerdas el esquema de la base de datos que describimos durante el día 3, también tenemos definido una columna expires_at. Actualmente este valor esta siempre vacío ya que este no se establece en el archivo de datos. Pero cuando un puesto de trabajo es creado, puede ser automáticamente establecido a 30 días del día actual.

Cuando necesitas hacer algo automáticamente antes que un objeto Doctrine sea guardado en la base de datos, puedes sobreescribir el método save() de la clase del modelo:

// lib/model/doctrine/JobeetJob.class.php
class JobeetJob extends BaseJobeetJob
{
  public function save(Doctrine_Connection $conn = null)
  {
    if ($this->isNew() && !$this->getExpiresAt())
    {
      $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
      $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * 30));
    }
 
    return parent::save($conn);
  }
 
  // ...
}

El método isNew() devuelve true cuando el objeto no ha sido serializado aún en la base de datos, y false de lo contratio.

Ahora, vamos a cambiar la acción para usar la columna expires_at en lugar de created_at para seleccionar los puestos de trabajo activos:

public function executeIndex(sfWebRequest $request)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
  $this->jobeet_jobs = $q->execute();
}

Restringimos la consulta para solo seleccionar los puestos de trabajo con un día expires_at en el futuro.

Con Datos

Actualizando la página de inicio de Jobeet en tu navegador vemos que no cambiamos ningún puesto de trabajo en la base de datos que habíamos dejado hace unos pocos días atrás. Vamos a cambiar el archivo fixtures para agregar un puesto de trabajo que ya haya expirado:

# data/fixtures/jobs.yml
JobeetJob:
  # other jobs
 
  expired_job:
    JobeetCategory: programming
    company:        Sensio Labs
    position:       Web Developer
    location:       Paris, France
    description:    Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply:   Send your resume to lorem.ipsum [at] dolor.sit
    is_public:      true
    is_activated:   true
    created_at:     '2005-12-01 00:00:00'
    token:          job_expired
    email:          [email protected]

note

Ten cuidado cuando copies y pegues códifo en un archivo de datos para no romper la indentación. El expired_job debe solo tener dos espacios en blanco después de si.

Recarga los datos y actualiza tu navegador para asegurarte que los viejos puestos de trabajo no se muestran más:

$ php symfony doctrine:data-load

También puedes ejecutar la siguiente consulta para asegurarte que la columna expires_at es automáticamente completada por el método save(), basado en el valor de created_at:

SELECT `position`, `created_at`, `expires_at` FROM `jobeet_job`;

Configuración Personalizada

En el método JobeetJob::save(), hemos tenido que hardcodear el número de días para que los puestos de trabajo expiren. Podría mejorarse haciendo que los 30 días sean configurables. El framework Symfony trae incluído un archivo de configuración para la configuración específica de una aplicación, el archivo app.yml. Este archivo de formato YAML puede contener cualquier configuración de desees:

# apps/frontend/config/app.yml
all:
  active_days: 30

En la aplicación, esas configuraciones están disponibles a través de la clase global sfConfig:

sfConfig::get('app_active_days')

El parámetro tiene un prefijo app_ porque la clase sfConfig también da acceso a la configuración de symfony como veremos más tarde.

Vamos a actualizar el código para tomar esta nueva configuración en cuenta:

public function save(Doctrine_Connection $conn = null)
{
  if ($this->isNew() && !$this->getExpiresAt())
  {
    $now = $this->getCreatedAt() ? $this->getDateTimeObject('created_at')->format('U') : time();
    $this->setExpiresAt(date('Y-m-d H:i:s', $now + 86400 * sfConfig::get('app_active_days')));
  }
 
  return parent::save($conn);
}

El archivo de configuración app.yml e una gran forma de centralizar configuraciones globales para tu aplicación.

Refactorizando

Todo el código escrito funciona bien, pero aún no esta del todo bien. ¿Puedes ver el problema?

El código Doctrine_Query no pertenece a la acción (capa del Controlador), sino que pertenece a la capa del Modelo. En el modelo MVC, el modelo define toda la lógicas de negocios, y el Controlador solo invoca al modelo para obtener los datos de éste. Como el código devuelve una colección de puestos de trabajo, vamos a mover el código a la clase JobeetJobTable y crear un método getActiveJobs():

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function getActiveJobs()
  {
    $q = $this->createQuery('j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Ahora el código de la acción puede usar este nuevo método para obtener los puestos de trabajo activos:

public function executeIndex(sfWebRequest $request)
{
  $this->jobeet_jobs = Doctrine_Core::getTable('JobeetJob')->getActiveJobs();
}

Esta refactorización tiene varios beneficios sobre el anterior código:

  • La lógica para obtener los puestos de trabajo activos está ahora en el modelo, donde pertenerce
  • El código en el controlador es mucho mas legible
  • El método getActiveJobs() es re-usable (por ejemplo en otra acción)
  • El código del modelo ahora puede ser probado con pruebas unitarias

Vamos a ordenar los puestos de trabajo por la columna expires_at:

public function getActiveJobs()
{
  $q = $this->createQuery('j')
    ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->orderBy('j.expires_at DESC');
 
  return $q->execute();
}

El método orderBy añade una claúsula ORDER BY al SQL generado (addOrderBy() también existe).

Categorías en la Página de Inicio

De los requisitos del día 2:

"Los puestos de trabajo son ordenados por categoría y entonces por la fecha de publicación (los nuevos primeros)."

Hasta ahora, no teníamos la categoría en cuenta. De los requisitos, la página de inicio debe mostrar los puestos de trabajo por categoría. Primero, necesitamos obtener todas las categorías con al menos un puesto de trabajo activo.

Abre la clase JobeetCategoryTable y agregale el método getWithJobs():

// lib/model/doctrine/JobeetCategoryTable.class.php
class JobeetCategoryTable extends Doctrine_Table
{
  public function getWithJobs()
  {
    $q = $this->createQuery('c')
      ->leftJoin('c.JobeetJobs j')
      ->where('j.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->execute();
  }
}

Cambia la acción index adecuadamente:

// apps/frontend/modules/job/actions/actions.class.php
public function executeIndex(sfWebRequest $request)
{
  $this->categories = Doctrine_Core::getTable('JobeetCategory')->getWithJobs();
}

En la plantilla, necesitamos iterar a través de todas las categorías y mostrar los puestos de trabajo activos:

// apps/frontend/modules/job/indexSuccess.php
<?php use_stylesheet('jobs.css') ?>
 
<div id="jobs">
  <?php foreach ($categories as $category): ?>
    <div class="category_<?php echo Jobeet::slugify($category->getName()) ?>">
      <div class="category">
        <div class="feed">
          <a href="">Feed</a>
        </div>
        <h1><?php echo $category ?></h1>
      </div>
 
      <table class="jobs">
        <?php foreach ($category->getActiveJobs() as $i => $job): ?>
          <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>">
            <td class="location">
              <?php echo $job->getLocation() ?>
            </td>
            <td class="position">
              <?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>
            </td>
            <td class="company">
              <?php echo $job->getCompany() ?>
            </td>
          </tr>
        <?php endforeach; ?>
      </table>
    </div>
  <?php endforeach; ?>
</div>

note

Para mostrar el nomre de la categoría en la plantilla, usamos echo $category. ¿Te suena raro? $category es un objeto, ¿Cómo puede echo mágicamente mostrar el nombre de la categoría? La respuesta fue dada durante el día 3 cuando teníamos que definir el método mágico __toString() para todas las clasese del modelo.

Para que funcione, necesitamos agregar el método getActiveJobs() a la clase JobeetCategory que devuelve los puestos de trabajo activos para el objeto categoría:

// lib/model/doctrine/JobeetCategory.class.php
public function getActiveJobs()
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId());
 
  return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q);
}

El método JobeetCategory::getActiveJobs() usa al método Doctrine::getTable('JobeetJob')->getActiveJobs() para obtener los puestos de trabajo activos para una categoría dada.

Cuando llamamos al Doctrine::getTable('JobeetJob')->getActiveJobs(), lo queremos para restringir la condición aún más para una categoría dada. En lugar de pasar el objeto categoría, tenemos decidido pasar el objeto Doctrine_Query ya que este es la mejor forma de encapsular una condición genérica.

El método getActiveJobs() necesita combinar este objeto Doctrine_Query con su propio consulta. Ya que Doctrine_Query es un objeto, esto es bastante simple:

// lib/model/doctrine/JobeetJobTable.class.php
public function getActiveJobs(Doctrine_Query $q = null)
{
  if (is_null($q))
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j');
  }
 
  $q->andWhere('j.expires_at > ?', date('Y-m-d h:i:s', time()))
    ->addOrderBy('j.expires_at DESC');
 
  return $q->execute();
}

Limitar los Resultados

Aún queda un requisito por implementar para la lista de puestos de trabajo de la página de inicio:

"Por cada categoría, la lista solo muestra los primeros 10 puestos de trabajo y un enlace que permite listar todos los puestos de una categoría dada."

Es tán simple de agregar al método getActiveJobs():

// lib/model/doctrine/JobeetCategory.class.php
public function getActiveJobs($max = 10)
{
  $q = Doctrine_Query::create()
    ->from('JobeetJob j')
    ->where('j.category_id = ?', $this->getId())
    ->limit($max);
 
  return Doctrine_Core::getTable('JobeetJob')->getActiveJobs($q);
}

La apropiada claúsula LIMIT es ahora hardcodeada dentro del Modelo, pero es mejor que este valor sea configurable. Cambia la plantilla para pasar el número máximo de puestos de trabajo establecido en app.yml:

<!-- apps/frontend/modules/job/indexSuccess.php -->
<?php foreach ($category->getActiveJobs(sfConfig::get('app_max_jobs_on_homepage')) as $i => $job): ?>

y agrega esta nueva configuración en app.yml:

all:
  active_days:          30
  max_jobs_on_homepage: 10

Página de Inicio ordenada por Categoría

Datos Dinámicos

A menos que bajes el max_jobs_on_homepage, no verás ninguna diferencia. Necesitamos agregar un paquete de puestos de trabajo a los archivos fixtures de datos. Por eso, puedes copiar y pegar uno existente, diez, o veinte veces a mano... pero hay una mejor manera. La duplicación esta mal, aún es archivos fixture.

¡Symfony al rescate! Los archivos YAML en symfony pueden tener código PHP que será evaluado justo antes de ser analizado. Edita el archivo de datos jobs.yml y añade el siguiente código al final:

JobeetJob:
# Starts at the beginning of the line (no whitespace before)
<?php for ($i = 100; $i <= 130; $i++): ?>
  job_<?php echo $i ?>:
    JobeetCategory: programming
    company:      Company <?php echo $i."\n" ?>
    position:     Web Developer
    location:     Paris, France
    description:  Lorem ipsum dolor sit amet, consectetur adipisicing elit.
    how_to_apply: |
      Send your resume to lorem.ipsum [at] company_<?php echo $i ?>.sit
    is_public:    true
    is_activated: true
    token:        job_<?php echo $i."\n" ?>
    email:        [email protected]
 
<?php endfor ?>

Ten cuidado, al analizar YAML no olvides ninguna indentación. Manten en mente los siguientes simples tips cuando añadas código PHP a un archivo YAML

  • La declaración <?php ?> debe siempre empezar la linea o ser incrustada en un valor.

  • Si una declaración <?php ?> finaliza una linea, necesitarás explícitamente agregar una nueva linea ("\n").

Puedes ahora recargar los archivos de datos con la tarea doctrine:data-load y ver si solo 10 puestos de trabajo son mostrados en la página de inicio para la categoría Programming. En la siguiente captura de pantalla, tenemos modificado el número máximo de puestos de trabajo a cinco para hacer una imágen mas pequeña:

Paginación

Asegurar la Página

Cuando un puesto de trabajo expira, aun sabiendo la URL, no debería ser posible acceder a él nunca más. Prueba con la URL para el puesto de trabajo expirado (reemplaza el id con el actual id en tu base de datos - SELECT id, token FROM jobeet_job WHERE expires_at < NOW()):

/frontend_dev.php/job/sensio-labs/paris-france/ID/web-developer-expired

En lugar de mostrar la información, necesitarás redirigir al usuario a una página 404. Perp, ¿Cómo puedo hacer esto cuando la info es cargada automaticamente vía la ruta?

# apps/frontend/config/routing.yml
job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfDoctrineRoute
  options:
    model: JobeetJob
    type:  object
    method_for_query: retrieveActiveJob
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

El método retrieveActiveJob() recibirá el objeto Doctrine_Query ya listo por parte de la ruta:

// lib/model/doctrine/JobeetJobTable.class.php
class JobeetJobTable extends Doctrine_Table
{
  public function retrieveActiveJob(Doctrine_Query $q)
  {
    $q->andWhere('a.expires_at > ?', date('Y-m-d h:i:s', time()));
 
    return $q->fetchOne();
  }
 
  // ...
}

Ahora, si tratas de ontener un puesto de trabajo expirado, serás enviadoa una página 404.

Error 404 para un puesto de trabajo expirado

Enlazar a la Página de la Categoría

Ahora, vamos a agregar un enlace a la página de la categorías en la página de inicio y crear dicha página.

Pero, aguarda un minuto. La hora no terminó aun y ya hemos trabajado mucho. Por eso, ¡estás libre y con suficiente conocimientos para hacer esto por tí mismo.! Vamos hacer el ejecicio. Revisa mañana nuestra implementción.

Nos vemos Mañana

Para trabajar sobre una implementación en tu proyecto local. Por favor, usa la API documentation y toda la documentation disponible en el sitio web de Symfony para ayudarte. Nos veremos tra vez mañana con nuestra implementación.

¡Suerte!

Feedback

tip

Este capítulo ha sido traducido por Roberto Germán Puentes Díaz. Si encuentras algún error que deseas corregir o realizar algún comentario, no dudes en enviarlo por correo a puentesdiaz [arroba] gmail.com