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 in full color showing how to combine Symfony with Docker, APIs, queues & async tasks, Webpack, Single-Page Applications, etc.

Buy printed version

Día trece del calendario de symfony: Etiquetas

1.0

Anteriormente en symfony

La aplicación askeet puede mostrar datos a través de una página web, un feed RSS, o email. Se puede formular preguntas y responderlas. Pero la organización de las preguntas está por desarrollar. Organizar las preguntas en categorías y subcategorías terminaría siendo una estructura en árbol inextricable (muy intrincada y confusa), con cientos de ramas y sin una manera sencilla de saber en qué rama está la pregunta que estás buscando.

No obstante, las aplicaciones web 2.0 vienen con una nueva forma de organizar los ítems: etiquetas. Las etiquetas son palabras, igual que las categorías. Pero la diferencia es que no hay una jerarquía de etiquetas, y que un ítem puede tener varias etiquetas. Mientras que buscar un gato con categorías podría resultar engorroso (animal/mamífero/cuadrúpedo/felino/, u otros misteriosos nombres de categorías), es muy fácil hacerlo con etiquetas (mascota+bonita). Incluye esta característica para que todos los usuarios añadan etiquetas a las preguntas, y tendrás el famoso concepto de folksonomía.

¿Lo adivinas? Eso es exactamente lo que vamos a hacer con las preguntas de askeet. Nos llevará algún tiempo (hoy y mañana), pero el resultado merece la pena. Además será la ocasión para mostrar cómo hacer consultas SQL complejas a la base de datos usando una conexión Creole. Comencemos.

La clase QuestionTag

Hay varias formas de implementar etiquetas. Nosotros elegimos añadir una tabla QuestionTag con la siguiente estructura:

ERD

Cuando un usuario etiqueta una pregunta, se crea un nuevo registro en la tabla question_tag, enlazada a las tablas user y question. Hay dos versiones de la etiqueta insertada: la introducida por el usuario, y una versión normalizada (en minúsculas, sin caracteres especiales) usada para la indexación.

Actualización del esquema

Como de costumbre, añadir una tabla a un proyecto de symfony se hace añadiendo al final del archivo schema.xml su definición Propel:

...
<table name="ask_question_tag" phpName="QuestionTag">
  <column name="question_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_question">
    <reference local="question_id" foreign="id" />
  </foreign-key>
  <column name="user_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_user">
    <reference local="user_id" foreign="id" />
  </foreign-key>
  <column name="created_at" type="timestamp" />
  <column name="tag" type="varchar" size="100" />
  <column name="normalized_tag" type="varchar" size="100" primaryKey="true" />
  <index name="normalized_tag_index">
    <index-column name="normalized_tag" />
  </index>
</table>

Reconstruye el modelo del objeto:

$ symfony propel-build-model

Clase personalizada

Añade un archivo nuevo Tag.class.php en el directorio askeet/lib/ con los siguientes métodos:

<?php
 
class Tag
{
  public static function normalize($tag)
  {
    $n_tag = strtolower($tag);
 
    // remove all unwanted chars
    $n_tag = preg_replace('/[^a-zA-Z0-9]/', '', $n_tag);
 
    return trim($n_tag);
  }
 
  public static function splitPhrase($phrase)
  {
    $tags = array();
    $phrase = trim($phrase);
 
    $words = preg_split('/(")/', $phrase, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
    $delim = 0;
    foreach ($words as $key => $word)
    {
      if ($word == '"')
      {
        $delim++;
        continue;
      }
      if (($delim % 2 == 1) && $words[$key - 1] == '"')
      {
        $tags[] = trim($word);
      }
      else
      {
        $tags = array_merge($tags, preg_split('/\s+/', trim($word), -1, PREG_SPLIT_NO_EMPTY));
      }
    }
 
    return $tags;
  }
}
 
?>    

El primer método devuelve una etiqueta normalizada, el segundo toma una frase como argumento y devuelve un array de etiquetas. Estos dos métodos serán de gran utilidad cuando manejemos etiquetas.

Lo interesante de añadir la clase en el directorio lib/ es que será cargada automáticamente y solo cuando sea necesario, sin necesidad de solicitarlo. Esto se llama autocarga.

Extender el modelo

En el nuevo archivo askeet/lib/model/QuestionTag.php, añade el siguiente método para crear la normalized_tag cuando un tag es creado:

public function setTag($v)
{
  parent::setTag($v);
 
  $this->setNormalizedTag(Tag::normalize($v));
}

La clase helper que acabamos de crear es de gran utilidad: reduce el código de este método a tan solo dos líneas.

Añadir algunos datos de prueba

Agrega un archivo al directorio askeet/data/fixtures/ con algunos datos de prueba de etiquetas:

QuestionTag:
  t1: { question_id: q1, user_id: fabien, tag: relatives }
  t2: { question_id: q1, user_id: fabien, tag: girl }
  t4: { question_id: q1, user_id: francois, tag: activities }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t5: { question_id: q2, user_id: fabien, tag: relatives }
  t5: { question_id: q2, user_id: fabien, tag: present }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t7: { question_id: q3, user_id: francois, tag: blog }
  t8: { question_id: q3, user_id: francois, tag: activities }

Asegúrate de que este archivo va después de los otros archivos del directorio en orden alfabético, de esta forma el objeto sfPropelData puede enlazar estos nuevos registros con los relacionados de las tablas QuestionyUser`. Ahora puedes repoblar tu base de datos con la llamada:

$ php batch/load_data.php

Ahora estamos listos para trabajar en las acciones de las etiquetas. Pero primero, extendamos el modelo para la clase Question.

Mostrar las etiquetas de una pregunta

Antes de añadir nada a la capa del controlador, añadamos un nuevo módulo tag de forma que las cosas estén organizadas:

$ symfony init-module frontend tag

Extender el modelo

Necesitaremos mostrar la lista completa de las etiquetas dadas por todos los usuarios a una pregunta dada. Como la habilidad para recuperar las etiquetas relacionadas debería ser de la clase Question, la extenderemos (en askeet/lib/model/Question.php). El truco aquí es agrupar las entradas duplicadas para evitar etiquetas duplicadas (dos etiquetas idénticas deberían aparecer solo una vez en el resultado). El nuevo método tiene que devolver un array de etiquetas:

public function getTags()
{
  $c = new Criteria();
  $c->clearSelectColumns();
  $c->addSelectColumn(QuestionTagPeer::NORMALIZED_TAG);
  $c->add(QuestionTagPeer::QUESTION_ID, $this->getId());
  $c->setDistinct();
  $c->addAscendingOrderByColumn(QuestionTagPeer::NORMALIZED_TAG);
 
  $tags = array();
  $rs = QuestionTagPeer::doSelectRS($c);
  while ($rs->next())
  {
    $tags[] = $rs->getString(1);
  }
 
  return $tags;
}

Esta vez, como solo necesitamos una columna (normalized_tag), no hay razón para pedir a Propel que devuelva un array poblado de objetos Tag desde la base de datos (este proceso, de momento, es llamado hydrating). Así que haremos una petición simple que nosotros convertiremos en un array, que es mucho más rápido.

Modificar la vista

Ahora la página de detalle de la pregunta debería mostrar una lista de las etiquetas dadas a la pregunta. Usaremos la barra lateral para ello. Como ésta ha sido construida como una zona para componentes durante el séptimo día, podemos establecer un componente específico para esta barra solo en el módulo de las preguntas.

Así que en askeet/apps/frontend/modules/question/config/view.yml, añade la siguiente configuración:

showSuccess:
  components:
    sidebar: [sidebar, question]

Este componente del módulo sidebar aún no está creado, pero es bastante simple (en modules/sidebar/actions/components.class.php):

public function executeQuestion()
{
  $this->question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title'));
}

La parte más larga de escribir es el fragmento (modules/sidebar/templates/_question.php):

<?php include_partial('sidebar/default') ?>
 
<h2>question tags</h2>
 
<ul id="question_tags">
  <?php include_partial('tag/question_tags', array('question' => $question, 'tags' => $question->getTags())) ?>
</ul>

Elegimos insertar la lista de etiquetas como un fragmento ya que será actualizada con una petición AJAX dentro de un momento.

Este elemento parcial tiene que ser creado en modules/tag/templates/_question_tags.php:

<?php foreach($tags as $tag): ?>
  <li><?php echo link_to($tag, '@tag?tag='.$tag, 'rel=tag') ?></li>
<?php endforeach; ?>

El atributo rel=tag es un MicroFormato. Esto no es obligatorio, pero como no cuesta nada lo añadimos, dejémoslo estar.

Añade la regla de enrutamiento @tag en el routing.yml:

tag:
  url:   /tag/:tag
  param: { module: tag, action: show }

Pruébalo

Muestra el detalle de la primera pregunta y busca la lista de etiquetas en la barra lateral:

http://askeet/question/what-can-i-offer-to-my-step-mother

lista de etiquetas de una pregunta

Mostrar una lista corta de las etiquetas populares de una pregunta

La barra lateral es un buen lugar para mostrar la lista entera de etiquetas de una pregunta. ¿Pero qué pasa con las etiquetas mostradas en la lista de preguntas? Para cada pregunta, solo deberíamos mostrar un subconjunto de etiquetas. ¿Pero cuáles? Elegiremos las más populares, por ejemplo las etiquetas que han sido asignadas más a menudo para la pregunta. Probablemente tengamos que animar a los usuarios a mantener una pregunta etiquetada con las etiquetas que ya existen para así aumentar la popularidad de las etiquetas para la pregunta. Si los usuarios no lo hacen, quizá lo deban hacer los "moderadores".

Extender el modelo

De todas formas, esto significa que tenemos que añadir el método ->getPopularTags() a nuestro objeto Question. Pero esta vez, la petición a la base de datos no es simple. Usando Propel para hacerlo multiplicaría el número de peticiones y llevaría demasiado tiempo. Symfony permite usar el poder de SQL cuando ésta sea la mejor solución, así que añadiremos una conexión Creole a la base de datos y ejecutaremos una petición SQL normal.

Esta petición debería ser algo así:

SELECT normalized_tag AS tag, COUNT(normalized_tag) AS count
FROM question_tag
WHERE question_id = $id
GROUP BY normalized_tag
ORDER BY count DESC
LIMIT $max

Sin embargo, usar los nombres reales de la columna y la tabla crea una dependencia con la base de datos y salta la capa de abstracción de datos. Si, en el futuro, decides renombrar una columna o una tabla, esta petición SQL en crudo no funcionará más. Por esto es por lo que la versión de symfony de la petición usa el nombre abstracto en vez del nombre actual. Esto es ligeramente más difícil de leer, pero es mucho más fácil de mantener.

public function getPopularTags($max = 5)
{
  $tags = array();
 
  $con = Propel::getConnection();
  $query = '
    SELECT %s AS tag, COUNT(%s) AS count
    FROM %s
    WHERE %s = ?
    GROUP BY %s
    ORDER BY count DESC
  ';
 
  $query = sprintf($query,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::TABLE_NAME,
    QuestionTagPeer::QUESTION_ID,
    QuestionTagPeer::NORMALIZED_TAG
  );
 
  $stmt = $con->prepareStatement($query);
  $stmt->setInt(1, $this->getId());
  $stmt->setLimit($max);
  $rs = $stmt->executeQuery();
  while ($rs->next())
  {
    $tags[$rs->getString('tag')] = $rs->getInt('count');
  }
 
  return $tags;
}

Primero, se abre una conexión a la base de datos en $con. La petición SQL es construida reemplazando los símbolos %s en una cadena por los nombre de las columnas y las tablas que vienen desde la capa de abstracción. Se crea un objeto Statement que contiene la petición y un objeto ResultSet que contiene el resultado de la petición. Éstos son objetos Creole, y su uso se describe detalladamente en la documentación de Creole. El método ->setInt() del objeto Statement reemplaza el primer ? en la petición SQL pero el id de la pregunta. El argumento $max es usado para limitar el número de resultados devueltos con el método ->setLimit().

El método devuelve un array asociativo de etiquetas normalizadas y popularidad, ordenadas descendentemente por popularidad, con solo una petición a la base de datos.

Modificar la vista

Ahora podemos añadir la lista de etiquetas de una pregunta, la cual está formateada en un fragmento _list.php en el directorio modules/question/templates/:

<?php use_helper('Text', 'Date', 'Global', 'Question') ?>
 
<?php foreach($question_pager->getResults() as $question): ?>
  <div class="question">
    <div class="interested_block" id="block_<?php echo $question->getId() ?>">
      <?php include_partial('question/interested_user', array('question' => $question)) ?>
    </div>
 
    <h2><?php echo link_to($question->getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?></h2>
 
    <div class="question_body">
      <div>asked by <?php echo link_to($question->getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>
      <?php echo truncate_text(strip_tags($question->getHtmlBody()), 200) ?>
    </div>
 
    tags: <?php echo tags_for_question($question) ?>
 
  </div>
<?php endforeach; ?>
 
<div id="question_pager">
  <?php echo pager_navigation($question_pager, $rule) ?>
</div>

Como queremos separar las etiquetas por un signo +, y para evitar demasiado código en la plantilla para tratar con los límites, escribimos una función helper tags_for_question() en una nueva librería helper lib/helper/QuestionHelper.php:

function tags_for_question($question, $max = 5)
{
  $tags = array();
 
  foreach ($question->getPopularTags($max) as $tag => $count)
  {
    $tags[] = link_to($tag, '@tag?tag='.$tag);
  }
 
  return implode(' + ', $tags);
}

Prueba

Ahora la lista de preguntas muestra las etiquetas populares para cada una:

http://askeet/

lista de etiquetas populares de la pregunta

Mostrar la lista de preguntas etiquetadas con una palabra

Cada vez que mostramos una etiqueta, añadimos un enlace a la regla de enrutamiento @tag. Esto es para enlazar a la página que muestra la preguntas populares etiquetadas con un etiqueta dada. Es fácil de escribir, así que no lo demoremos más.

La acción tag/show

Crea una acción show en el módulo tag:

public function executeShow()
{
  $this->question_pager = QuestionPeer::getPopularByTag($this->getRequestParameter('tag'), $this->getRequestParameter('page'));
}

Extiende el modelo

Como de costumbre, el código que se encarga del modelo está situado en el modelo, esta vez en la clase QuestionPeer ya que devuelve un conjunto de objetos Question. Queremos las preguntas populares por usuarios interesados, así que esta vez, no hay necesidad de una petición compleja. Propel puede hacerlo con una simple llamada ->doSelect():

public static function getPopularByTag($tag, $page)
{
  $c = new Criteria();
  $c->add(QuestionTagPeer::NORMALIZED_TAG, $tag);
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $c->addJoin(QuestionTagPeer::QUESTION_ID, QuestionPeer::ID, Criteria::LEFT_JOIN);
 
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->init();
 
  return $pager;
}

El método devuelve una paginación de preguntas, ordenadas por popularidad.

Crear la plantilla

La plantilla modules/tag/templates/showSuccess.php es tan simple como cabría esperar:

<h1>popular questions for tag "<?php echo $sf_params->get('tag') ?>"</h1>
 
<?php include_partial('question/list', array('question_pager' => $question_pager, 'rule' => '@tag?tag=.'$sf_params->get(tag))) ?>

Añadir el parámetro page en la regla de enrutamiento

En routing.yml, añade un parámetro :page con un valor por defecto en la regla de enrutamiento @tag:

tag:
  url:   /tag/:tag/:page
  param: { module: tag, action: show, page: 1 }

Pruébalo

Navega hasta la página de la etiqueta activities y mira todas las preguntas etiquetadas con esta palabra:

http://askeet/tag/activities

lista de preguntas etiquetadas como 'activities

Nos vemos mañana

La capa de abstracción de Creole permite a symfony hacer peticiones SQL complejas. Encima de esto, el mapeo objeto-relacional de Propel te da las herramientas para trabajar en un mundo orientado a objetos, métodos útiles que te mantienen alejado de preocuparte por la base de datos, y transforma las peticiones en sentencias simples.

Algunos de vosotros puede que estéis preocupados por la importante carga que las peticiones de más arriba pueden crear en la base de datos. Aún son posibles algunas optimizaciones - por ejemplo, podrías crear una columna popular_tags en la tabla Question, actualizada con una transacción cada vez que una QuestionTag relacionada es creada. La lista de preguntas sería entonces mucho menos pesada. Pero los beneficios del sistema de caché - el cual trataremos en unos pocos días - hace estas optimizaciones innecesarias.

Mañana, terminaremos las características de las etiquetas de la aplicación askeet. Los usuarios podrán añadir etiquetas a una pregunta, y la nube de etiquetas general estará disponible. Asegúrate de volver mañana para leer sobre esto.

El código completo de la aplicación askeet hasta hoy puede ser descargado desde el repositorio SVN de askeet, etiquetado como /tags/release_day_13/. Si tienes alguna pregunta sobre el tutorial de hoy, sé libre de preguntarla en el foro de askeet.