![]() |
|
The Askeet TutorialDía trece del calendario de symfony: Etiquetas |
WARNING: The SVN source code found in the release_day tags is outdated. Please refer to the current version until each day code is updated.
You are currently reading "The Askeet Tutorial" which is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.
QuestionTag
tag/show
page
en la regla de enrutamiento
![]() |
This work is licensed under a
Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License.
Translation of this work into another language is explicitly allowed. |
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.
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.
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
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.
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.
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
.
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
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.
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 }
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
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".
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.
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); }
Ahora la lista de preguntas muestra las etiquetas populares para cada una:
http://askeet/
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.
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')); }
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.
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))) ?>
page
en la regla de enrutamientoEn 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 }
Navega hasta la página de la etiqueta activities
y mira todas las preguntas etiquetadas con esta palabra:
http://askeet/tag/activities
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.
If you find a typo or an error, please register and open a ticket.
If you need support or have a technical question, please post to the user mailing-list or to the forum.