Récupérer des données depuis une base en utilisant l'API ActiveRecord du framework WdCore

Adoptant ce que propose le framework Rails pour le langage de programmation Ruby, le framework WdCore offre une API permettant de manipuler les enregistrements en base de données comme de simples objets. On peut ainsi créer des tables, ajouter des enregistrements, les modifier, les supprimer sans jamais avoir à saisir la moindre commande SQL. Cet article a pour but de présenter les méthodes permettant de récupérer un objet, ou une collection d'objets, depuis une base de données, mais aussi de spécifier l'ordre dans lequel les objets sont récupérés, comment les grouper, ou encore comment ne récupérer que certains champs.

Les requêtes se font sur un objet modèle qui est une représentation d'une ou plusieurs tables en base de données. Au long de cette démonstration, la variable $model fera référence au modèle de données primaire du module « Nœuds », que l'on peut obtenir avec la ligne suivante :

<?php

$model = $core->models['system.nodes'];

Avant de continuer la lecture de cet article, vous devez être familiarisé avec les concepts objet de PHP ainsi que PDO, son interface d'accès aux bases de données. Vous pouvez retrouver le code source de la classe WdActiveRecordQuery sur Github.

Récupérer des objets depuis la base de données

L'API ActiveRecord propose plusieurs méthodes permettant de récupérer des objets depuis la base de données. Chacune de ces méthodes permet de définir les fragments de la requête SQL qui sera soumise à la base de données, sans que l'on aie jamais à saisir d'expression SQL brute. Les méthodes reprennent le vocabulaire de SQL, en voici quelques une :

  • where
  • select
  • group
  • order
  • limit
  • offset
  • joins

Chacune de ces méthodes retourne une instance de la classe WdActiveRecordQuery, permettant d'enchainer les méthodes les unes à la suite des autres.

Nous verrons qu'il existe de nombreuses façons de récupérer des données, notamment au travers des propriétés all et one, mais pour le moment intéressons-nous à la plus simples d'entre elles : la méthodes find qui permet de récupérer un objet ou un jeu d'objets.

Récupérer un objet unique

L'API ActiveRecord permet de récupérer un objet unique très simplement. En utilisant « $model->find(primary_key) » ou « $model[primary_key] », on peut récupérer l'objet correspondant à une clé primaire :

<?php

$article = $model->find(10);

On peut également accéder aux modèles de données comme s'il s'agissait de tableaux associatifs, c'est d'ailleurs cette forme que l'on privilégiera lorsque l'on récupère un enregistrement unique :

<?php

$article = $model[10];

Récupérer un ensemble d'objets

En utilisant la même méthode, il est également possible de récupérer un ensemble d'objets :

<?php

$articles = $model->find(array(103289));
# ou
$articles = $model->find(103289);

Récupérer des enregistrements en utilisant la méthode find met en jeu deux fonctionnalités supplémentaires : les exceptions et la mise en cache des enregistrements.

Exceptions et mise en cache

Que l'on récupère un objet unique ou un jeu d'objets, une exception de type WdMissingRecordException est lancée lorsque qu'un objet s'avère manquant dans la base de données.

Une fois récupérés sans encombre, les objets sont mis en cache, c'est à dire que si l'on demande une seconde fois un objet, il sera servi depuis le cache, évitant ainsi une requête à la base de données.

Conditions

Afin de restreindre le nombre d'enregistrements retournés, il est possible de préciser des conditions de recherche. Les conditions peuvent être précisées sous forme de chaines de caractères ou de tableaux associatifs.

Chaine de caractères de conditions

Si l'on souhaite ajouter une condition à notre recherche, on peut la préciser aussi simplement que « $model->where('is_online = 1'); ». Cela retournera tous les enregistrements dont le champ is_online a pour valeur « 1 ».

Attention, construire votre propre condition sous la forme d'une chaine de caractères vous rend particulièrement vulnérable aux attaques d'injection SQL, parce qu'on ne sait jamais ce que les petits malins sont capables de faire pour exploiter nos données. La section suivante vous renseignera sur la meilleure façon de passer des arguments à vos conditions.

Tableau associatif de conditions

Si la valeur « 1 » est variable, parce qu'elle provient d'un paramètre qui peut être extérieur à l'application, on utilisera la forme de condition suivante :

<?php

$model->where('is_online = ?'$_GET['online']);

ou encore :

<?php

$model->where(array('is_online' => $_GET['online']));

Avec deux conditions à préciser, cela donne :

<?php

$model->where('is_online = ? AND is_home_excluded = ?'$_GET['online']false);

ou bien :

<?php

$model->where(array('is_online' => $_GET['online']'is_home_excluded' => false));

Conditions de sous-ensembles

Si l'on souhaite rechercher les enregistrements qui appartiennent à un sous-ensemble, on peut fournir un tableau associatif en tant que valeur de la condition :

<?php

$model->where(array('orders_count' => array(1,3,5)));

Ce code générera quelque chose comme : ... WHERE (orders_count IN (1,3,5)).

Modificateurs

Lorsque les conditions sont précisées sous la forme d'un tableau associatif, il est possible de modifier la fonction de comparaison en préfixant le champ par un signe d'exclamation « ! ». Ainsi, au lieu de rechercher la commande ayant « 2 » pour identifiant, on pourra rechercher les commandes n'ayant pas « 2 » pour identifiant :

<?php

$model->where(array('!order_count' => 2));

De la même manière on pourra rechercher les enregistrements qui n'appartiennent pas à un sous-ensemble :

<?php

$model->where(array('!order_count' => array(1,3,5)));

Filtres dynamiques

Pour chaque colonne définie par le modèle, l'API ActiveRecord fournie automatiquement une méthode de recherche. Parce le modèle Nœuds définit le champ slug, on peut utiliser la méthode de recherche find_by_slug :

<?php

$model->find_by_slug('creer-nuage-mots-cle');

Si l'on a besoin de connaitre les articles en ligne de l'utilisateur ayant pour identifiant 3 on peut tout simplement enchainer les filtres avec le séparateur _and_ :

<?php

$model->find_by_is_online_and_uid(true3);

Ce qui, si l'on utilise la méthode de recherche where, équivaut à :

<?php

$model->where(array('is_online' => true'uid' => 3));

Portées

Les portées peuvent être considérées comme des macros de recherche, des options toutes prêtes, rapides à utiliser. Chaque modèle peut définir ses propres portées ou surcharger celles du modèle dont il hérite. Par exemple, voici la définition de la portée visible par le modèle Nœuds :

<?php

...

    protected function scope_visible(WdActiveRecordQuery $query)
    {
        global $core;

        return $query->where('is_online = 1 AND (siteid = 0 OR siteid = ?) AND (language = "" OR language = ?)'$core->site->siteid$core->site->language);
    }

    ...

On peut alors très simplement obtenir la liste des enregistrements disponibles pour le site :

<?php

$model->visible;

On peut bien sûr combiner les portées ainsi que les autres méthodes de recherche :

<?php

$model->find_by_uid(1)->visible->where('YEAR(created) = 2011');

Ordonner les enregistrements

Pour récupérer les enregistrements dans un certain ordre, on peut utiliser la méthode « order ».

Par exemple, si l'on souhaite récupérer les enregistrements dans l'ordre chronologique de leur date de création (enregistrée dans le champ « created ») :

<?php

$model->order('created');
# ou
$model->order('created ASC');

Pour un ordre antichronologique :

<?php

$model->order('created DESC');

On peut également ordonner les enregistrements selon plusieurs critères :

<?php

$model->order('created DESC, title');

Grouper les données

La clause SQL GROUP BY peut être spécifiée en utilisant la méthode group(). Par exemple, si l'on souhaite obtenir le premier enregistrement crée par jour, on utilisera le code suivant :

<?php

$model->group('date(created)')->order('created');

Appliquer un filtre au groupe

La clause HAVING est utilisée pour spécifier les conditions de la clause GROUP BY. Par exemple, si l'on souhaite obtenir le premier enregistrement crée par jour pour le mois passé, on utilisera le code suivant :

<?php

$model->group('date(created)')->having('created > ?'date('Y-m-d'strtotime('-1 month')))->order('created')

Limite et décalage

La méthode « limit » peut être utilisée pour limiter le nombre d'enregistrements retournés. Elle accepte un ou deux arguments numériques. Ces arguments doivent être des entiers constants.

Avec un argument, la valeur spécifie le nombre de lignes à retourner depuis le début du jeu de résultat. Si deux arguments sont donnés, le premier indique le décalage du premier enregistrement à retourner, le second donne le nombre maximum d'enregistrements à retourner. Le décalage du premier enregistrement est 0 (pas 1) :

<?php

$model->where('is_online = 1')->limit(10)// retourne les 10 premiers enregistrements
$model->where('is_online = 1')->limit(510)// retourne les enregistrements 6 à 16

La méthode « offset » peut être utilisée pour définir seulement le décalage :

<?php

$model->where('is_online = 1')->offset(5)// retourne les enregistrements de 6 jusqu'au dernier
$model->where('is_online = 1')->limit(10)->offset(5);

Sélectionner des champs spécifiques

Par défaut, tous les champs sont sélectionnés (SELECT *) et les enregistrements sont retournés sous la forme d'objets dont la classe dépend du modèle de données. Il est cependant possible de ne sélectionner qu'un sous ensemble de champs grâce à la méthode select. Dans ce cas, chaque ligne de résultat est renvoyée sous la forme d'un tableau associatif. Par exemple si l'on souhaite obtenir l'identifiant d'un nœud, sa date de création et son titre :

<?php

$model->select('nid, created, title');

Parce que les champs spécifiés sont utilisés tels-quels pour construire la requête SQL, il est tout à fait possible d'utiliser les fonctions SQL :

<?php

$model->select('nid, created, CONCAT_WS(":", title, language)');

Joindre des tables

L'API Active Record fournie une méthode de recherche qui permet de spécifier la clause JOIN de la requête SQL. Grâce à la méthode joins, il est possible de spécifier un fragment brut ou d'utiliser les relations qu'il existe entre les modèles.

Utiliser un fragment brut

On peut spécifier un fragment brut, il sera inclus tel quel dans la requête finale :

<?php

$model->joins('INNER JOIN contents USING(nid)');

Utiliser une référence à un modèle

On peut profiter des relations définies entre les modèles et laisser faire le framework :

<?php

$model->joins(':contents');

La requête produira le même effet que la précédente, sans que nous ayons à nous soucier des conditions de la jointure. On notera les deux points « : » utilisés pour identifier l'utilisation d'un nom de modèle plutôt qu'un fragment brut.

Récupérer les données

Il existe de nombreuses façons de récupérer les lignes du jeu d'enregistrement. Nous avons déjà vu la méthode find, voici les méthodes et les propriétés que l'on peut utiliser conjointement aux méthodes de recherche :

Par itération

Parce que l'objet WdActiveRecordQuery est traversable, l'itération est la façon la plus simple de récupérer les lignes du jeu d'enregistrements :

<?php

foreach ($model->where('is_online = 1') as $node)
{
    ...
}

Récupérer tous les enregistrements

Le jeu de résultat peut être renvoyé sous la forme d'un tableau associatif grâce à la propriété all :

<?php

$array = $model->order('created DESC')->all;

Récupérer seulement le premier enregistrement

Il arrive souvent que l'on ne souhaite récupérer que le premier objet d'une requête, dans ce cas on utilisera la propriété one :

<?php

$record = $model->order('created DESC')->one;

Récupérer des paires de valeurs

Lorsque l'on ne sélectionne que deux colonnes, il est possible de récupérer un résultat sous la forme clé/valeur grâce à la propriété pairs :

<?php

$model->select('nid, title')->pairs;

Dans ce cas la première colonne est utilisée comme clé et la seconde comme valeur, pour un résultat similaire à celui-ci :

array
  34 => string 'Créer un nuage de mots-clé' (length=28)
  57 => string 'Générer à la volée des miniatures avec mise en cache' (length=56)
  307 => string 'Mes premiers pas de développeur sous Ubuntu 10.04 (Lucid Lynx)' (length=63)
  ...

Récupérer la valeur de la première colonne de la première ligne

On peut récupérer la valeur de la première colonne de la première ligne en utilisant la propriété rc :

<?php

$model->select('title')->rc;
string 'Créer un nuage de mots-clé' (length=28)

Choisir le type des enregistrements

En général on laissera le framework décider du type des enregistrements, mais il est possible de décider cela à sa place grâce à la méthode mode :

<?php

$model->select('nid, title')->mode(PDO::FETCH_NUM);

La méthode mode prend les même arguments que la méthode PDOStatement::setFetchMode.

Choisir le type des enregistrements avec les méthodes all et one

Il est également possible de définir le type des données depuis les méthodes all et one :

<?php

$array = $model->order('created DESC')->all(PDO::FETCH_ASSOC);
$record = $model->order('created DESC')->one(PDO::FETCH_ASSOC);

Tester l'existence d'objets

Pour simplement vérifier l'existence d'objets on utilise la méthode exists. Comme la méthode find, cette méthode interroge la base de données à la recherche d'objets, mais au lieu de retourner un objet ou une collection d'objets elle retourne TRUE ou FALSE selon la présence de l'objet, ou des objets, dans la base.

<?php

$model->exists(1);

La méthode exists accepte également les jeux d'identifiants, mais au lieu de retourner TRUE ou FALSE, elle retourne un tableau associatif où chaque clé est la valeur de la clé primaire de l'objet, et la valeur de cette clé est TRUE ou FALSE selon que l'objet existe ou pas.

<?php

$model->exists(1,2,999)
# ou
$model->exists(array(1,2,999));

Ce qui peut donner le résultat suivant :

array
  1 => boolean true
  2 => boolean true
  999 => boolean false

Il est également possible d'utiliser la méthode exists sans argument sur un modèle ou une requête :

<?php

$model->where('author = ?''Madonna')->exists;

La requête ci-dessus retourne TRUE si au moins un auteur a pour nom « Madonna », FALSE dans le cas contraire.

<?php

$model->exists;

La requête ci-dessus retourne FALSE si la table est vide, et TRUE dans le cas contraire.

Fonctions de calcul

Cette section utilise la méthode count comme exemple, mais les options décrites s'appliquent à toutes les sous-sections, même si la méthode count possède quelques particularités.

Les méthodes de calcul peuvent s'appliquer directement sur le modèle :

<?php

$model->count;

Où sur une recherche :

<?php

$model->where('firstname = ?''Ryan').count;

Bien sûr, toutes les méthodes de recherche peuvent être utilisées :

<?php

$model->find_by_firstname('Ryan')->joins(':content')->where('YEAR(date) = 2011')->count;

Compter

La méthode count permet de connaitre le nombre d'enregistrements. Si l'on veut être plus spécifique, on peut connaitre le nombre d'enregistrements selon la valeur d'un champ :

<?php

$model->count('is_online');

Renvera un tableau avec pour clé la valeur de la colonne, et pour valeur le nombre d'enregistrements ayant la même valeur pour la colonne :

array
  0 => string '35' (length=2)
  1 => string '145' (length=3)

Ici, il y a 35 enregistrements en ligne et 145 hors ligne.

Attention, ceci est une particularité de la méthode de calcul count. Aucune autre méthode de calcul ne fonctionne de cette manière.

Moyenne, Minimum, Maximum et somme

Les méthodes de calcul average, minimum, maximum et sum permettent respectivement, pour une colonne, de calculer la moyenne de ses valeurs, la valeur minimum, la valeur maximum et la somme de ses valeurs.

Contrairement à la méthode de calcul count, ces méthodes requièrent le nom de la colonne sur laquelle appliquer le calcul :

<?php

$model->average('comments_count');
$model->minimum('created');
$model->maximum('created');
$model->sum('comments_count');

Récapitulatif de l'ensemble des méthodes sous forme d'exemples

Obtenir des objets :

<?php

$record = $model[10];
# ou
$record = $model->find(10);

$records = $model->find(101519);
# ou
$records = $model->find(array(101519));

Conditions :

<?php

$model->where('is_online = ?'true);
$model->where(array('is_online' => true'is_home_excluded' => false));
$model->where('siteid = 0 OR siteid = ?'1)->where('language = '' OR language = ?'"fr");

# Sous-ensembles

$model->where(array('order_count' => array(123));
$model->where(array('!order_count' => array(123))# contraire

# Filtres dynamiques

$model->find_by_nid(1);
$model->find_by_siteid_and_language(1'fr');

# Portées

$model->visible;

Grouper, ordonner :

<?php

$model->group('date(created)')->order('created');
$model->group('date(created)')->having('created > ?'date('Y-m-d'strtotime('-1 month')))->order('created');

Limites et décalage :

<?php

$model->where('is_online = 1')->limit(10)// retourne les 10 premiers enregistrements
$model->where('is_online = 1')->limit(510)// retourne les enregistrements 6 à 16

$model->where('is_online = 1')->offset(5)// retourne les enregistrements de 6 jusqu'au dernier
$model->where('is_online = 1')->offset(5)->limit(10);

Sélection de champs :

<?php

$model->select('nid, created, title');
$model->select('nid, created, CONCAT_WS(":", title, language)');

Jointures :

<?php

$model->joins('INNER JOIN contents USING(nid)');
$model->joins(':contents');

Récupérer les données :

<?php

$model->all;
$model->order('created DESC')->all(PDO::FETCH_ASSOC);
$model->order('created DESC')->mode(PDO::FETCH_ASSOC)->all;
$model->order('created DESC')->one;
$model->select('nid, title')->pairs;
$model->select('title')->rc;

Tester l'existence d'objets :

<?php

$model->exists;
$model->exists(123);
$model->exists(array(123));
$model->where('author = ?''madonna')->exists;

Fonctions de calcul :

<?php

$model->count;
$model->count('is_online');
$model->average('score');
$model->minimum('age');
$model->maximum('age');
$model->sum('comments_count');

Bref, de quoi faire.

Laisser un commentaire

4 commentaires

oxman
oxman

Et si on veut l'utiliser en standalone, quelle est la procédure ?

Eric
Eric

Le code de la classe WdActiveRecordQuery est vraiment très propre, ce n'est pas souvent, je trouve, dans ce genre d'exercice.

Par contre, je ne comprend pas l'intérêt : Si j'ai bien compris :

<?php

SELECT a,b,c FROM table JOIN table2 ON table.id=table2.id WHERE d=? ORDER BY a DESC LIMIT 5,10;

va devenir :

<?php

$model->select('a,b,c')->joins(':table2')->where('d = ?',1)->order->('a DESC')->offset(5)->limit(10);

Quel est l'avantage par rapport à la première écriture ?

Olivier
Olivier

L'intérêt ne se situe pas vraiment dans la réécriture des requêtes SQL, et on peut très bien s'en passer :

<?php

$model->query('SELECT * FROM {self}')->fetchAll();

Par contre, que se passe-t-il lorsqu'un nombre de fonctions peut modifier la requête SQL pour changer l'ordre des enregistrements, modifier les champs à retourner, ajouter de nouvelles conditions ou jointures, même changer les limites ? Manipuler la chaine de caractères risque de ne pas être très amusant.

L'objet WdActiveRecordQuery est au contraire très malléable puisqu'on peut le modifier à tout moment, dans n'importe quel ordre, sans pour autant connaitre la requête initiale.

Par exemple, tous les types de contenu du mon CMS sont présentés sous forme de tableaux. Chaque type de contenu étant légèrement différent, il hérite de la classe tableau du type de contenu parent, pour y ajouter ses spécificités, ou surcharger celles de son parent. Pour autant, la classe racine n'a pas besoin de connaitre tous les détails, elle se contente de créer un objet de requête initial et de demander sa modification :

<?php

$query = $this->model->where($conditions$conditions_args)->limit($offset$limit);
# les sous classes peuvent s'en donner à cœur joie
$this->alter_query($query);
# et l'on peut récupérer les enregistrements
$records = $query->all;

On peut voir des choses comme ça (les where sont additifs) :

<?php

if (isset($url_variables['year']))
{
    $query->where('YEAR(date) = ?'$url_variables['year']);
}

if (isset($url_variables['month']))
{
    $query->where('MONTH(date) = ?'$url_variables['month']);
}

if (isset($url_variables['day']))
{
    $query->where('DAY(date) = ?'$url_variables['day']);
}

Parce que l'objet est réutilisable, il devient très simple d'avoir un compte de tous les enregistrements disponibles pour une recherche, et de n'en charger qu'un jeu :

<?php

$count = $query->visible->count;
$range = $query->order('created DESC')->limit(10)->all;

Sans parler des portées, puisque dans l'exemple ci-dessus, visible est un raccourcis pour :

<?php

$query->where('is_online = 1 AND (site = 0 OR site = ?) AND (language = "" OR language = ?)'$site->siteid$site->language)

Je t'assure, dans un CMS entièrement orienté objet, c'est utile :-)

Olivier
Olivier

Si par « standalone » tu veux dire « sans le framework », ça risque d'être compliqué. Comme tu peux l'imaginer, les classes qui gèrent les données sont assez dépendantes. Si tu veux utiliser l'interface de requête de la classe WdActiveRecordQuery, il te faudra au moins les classes suivantes :

  • WdObject, qui offre les getters, setters et autres méthodes magiques.
  • WdActiveRecord, qui est la classe de base de tous les enregistrements actifs.
  • WdDatabase, par laquelle s'établissent les connexions aux bases de données.
  • WdDatabaseTable, qui est la représentation bas niveau d'une table de données.
  • WdModel, sous classe de WdDatabaseTable, qui est le point à partir duquel commence l'API Active Record.

Ensuite il faudra remplacer toutes les exceptions de classe WdException sinon il faudra que tu également inclure cette classe ainsi que le système d'I18n.

Bref, c'est possible, mais c'est du boulot. Sinon le framework pèse dans les 300Ko, avec tous ces composants…

Je t'invite à consulter l'article « Vue d'ensemble des connexions, modèles et enregistrements actifs (activerecords) », peut-être trouveras-tu d'autres choses intéressantes.