Pages

Date 30 janvier 2013

Jobeet ZF2 - Jour 6 - Aller plus loin avec le Modèle

Nous avons vu lors du jour 5 comment utiliser les routes avec Zend Framework 2, et nous avons ajouter quelques pages à notre site.
Bien que notre code fonctionne, plusieurs règles de gestion ne sont pas encore prises en compte. Nous allons y remédier maintenant.

Amélioration de nos requêtes

Conditions du jour 2: "Pour la page d'accueil, je vois une liste d'emploi actifs."
Or, pour le moment, nous affichons tous les emplois de chaque catégorie, peu importe qu'ils soient actifs ou pas...
// Dans module/Front/src/Front/Model/JobTable
public function fetchAllByIdCategory($idCategory)
{
    $resultSet = $this->tableGateway->select(array('id_category' => (int)$idCategory));
    return $resultSet;
}

Un emploi est considéré comme actif si'l a été posté il y a moins de 30 jours. Dans le code ci-dessus, aucune condition de ce type n'est spécifié: nous récupérons tous les enregistrements pour une catégorie donnée.

Modifions cela pour n'afficher que les emplois actifs:
// Remplacer le code de la méthode ci-dessus, par celui-ci
public function fetchAllByIdCategory($idCategory)
{
    $resultSet = $this->tableGateway->select(
        array(
            'id_category = ?' => (int)$idCategory,
            'created_at >= ?' => date('Y-m-d H:i:s', time() - 86400 * 30)
        )
    );
    return $resultSet;
}

Nous récupérons bien les emplois datés de moins de 30 jours. Par contre, ce qui est gênant  c'est que le 30 est écrit en dur...Que feriez-vous si vous décidiez, demain, de considérer les emplois emplois comme des emplois postés il y a mois de 45 jours ? Vous iriez dans la méthode et vous changeriez 30 par 45: c'est pas forcément génial surtout si ce paramètre de 30 jours est utilisé à plusieurs endroits.
Nous allons ajouté ce paramètre dans le fichier de configuration du module (fichier module/Front/config/module.config.php):
return array(
'jobeet' => array(
        'nb_job_by_category' => 10, // Nb Job par catégorie
        'nb_job_pagination' => 4,   // Nb job par page (pagination)
        'job_nb_valid_days' => 30,  // Nb jours de validité
        // Nous ajouterons plus tard d'autres paramètres ici, en fonction de nos besoins
),
    [...]
    // Le reste ci-dessous (routes, ...)
);
Nous allons modifier notre controlleur Index pour prendre récupérer ces informations de configuration:
class IndexController extends AbstractActionController
{
    protected $config;
    [...]
    public function __construct($category, $job)
    {
        $this->categoryTable = $category;
        $this->jobTable = $job;

        $services = $this->getServiceLocator()->get('Configuration');
        // On ne récupère que la configuration dont la clé est 'jobeet'
        // On pourra ainsi accèder à nos paramètres ainsi:
        // $this->config['job_nb_valid_days'];
        $this->config = isset($config['jobeet']) ? $config['jobeet'] : array();
    }
}
Nous avons ainsi accès à nos paramètres de configuration. Nous pouvons modifier tous nos contrôleur de cette manière, mais cela fait beaucoup de modification et nous risquons d'en oublier au passage (bon, le code n'est pas encore très compliqué pour le moment...mais dans le cadre d'un gros projet, c'est le risque).

Pour être plus efficace, nous allons agir d'une autre manière. Vous l'aurez sans doute remarqué, nous avons du code dupliqué dans tous nos contrôleurs (IndexController / CategoryController / JobController): nous avons les 2 attributs $jobTable et $categoryTable, le constructeur (identique) pour setter ces 2 tables, la récupération de la configuration...
Ça va être le moment de revoir le code :-)

Un peu de "refactoring"

Le "refactoring" (ré-usinage en français, et non refactorisation comme on l'entend souvent) est une opération de maintenance du code informatique qui consiste à reprendre le code pour l'améliorer, le simplifier, le clarifier pour la lecture, ou le rendre plus générique et cela, sans ajouter de nouvelles fonctionnalités.

Dans le cadre de ce ré-usinage, j'ai ainsi créé une nouveau controlleur "JobeetController"afin de simplifier les contrôleurs existants:
namespace Front\Controller;

use Zend\Stdlib\ArrayUtils;
use Zend\Mvc\Controller\AbstractActionController;
use RuntimeException;

class JobeetController extends AbstractActionController
{
    protected $config = array();
    
    protected $jobTable;
    protected $categoryTable;
    
    public function __construct($category, $job)
    {
        $this->categoryTable = $category;
        $this->jobTable = $job;
    }
    
    public function setConfig($config)
    {
        if ($config instanceof \Traversable) {
            $config = ArrayUtils::iteratorToArray($config);
        }
         
        if (!is_array($config)) {
            throw new RuntimeException(
                sprintf(
                    'Expected array or Traversable Jobeet configuration; received %s',
                    (is_object($config) ? get_class($config) : gettype($config))
                )
            );
        }
        $this->config = $config;
    }
    
    public function setCategoryTable($category)
    {
        $this->categoryTable = $category;
    }
    
    public function setJobTable($job)
    {
        $this->jobTable = $job;
    }
}
J'ai simplement mis dans ce JobeetController tout le code dupliqué dans nos contrôleurs. Allez modifier les 3 contrôleurs pour supprimer ce code dupliqué: ils ne contiennent plus que leurs propres méthodes xxxxAction(), c'est de suite plus lisible.

Ensuite, au lieu d'étendre AbstractActionController, ces 3 contrôleurs doivent étendre JobeetController.

class IndexController extends AbstractActionController
devient
class IndexController extends JobeetController

class JobController extends AbstractActionController
devient
class JobController extends JobeetController

class CategoryController extends AbstractActionController
devient
class CategoryController extends JobeetController

Ok, c'est fait ? Mais a quel moment allons-nous injecter la config via la méthode setConfig() ?
Tout simplement dans le fichier Module.php:
[...]
public function getControllerConfig()
    {
        return array(
            'factories' => array(
                'Front\Controller\Category'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    // On récupère les infos de notre module.config.php
                    $config     = $sm->get('Config');
                    $config     = isset($config['jobeet']) ? $config['jobeet'] : array();

                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new CategoryController($category, $job);

                    // On l'injecte via un setter ici
                    $controller->setConfig($config);
                    return $controller;
                },
                // Et l'on fait pareils pour JobController
                'Front\Controller\Job'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    $config     = $sm->get('Config');
                    $config     = isset($config['jobeet']) ? $config['jobeet'] : array();
                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new JobController($category, $job);
                    $controller->setConfig($config);
                    return $controller;
                },
                // Et pour IndexController
                'Front\Controller\Index'    => function(ControllerManager $cm) {
                    $sm   = $cm->getServiceLocator();
                    $config     = $sm->get('Config');
                    $config     = isset($config['jobeet']) ? $config['jobeet'] : array();
                    $category = $sm->get('Front\Model\CategoryTable');
                    $job = $sm->get('Front\Model\JobTable');
                    $controller = new IndexController($category, $job);
                    $controller->setConfig($config);
                    return $controller;
                },
            ),
        );
    }
[...]
De cette manière, la configuration sera disponible dans nos contrôleurs, sans plus rien faire :-)

Créons une nouvelle méthode dans JobTable, pour récupérer les jobs actifs, avec une limite:
public function fetchByIdCategoryWithLimit($idCategory, $limit = 10, $nbDays = 30)
{
    $select = new Select($this->tableGateway->getTable());
    $select->where("id_category = {$idCategory}")
           ->where("created_at >= '" . date('Y-m-d H:i:s', time() - 86400 * $nbDays) . "'")
           ->limit($limit);

    return $this->tableGateway->selectWith($select);
}
Modifions notre IndexController pour utiliser cette méthode, avec les bons paramètres (que l'on va récupérer dans la config):
[...]
public function indexAction()
{
    $categories = $this->categoryTable->fetchAll();
    $results = array();

    foreach ($categories as $category) {
        $jobs = $this->jobTable->fetchByIdCategoryWithLimit(
            $category->idCategory,
            $this->config['nb_job_by_category'],
            $this->config['job_nb_valid_days']
        );
        $results[] = array( 'category' => $category, 'job' => $jobs);
    }

    return new ViewModel(
        array(
            'results' => $results,
        )
    );
}
[...]
De la même manière, nous avions défini précédemment que la page d'une catégorie devait afficher 15 jobs. Réutilisons notre méthode fetchByIdCategoryWithLimit en lui passant les bons paramètres. Pour cela, modifier la méthode listAction() du contrôleur CategoryController:
[...]
public function listAction()
{
    $idCategory = $this->params()->fromRoute('id', null);

    if (!is_null($id_category)) {
        $category = $this->categoryTable->getCategory($idCategory);
        $jobs = $this->jobTable->fetchByIdCategoryWithLimit(
            $idCategory,
            $this->config['nb_job'],
            $this->config['job_nb_valid_days']
        );

        return new ViewModel(
            array(
                'category' => $category,
                'jobs'     => $jobs
            )
        );
    } else {
        $this->getResponse()->setStatusCode(404);
        return;
    }
}
[...]

Nous avons limité le nombre d'emplois affichés par catégorie sur la page d'accueil, et nous avons aussi limité le nombre d'emplois affichés pour une catégorie donnée. Mais nous avons maintenant un problème: si nous avons plus de 15 emplois pour une catégorie, commet allons-nous les visualiser, il n'y a rien pour voir les emplois suivants ?

La pagination

Afin d'accéder aux autres emplois d'une catégorie, nous allons voir comment gérer une pagination.
La base de données MySQL (et d'autres) permet, via sa clause LIMIT, de réduire le nombre d'enregistrements retournés par la commande SELECT. Cette clause LIMIT accepte 1 ou 2 arguments numérique:
  • avec un seul argument, la valeur spécifie le nombre de lignes à retourner depuis le début du jeu de résultat (row_count)
  • si deux arguments sont donnés (row_count et offset), le premier indique le décalage du premier enregistrement à retourner, le second donne le nombre maximum d'enregistrement à retourner (le décalage du premier enregistrement est 0, et pas 1).
Nous pourrions, comme nous l'aurions fait AVANT, gérer les paramètres row_count et offset pour gérer notre pagination et créer un système de pages pour avancer/reculer dans ces pages.

Heureusement, nous n'allons pas faire ça manuellement. ZF2 va nous faciliter la tâche grâce à son composant Paginator. Ce composant va nous aider à paginer des collections de données de façon générique. Pour cela, il va avoir besoin d'un adapteur. On peut actuellement en utiliser 3:
  • Array: va utiliser un tableau PHP
  • DbSelect: Accepte un Zend\Db\Sql\Select ET un Zend\Db\Adapter\Adapter ou uniquement un Zend\Db\Sql\Sql pour paginer des enregistrements provenant d'une base de données. C'est cet adapteur que nous utiliserons pour notre pagintion
  • Iterator: n'importe quoi implémentant l'interface Iterator
A noter concernant l'adapteur DbSelect: plutôt que de récupérer toutes les données d'un coup, l'adapteur va récupérer uniquement le minimum d'enregistrements (nombre que l'on aura défini) nécessaire à l'affichage de la page. A cause de cela , une seconde requête sera générée dynamiquement pour connaitre  le nombre total d'enregistrement.

Dans un premier temps, nous allons indiquer à une route qu'elle pourra recevoir un paramètre 'page'.
Nous allons créer une nouvelle route (on fera le nettoyage plus tard, nous aurons l'occasion de revenir sur les routes pour avoir des url plus SEO-friendly, c'est-à-dire plus lisible pour un humain mais surtout pour un moteur de recherche).
Editer le fichier module.config.php et ajouter la ligne suivante dans le tableau de route:
'router' => array(
        'routes' => array(
            [...]
            // Juste après la route 'category'
            'list_category_page' => array(
                'type'    => 'Segment',
                'options' => array(
                    'route'    => '/category/list/[:id][/page/[:page]]',
                    'constraints' => array(
                        'id'     => '[0-9]+',
                        'page'   => '[0-9]+', // L'identifiant de page est un entier
                    ),
                    'defaults' => array(
                        '__NAMESPACE__' => 'Front\Controller',
                        'controller' => 'Category',
                        'action'     => 'list',
                        'page'       => 1, // Il nous faut une valeur par défaut
                    ),
                ),
            ),
            [...]

Préparons maintenant notre modèle JobTable pour autoriser la pagination. Nous allons rajouter 2 méthodes: l'une permettra de récupérer notre adapter (qui nous permet de nous connecter à MySQL et que nous avons injecter dans nos modèle de table) et une autre qui retournera un Zend\Db\Sql\Select (notre requête pour récupérer les emplois en cours d'une catégorie): ce sont les deux éléments nécessaires au composant \Zend\Paginator.
// Dans module/Front/src/Front/Model/JobTable.php
// La méthode qui va nous retourner l'adapter
public function getAdapter()
{
    return $this->tableGateway->getAdapter();
}

// Vous voyez, pour l'instant, pas de limit: c'est la magie du Paginator
public function getActiveJobsForPagination($idCategory, $nbDays)
{
    $select = new \Zend\Db\Sql\Select();
    $select->from($this->tableGateway->getTable())
           ->where(
               array(
                   'id_category = ?' => (int)$idCategory,
                   'created_at >= ?' => date('Y-m-d H:i:s', time() - 86400 * $nbDays)
               )
           );
    
    return $select;
}

Intervenons maintenant au niveau du contrôleur 'Category' et de l'action 'list' pour indiquer que nous voulons paginer nos emplois:
public function listAction()
{
    $id_category = $this->params()->fromRoute('id', null);

    // On récupère notre paramètre 'page' (voir la route créée plus haut)
    // Par défaut, il aura la valeur 1
    $currentPage = $this->params()->fromRoute('page', null);

    // On récupère notre adapteur
    // Après avoir écrit le tuto et chercher un moyen de faire mieux, j'ai trouvé une meilleur méthode pour récupèrer l'adapter
    // Pour cela, on utilise directement le ServiceLocator (récupère l'adapter dans la config générale de l'application
    // (dans application/config/database.local.php)
    // $adapter = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');
    $adapter = $this->jobTable->getAdapter();

    // On récupère le Zend\Db\Sql\Select
    $select = $this->jobTable->getActiveJobsForPagination($id_category, $this->config['job_nb_valid_days']);

    // On déclare notre Paginator (en lui passant l'adapteur \Zend\Paginator\Adapter\DbSelect en parametre du constructeur)
    $paginator = new \Zend\Paginator(new \Zend\Paginator\Adapter\DbSelect($select, $adapter));
    
    // On lui indique la page courante
    $paginator->setCurrentPageNumber($currentPage);

    // Et on définit combien de resultats on veut afficher sur chaque page (donnée que l'on récupère de notre config)
    $paginator->setDefaultItemCountPerPage($this->config['nb_job_pagination']);

    if (!is_null($id_category)) {
        $category = $this->categoryTable->getCategory($id_category);

        // On récupère (grâce au paginator) les emplois actifs de la page courante
        $jobs = $paginator->getCurrentItems();

        Et l'on retourne les infos à la vue (category/list.phtml)
        return new ViewModel(
            array(
                'category' => $category,
                'jobs'     => $jobs,
                'paginator' => $paginator,
                'id' => $id_category,
            )
        );
    } else {
        $this->getResponse()->setStatusCode(404);
        return;
    }
}

Si vous affichez maintenant la page d'une catégorie, vous ne verrez que les emplois de la page 1. Et toujours rien pour changer de page, me direz-vous. C'est exact: il faut ajouter le paginator à la vue. Pour cela, nous allons utiliser une nouvelle aide de vue (paginationControl). Modifions la vue list.phtml du contrôleur Category
// Dans module/Front/view/front/category/list.phtml
<?php
    $this->headLink()->appendStylesheet('/css/jobs.css');
?>
<h1><?php echo $this->escapeHtml($this->category->name); ?></h1>
<table class="jobs">
    <tbody>
        <?php foreach($this->jobs as $job): ?>
        <tr class="<?php echo $this->cycle(array("even", "odd"))->next();?>">
            <td class="location"><?php echo $this->escapeHtml($job->location); ?></td>
            <td class="position">
                <a href="<?php echo $this->url('job', array('action'=>'get', 'id' => $job->id_job));?>">
                    <?php echo $this->escapeHtml($job->position); ?>
                </a>
            </td>
            <td class="company">
                <?php echo $this->escapeHtml($job->company); ?>
            </td>
        </tr>
        <?php endforeach;?>
    </tbody>
</table>

<!-- Notre aide de vue ICI-->
<!-- Nous lui passons en parametre le Paginator, le type de pagination, le template de pagination, et un tableau de parametre (id pour l'id category)-->
<?php echo $this->paginationControl($this->paginator, 'Sliding', 'pagination.phtml', (array('id' => $this->id))); ?>

Ajoutons maintenant le template permettant l'affichage de la pagination. Créez une nouvelle vue module/Front/view/front/pagination.phtml
<?php if ($this->pageCount): ?>
    <div class="pagination">
        <!-- First page link -->
        <a href="<?php echo $this->url($this->route, array('page' => $this->first, 'id' => $this->id)); ?>">
            <img src="/images/first.png" alt="First page" />
        </a>
    
        <!-- Previous page link -->
        <a href="<?php echo $this->url($this->route, array('page' => $this->previous, 'id' => $this->id)); ?>">
            <img src="/images/previous.png" alt="Previous page" />
        </a>

        <!-- Numbered page links -->
        <?php foreach ($this->pagesInRange as $page): ?>
            <?php if ($page != $this->current): ?>
                <a href="<?php echo $this->url($this->route, array('page' => $page, 'id' => $this->id)); ?>"><?php echo $page; ?></a>
            <?php else: ?>
                <?php echo $page; ?>
            <?php endif; ?>
        <?php endforeach; ?>

        <!-- Next page link -->
        <?php if (isset($this->next)): ?>
            <a href="<?php echo $this->url($this->route, array('page' => $this->next, 'id' => $this->id)); ?>">
                <img src="/images/next.png" alt="Next page" />
            </a>
        <?php else: ?>
            <a href="#" class="disabled"><img src="/images/next.png" alt="Next page" /></a>
        <?php endif; ?>
        
        <!-- Last Page -->
        <a href="<?php echo $this->url($this->route, array('page' => $this->last, 'id' => $this->id)); ?>">
            <img src="/images/last.png" alt="Last page" />
        </a>
    </div>
<?php endif; ?>

<div class="pagination_desc">
  <strong><?php echo $this->totalItemCount; ?></strong> annonces dans cette catégorie - page <strong><?php echo $this->current;?> / <?php echo $this->last;?></strong>
</div>

Dans votre navigateur, allez voir maintenant une page categorie (en clickant sur le titre d'une categorie sur l'accueil). Vous devriez obtenir ceci:
Exemple de pagination avec Zend\Paginator


Si vous regardez maintenant dans la barre de debug (pour les requetes ), vous verrez ce que je vous disais sur le Paginator avec l'adapteur DbSelect: 2 requêtes sont exécutées.

  • une pour la pagination en elle-même (avec LIMIT x OFFSET y)
  • et la requête générée automatiquement pour compter le nombre d'enregistrement total (avec un count)

Nous verrons, au moment d'étudier les caches, comment  limiter ces requêtes


Jouez avec les différents paramètres (nb_job_by_category, nb_job_pagination et job_nb_valid_days) et regarder ce qu'il se passe. Vous trouverez dans le repertoire /data/bdd un script SQL pour remplir les tables category et job (le temps d'avoir un formulaire de saisie ...)

Voila, nous avons bien avancer pour ce nouveau tutoriel. Je vous invite à revenir lire la suite prochainement. (Les sources sont disponible sur mon compte Github)

4 commentaires: