Día trece del calendario de symfony: Etiquetas ============================================== 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][1]. ¿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](/images/askeet/mcd3.gif) 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: [xml] ...
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] $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: [php] 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 Question` y `User`. 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: [php] 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](7.txt), 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`): [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]

question tags

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]
  • El atributo `rel=tag` es un [MicroFormato](http://microformats.org/wiki/rel-tag). 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](/images/askeet/tag_list_question.gif) 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í: [sql] 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. [php] 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][2]. 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] getResults() as $question): ?>
    $question)) ?>

    getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?>

    asked by getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on getCreatedAt(), 'f') ?>
    getHtmlBody()), 200) ?>
    tags:
    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`: [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](/images/askeet/popular_tags_question_list.gif) 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`: [php] 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()`: [php] 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: [php]

    popular questions for tag "get('tag') ?>"

    $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](/images/askeet/tagged_question_list.gif) 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](http://svn.askeet.com/tags/release_day_13/), etiquetado como `/tags/release_day_13/`. Si tienes alguna pregunta sobre el tutorial de hoy, sé libre de preguntarla en el [foro de askeet](http://www.symfony-project.com/forum/index.php/f/8/). [1]: http://en.wikipedia.org/wiki/Folksonomy "definición de folksonomía en la Wikipedia" [2]: http://creole.phpdb.org/wiki/wiki.php?node=5 "Guía de Creole"