I18nSluggableBehavior pour gérer les slugs dans une base I18n avec Propel

Je travaille sur un nouveau projet réalisé avec Symfony 2.1 et Propel 1.6. Le projet étant multilangues, j'utilise bien entendu le behavior I18n de Propel. Pour afficher de belles URL j'utilise également le behavior Sluggable. Malheureusement si ces behaviors sont complémentaires pour mon projet ils ne fonctionnent pas ensemble comme je le voudrais. J'ai donc entrepris de créer un behavior transgénique en les combinant pour créer le behavior I18nSluggableBehavior.

 

1. Limitation des behaviors natifs

Imaginons un site multilingues avec une table "titre" définit comme ceci:

 
    <table name="titre">
        <column name="titre_id" type="INTEGER" size="11" primaryKey="true" autoIncrement="true"/>
        <column name="titre" type="LONGVARCHAR" primaryString="true"/>
        <column name="date" type="DATE"/>
    </table>

La table contient 3 colonnes: "titre_id", "titre" et "date". Sur cette table je veux pouvoir gérer plusieurs langues pour la colonne "titre" et avoir un slug de cette colonne pour construire plus tard des URLs. J'ajoute donc mes 2 behaviors I18n et Sluggable à la définition de la table.

 
    <table name="titre">
        <column name="titre_id" type="INTEGER" size="11" primaryKey="true" autoIncrement="true"/>
        <column name="titre" type="LONGVARCHAR" primaryString="true"/>
        <column name="date" type="DATE"/>
        <behavior name="sluggable" />
        <behavior name="i18n">
        	<parameter name="i18n_columns" value="titre" />
        </behavior>
    </table>

Après avoir joué la commande propel:sql:build j'obtiens ce résultat.

 
CREATE TABLE `titre`
(
    `titre_id` INTEGER(11) NOT NULL AUTO_INCREMENT,
    `date` DATE,
    `slug` VARCHAR(255),
    PRIMARY KEY (`titre_id`),
    UNIQUE INDEX `titre_slug` (`slug`(255)),
) ENGINE=InnoDB;
 
CREATE TABLE `titre_i18n`
(
    `titre_id` INTEGER(11) NOT NULL,
    `locale` VARCHAR(5) DEFAULT 'en_EN' NOT NULL,
    `titre` TEXT,
    PRIMARY KEY (`titre_id`,`locale`),
    CONSTRAINT `titre_i18n_FK_1`
        FOREIGN KEY (`titre_id`)
        REFERENCES `titre` (`titre_id`)
        ON DELETE CASCADE
) ENGINE=InnoDB;
 

On voit bien le problème, Propel a généré une table "titre_i18n" pour gérer les différentes langues de la colonne "titre" mais a créé le champs "slug" dans la table "titre" ce qui implique un slug unique pour plusieurs langues. Evidemment, ce n'est pas ce qui m'interesse, je veux que la colonne "slug" soit dans la table "titre_i18n" pour avoir un slug différent pour chaque langue.

 

2. Création du behavior I18nSluggableBehavior

Pourquoi créer un behavior from scratch alors que j'ai presque tout le code qui me faut dans les behaviors standards. Après avoir analysé les 2 behaviors fournis par Propel, je suis parti sur l'idée que mon nouveau behavior étendra le I18nBehavior dans lequel j'injecterai le behavior Sluggable. J'ai fait ce choix car le behavior Sluggable se résume à une seule classe alors que le behavior I18n est basé sur 4 classes et des templates.

Dans la classe I18nBehavior la méthode modifyTable() contient tout le code qui génère la table *_i18n et supprime les champs de la table d'origine. La nouvelle table i18n est stockée dans la variable de classe $i18nTable et est une instance de la classe Table de Propel. Comme les choses sont bien faites dans Propel, il existe une méthode addBehavior() dans la classe Table.

Je peux donc écrire mon nouveau behavior très simplement:

 
<?php
 
class I18nSluggableBehavior extends I18nBehavior
{
 
    public function __construct()
    {
	if (null === $this->dirname) {
            $extend = new ReflectionObject(new I18nBehavior());
            $this->dirname = dirname($extend->getFileName());
        }
    }
 
    public function modifyTable()
    {
        parent::modifyTable();
 
        //add sluggableBehavior
        $this->i18nTable->addBehavior(new SluggableBehavior());
    }
 
}

J'ai commencé par définir un constructeur dans lequel je spécifie la variable de classe $dirname avec le chemin de la classe I18nBehavior. C'est obligatoire car la résolution des chemins pour les templates est basée sur le chemin de la classe I18nBehavior et ce n'est pas l'endroit où j'ai créé ma classe.

Ensuite je complète la méthode modifyTable() pour ajouter mon behavior sluggable. Je ne spécifie pas la colonne car Propel à copié l'option primaryString="true" quand il a déplacé la colonne "titre" de la table "titre" à la table "titre_i18n" et le behavior sluggable se base sur ce paramêtre pour déterminer sur quelle colonne il doit agir.

 

3. Utilisation du behavior I18nSluggableBehavior

Voici la nouvelle définition de ma table dans mon schema.xml.

 
    <table name="titre">
        <column name="titre_id" type="INTEGER" size="11" primaryKey="true" autoIncrement="true"/>
        <column name="titre" type="LONGVARCHAR" primaryString="true"/>
        <column name="date" type="DATE"/>
        <behavior name="i18n_sluggable">
        	<parameter name="i18n_columns" value="titre" />
        </behavior>
    </table>

Je relance la commande propel:sql:build pour vérifier que mon sql est conforme à mes attentes.

 
CREATE TABLE `titre`
(
    `titre_id` INTEGER(11) NOT NULL AUTO_INCREMENT,
    `date` DATE,
    PRIMARY KEY (`titre_id`),
) ENGINE=InnoDB;
 
CREATE TABLE `titre_i18n`
(
    `titre_id` INTEGER(11) NOT NULL,
    `locale` VARCHAR(5) DEFAULT 'en_EN' NOT NULL,
    `titre` TEXT,
    `slug` VARCHAR(255),
    PRIMARY KEY (`titre_id`,`locale`),
    CONSTRAINT `titre_i18n_FK_1`
        FOREIGN KEY (`titre_id`)
        REFERENCES `titre` (`titre_id`)
        ON DELETE CASCADE
) ENGINE=InnoDB;

La génération du SQL correspond à ce que je souhaite et après quelques tests les classes Propel se comportent comme elles doivent et génèrent les slugs sans aucun soucis.

 

Voici donc un nouveau behavior qui répond à un besoin assez précis. Je n'ai pas encore écrit de tests unitaires et mon behavior souffre de quelques limitations/manquements. Certains l'auront peut être remarqué la contrainte d'unicité sur le champs slug a disparu, mais Propel la vérifie automatiquement en faisant un select lors de la génération du slug. Je suis également limité à l'utilisation de l'option primaryString="true" et au fait que je ne peux slugifier qu'un champs par table. Mais je n'ai encore jamais eu besoin de slugifier 2 champs dans une même table. A défault de vous être utile dans l'état, j'espère que ce behavior vous aura donné des idées.

Il n'y aucun commentaire