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:
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 Questiony
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:
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
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/
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
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.
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.