Créer une application Perl autour de MySQL : DBIx::Class (2/3)

GNU/Linux Magazine n° 168 | février 2014 | Dominique Dumont
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Reprenons l'histoire du hacker à qui on a demandé de créer des pages web pour le suivi de la production logicielle de la société. Comme tout hacker qui se respecte, il préfère que la machine fasse son boulot à sa place. Pour ce faire, il a mis en place une base de données MySQL. Reste maintenant à utiliser Perl et DBIx::Class pour accéder aux données de cette base.

Introduction

Petit rappel de l'épisode précédent : notre hacker favori a expliqué comment mettre en place dans une base de données MySQL les tables et relations qui assureront la persistance des données de production.

Pour rappel, voici un résumé des tables créées. Le code complet en SQL est disponible sur le GitHub de l'auteur [GITHUBADOD] :

- Table « Packages », avec « id » et « name »,

- Table « Products » avec « id », « name » et « home_page »,

- Table « ProductVersions » avec « id », « version », « product_id », « date », « log », « status »,

- Table « TarballPackages » avec « tarball_id » et « package_id »,

- Table « TarBalls » avec « id », « url » et « product_version_id ».

Chaque colonne finissant en « _id » est une clef étrangère sur la colonne « id » d'une autre table. Par exemple, « product_id » est une clef étrangère sur la colonne « id » de la table « Products ».

1. Accès DB avec Perl

On a maintenant une jolie base de données toute configurée pour recevoir les données de produits et leur myriade de versions. Mais, il s'agit maintenant d'y accéder à partir de notre application.

1.1 Les ORM ou comment ne pas se compliquer la vie ?

Un des premiers réflexes qu'on peut avoir est d'écrire une application générant tout le code SQL nécessaire pour gérer les données. (SQL est le langage de manipulation des bases de données). Au début, ça va suffisamment vite à coder pour rassurer le chef, mais ça finit souvent en un seau de code difficile à maintenir (c'est du vécu), car l'application mélange deux langages avec des paradigmes différents.

Bien des gens brillants se sont penchés sur le problème de relier les données d'un programme avec celles d'une base de données. Les ORM ou Object Relational Mapper, qu'on peut traduire en Passerelle Objet Relationnel (dommage que l'acronyme ne soit pas très vendeur...) permettent de minimiser le code à écrire (aussi bien en Perl qu'en SQL).

Avec un peu de chance, un ORM vous permettra d'écrire votre application sans avoir à écrire une seule ligne de SQL. Mais attention, des connaissances en SQL sont quand même fortement conseillées : les contraintes mises en place sur la base (c'est-à-dire index unique, clef étrangère) vont provoquer des erreurs lors de la mise au point. Ces erreurs sont montrées avec les instructions SQL rejetées par la DB. À vous d'interpréter ces erreurs SQL pour trouver les instructions Perl erronées dans votre programme.

Pour le projet, l'ORM Perl DBIx::Class a été choisi. Il contient toutes les notions requises pour faire de la programmation objet moderne. Le seul problème est qu'il faut lui spécifier la structure de la base de données pour qu'il puisse générer les classes et les méthodes qui feront l'interface avec la base.

Et là, miracle, un codeur fou (celui à qui personne n'a dit que c'était impossible) a créé une application qui interroge la base de données, en extrait le schéma et génère toutes les déclarations qui vont bien pour DBIx::Class : que du bonheur ! Il s'agit de dbicdump fourni avec DBIx::Class::Schema::Loader.

1.2 Installation

Sous Debian, il faudra installer les paquets libdbix-class-perl et libdbix-class-schema-loader-perl (version >= 0.07015).

1.3 Génération des interfaces avec « dbicdump »

Générer l'interface Perl pour votre base de données se résume à cette commande :

dbicdump -o dump_directory=./lib -o use_moose=1 \

-o components='["InflateColumn::DateTime"]' \

Integ::Schema dbi:mysql:Integ root s3cr3t

Voici quelques explications sur les options :

- use_moose=1 : dbicdump peut générer des classes Perl classiques ou des classes Moose (basées sur MooseX:NonMoose) ;

- components='["InflateColumn::DateTime"]' : cette option est fortement recommandée dans la documentation de DBIx::Class::Schema::Loader. Elle permet de gérer toute colonne de type DATE à travers un objet DateTime, ce qui est très pratique.

- Les 3 derniers arguments permettent à dbicdump de se connecter sur la base.

Une fois cette commande finie, on obtient :

$ find . -name *.pm

./lib/Integ/Schema.pm

./lib/Integ/Schema/Result/Package.pm

./lib/Integ/Schema/Result/ProductVersion.pm

./lib/Integ/Schema/Result/TarBall.pm

./lib/Integ/Schema/Result/TarballPackage.pm

./lib/Integ/Schema/Result/Product.pm

Chaque fichier contient la documentation en format Pod et toutes les instructions DBIx::Class pour créer la classe d'accès à votre base de données. Prenons par exemple la classe Integ::Schema::Result::ProductVersion et voyons les parties les plus importantes :

package Integ::Schema::Result::ProductVersion;

use Moose;

use MooseX::NonMoose;

use MooseX::MarkAsMethods autoclean => 1;

extends 'DBIx::Class::Core';

La classe d'interface est bien une classe Moose héritant de la classe DBIx::Class::Core (celle-ci n'est pas une classe Moose).

Le code suivant déclare la table et les composants chargés :

__PACKAGE__->table("ProductVersions");

__PACKAGE__->load_components("InflateColumn::DateTime");

Voici la déclaration des colonnes. add_columns va aussi ajouter les méthodes correspondantes (« accessors ») de façon à pouvoir écrire print $obj->version; ou $obj->version(1.002).

__PACKAGE__->add_columns(

"id",

{ data_type => "integer", is_auto_increment => 1, is_nullable => 0 },

"version",

{ data_type => "varchar", is_nullable => 0, size => 32 },

"product_id",

{ data_type => "integer", is_foreign_key => 1, is_nullable => 0 },

);

Déclaration de la relation entre Products et ProductVersions :

__PACKAGE__->belongs_to(

"product",

"Integ::Schema::Result::Product",

{ id => "product_id" },

{ is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" },

);

Voici la déclaration de la relation entre Products et TarBalls. Le code généré contient aussi des indications sur les clés étrangères et leur contraintes :

__PACKAGE__->has_many(

"tar_balls",

"Integ::Schema::Result::TarBall",

{ "foreign.product_version_id" => "self.id" },

{ cascade_copy => 0, cascade_delete => 0 },

);

Le commentaire suivant est important, car il définit la zone à partir de laquelle vous pouvez apporter vos propres modifications. Cette zone peut servir à définir de nouveaux attributs ou méthodes pour votre classe d'interface.

# Created by DBIx::Class::Schema::Loader v0.07015 @ 2012-02-07 20:31:16

# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:/R7o/BCNPW4v3IK8gUkIxQ

Si vous avez à modifier le schéma... Non, je reprends... Quand vous aurez à modifier le schéma de la DB pour suivre les besoins de vos clients, il suffira de relancer la commande dbicdump pour obtenir une nouvelle interface. La zone « libre » ne sera pas touchée. dbicdump ira même jusqu'à vérifier la syntaxe du fichier résultant.

1.4 Extension de la classe d'interface

En général, le premier réflexe est d'ajouter dans cette zone modifiable toutes les méthodes dont on a besoin. Mais c'est un mauvais réflexe. Pour pouvoir gérer correctement votre projet dans le temps et tester la logique métier indépendamment de la DB, il vaut mieux séparer clairement l'interface de la DB de la logique métier.

Le plus simple, quand on utilise Moose, est d'étendre la classe en ajoutant un rôle à la classe générée :

# Created by DBIx::Class::Schema::Loader v0.07015 @ 2012-01-31 15:49:49

# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:NiaYh10AJwYDWMg5lwliVA

# contient mon code à moi pour créer les pages HTML

with 'Integ::HTML::ProductVersion' ;

__PACKAGE__->meta->make_immutable;

1;

Note

Dans le rôle Moose, on peut aussi ajouter des attributs pour enrichir la classe. Ces attributs ne seront pas stockés dans la base de données. Curieusement, les valeurs par défaut spécifiées dans les déclarations des attributs sont ignorées. Il faut utiliser lazy_build => 1 (ou lazy => 1, build => '_build_toto') et spécifier une valeur par défaut avec une fonction du genre sub _build_toto { return "TOTO"; }.

1.5 Connexion à la base

La connexion à la base se fait directement à partir de la classe générée par dbicdump. On prépare d'abord une variable pour spécifier à DBI (le gestionnaire de la connexion à la DB) où se connecter :

my $integ_dsn = 'dbi:mysql:database=Integ;host=localhost;port=3306';

Puis, on se connecte :

my $integ_schema = Integ::Schema->connect(

$integ_dsn, 'integ_mgr', $password,

{ RaiseError => 1 }

);

Pour l'accès en lecture seule, le compte integ_user n'ayant pas de mot de passe, il suffira de passer une chaîne vide en lieu de mot de passe :

my $integ_schema = Integ::Schema->connect(

$integ_dsn, 'integ_user', '',

{ RaiseError => 1, quote_names => 1 }

);

L'option quote_names est nécessaire, car la colonne home page contient un espace.

1.6 Lecture des données

Maintenant qu'on a une connexion toute neuve à la base, on va pouvoir passer aux choses sérieuses.

Avant de continuer, il faut préciser qu'un nom de produit fictif sera utilisé dans cet article. Toute ressemblance avec un produit existant ou ayant existé serait fortuite et involontaire. En effet, par les temps qui courent, il vaut mieux être prudent avec les marques déposées si on ne veut pas se prendre un procès sur la courge. Tiens, ça me donne une idée : notre produit va s'appeler « OpenCourge ». Avec ça, not' rédac-chef favori devrait dormir tranquille ;-)

Pour des raisons pédagogiques, on va d'abord se pencher sur l'extraction de données de la base avant de voir l'écriture. Comme la base est vide, on va ajouter en SQL un produit et une version pour pouvoir tester la lecture avec DBIx::Class. Je vous propose :

$ myqsl -u integ_mgr -p Integ

...

mysql> insert into Products set name="OpenCourge" ;

mysql> insert into ProductVersions set version=1.01, product_id=1, date = "2012-08-20" ;

mysql> insert into ProductVersions set version=1.02, product_id=1, date = "2012-09-27" ;

mysql> quit ;

Première étape pour lire dans la base, il faut trouver le produit OpenCourge. On va d'abord récupérer un objet DBIx::Class::Resultset qui donne accès à la table Products :

my $product_rs = $integ_schema ->resultset('Product');

Notez que le nom de la table est « Products » avec un 's', mais qu'on cherche « Product » (sans 's') dans le schéma. En effet, DBIx::Class::Schema::Loader passe au singulier le nom de la table (qui représente un ensemble de données) pour créer le nom de la classe d'interface (qui représente plutôt un type de données). Pour plus de détails, voir la doc de DBIx::Class::Schema::Loader::Base.

Une fois qu'on a accès à l'ensemble des produits à travers $product_rs, on peut rechercher un produit spécifique :

my $product_row = $product_rs ->find ( { name => 'OpenCourge' } );

La méthode find va renvoyer un objet DBIx::Class::Row référençant la ligne de la table Products ayant pour nom OpenCourge. Comme la colonne Name a un index UNIQUE, on est sûr de référencer la bonne ligne. Le SQL exécuté sera un simple select * from Product where name='OpenCourge' ;.

Surtout, n'oubliez pas les accolades, sinon find chercherait une clé primaire basée sur deux colonnes nommées « name » et « OpenCourge ».

1.7 Utilisation de la relation « 1-vers-n »

On a retrouvé notre produit favori dans la base, il s'agit maintenant de récupérer les versions disponibles. On rappelle que ces versions sont stockées dans leur propre table et liée à la table « Products » avec la colonne « product_id ». La documentation de la classe générée Integ::Schema::Result::Product indique que dbicdump a matérialisé cette relation avec la méthode « product_versions ».

Ainsi, la ligne suivante va renvoyer un objet DBIx::Class::Resultset contenant les versions attachées à notre produit :

my $product_version_rs = $product -> product_versions;

say $product_version_rs->count ; # 2

Dans cet ensemble de versions, on peut chercher une version spécifique :

my $v = $product_version_rs->find({version => '1.02'}) ;

say $v->date->ymd ; # 2012-09-27

Ou chercher des versions selon un critère :

my $v_rs = $product_version_rs->search({date => { like => '2012%' }}) ;

say join(' ', map { $_->version } $v_rs->all) ; # 1.01 1.02

Notez que la méthode find est utilisée si le critère renvoie une seule valeur et renvoie un objet DBIx::Class::Row relié à une ligne dans la table. La méthode search concerne des recherches plus floues et renvoie un objet DBIx::Class::Resultset représentant un ensemble de lignes.

1.8 Écriture des données

Maintenant, il est temps de créer de nouvelles versions du produit dans la base à travers DBIx::Class. Pour éviter les ennuis, les instructions de création de version doivent être idempotentes. Il faut donc vérifier si la version existe déjà avant de la créer. Heureusement, DBIx::Class fournit la méthode qui permet de faire ça d'une manière concise :

say $product_version_rs->count ; # 2

$product_version_rs->find_or_create( { version => '1.04' } );

say $product_version_rs->count ; # 3

$product_version_rs->find_or_create( { version => '1.04' } );

say $product_version_rs->count ; # 3, pas de changement

1.9 Modification des données

Mettre à jour les données dans une ligne est très simple avec les méthodes fournies :

my $product_version_row = $product_version_rs -> find( { version => '1.04'} ) ;

$product_version_row -> log( "c'est mûr" );

Mettre en place la date de publication de la version demande un peu plus de précautions si on ne veut pas changer cette date chaque fois que le programme est lancé :

my $d = $product_version_row->date ;

$product_version_row->date( DateTime->now ) unless $d;

Le composant InflateColumn::DateTime va se charger de traduire l'objet DateTime en une chaîne de caractères compréhensible par MySQL.

Et pour déclarer les tarballs générés, on fera :

my $tarball_obj = $product_version_row

-> tarballs

-> find_or_create ( {

url => "http://cucurbitacée.com/repo/OpenCourge-i386.tar"

} ) ;

# http://cucurbitacée.com/repo/OpenCourge-i386.tar

say $product_version_row->tar_balls->single->url ;

Mais les informations écrites sont pour l'instant dans la mémoire du programme. Il faut les transférer dans la base :

$product_version_row -> update;

1.10 Utilisation de la relation « m-vers-n »

Pour pouvoir gérer les paquets et les tarballs, on va aborder le domaine qui rebute souvent les débutants en base de données relationnelles : la relation « plusieurs vers plusieurs », qui implique une table de liaison intermédiaire. En SQL, retrouver ses petits dans cette relation nécessite l'utilisation de jointures.

Heureusement, la classe DBIx::Class::Relationship (héritée par DBIx::Class::Core) gère ces relations et permet de masquer au programmeur les détails de la jointure (sauf en cas d'erreur) avec la fonction many_to_many.

Pour que dbicdump déclare cette liaison many_to_many, il faut une table de liaison avec :

- deux colonnes configurées en clé étrangère,

- une clé primaire faite avec ces 2 colonnes.

Note

Attention : la génération de la relation many_to_many ne fonctionne qu'à partir de la version 0.07015 de DBIx::Class::Schema::Loader.

À défaut, vous pouvez ajouter vous-même cette relation dans la zone « libre » de la classe générée par dbicdump. Voir [MANYTOMANY] pour les détails.

Revenons-en au programme Perl. Il faut maintenant associer le tarball avec ses paquets dans la base de données. Plusieurs cas de figure peuvent se présenter :

- Vous êtes certain que le paquet n'existe pas dans la base. Dans ce cas, l'ajout est très simple :

$tarball->add_to_packages({ name => 'concombre-1.1.el5.i386.rpm'} ) ;

L'inconvénient de cette commande est qu'elle va chercher à créer une nouvelle ligne dans la table « Packages ». Si ce paquet est déjà connu, la base refusera l'insertion, car la colonne « name » a un index unique.

- Le paquet existe dans la base, mais la relation entre le tarball et le paquet n'existe pas. La méthode find_or_create_related est capable de gérer ce package qui existe peut-être :

$tarball_obj->find_or_create_related('tarball_packages',

{ package => { name => 'courgette-1.2.el5.i386.rpm' }}) ;

- Le paquet et la relation peuvent exister dans la base. C'est le cas à considérer si vous voulez obtenir un script idempotent. Dans ce cas, il faut toujours vérifier l'existence de la relation et utiliser la méthode find_or_create_related comme avant :

my $pkg_name2 = 'courgette-1.2.el5.i386.rpm' ;

if (not $tarball_obj->packages->search({name => $pkg_name2})->count) { ;

$tarball_obj->find_or_create_related('tarball_packages',

{ package => { name => $pkg_name2 }}) ;

}

Le premier cas de figure est le plus facile à traiter, mais le moins souple. À vous de choisir ce qui conviendra le mieux à votre application.

1.11 Et les jointures ?

Supposons que mon chef me demande maintenant de pourvoir lister les versions de produits qui contiennent des corrections de sécurité. Il paraît que c'est facile, les logs du paquet contiennent la mention du CVE corrigé. Il faudra, pour chaque version de produit, aller chercher le tarball associé, ses packages et chercher CVE dans le log.

Il faut donc :

- Modifier le schéma de la table « Packages » pour ajouter une colonne « log »,

- Régénérer les classes d'interface vers la base,

- Scanner les paquets pour récupérer leur log et le stocker dans la base,

- Enfin, construire une requête avec une jointure pour extraire l'information de la base.

Les trois premières étapes ne posant pas de problème particulier, on va se concentrer sur la dernière. Il faut utiliser une jointure basée sur les relations mises en place par DBIx::Class :

my $cve_rs = $integ_schema->resultset('ProductVersion')->search(

{

'package.log' => { like => '%CVE%'}

},

{

join => { tar_balls => { tarball_packages => 'package' }}

}

);

L'appel à search prend deux paramètres sous forme de « hash ref ». La jointure est spécifiée dans le deuxième « hash » : la première difficulté est que la jointure passe au travers d'une relation « many-to-many » (entre les tarballs et les paquets). DBIx::Class ne sait pas gérer la jointure en utilisant directement la relation « many-to-many » entre « TarBalls » et « Packages » (matérialisée par la méthode packages de la classe Integ::Schema::Result::Tarball).

La jointure est donc spécifiée avec les relations plus simples « has_many » et « belongs_to ». Chaque relation à traverser est représentée par un « hash ref » : il faut spécifier la jointure à partir de la table « ProductVersions » (join => {), trouver les tarballs associés (tar_balls => {), utiliser la relation tarball_packages vers la table de liaison et enfin, spécifier la dernière relation vers la table « Packages », (tarball_packages => 'package').

Le premier paramètre du search contient juste le critère de recherche. Comme l'attribut log ne fait pas partie de la table « ProductVersions », mais de la table « Packages », il faut spécifier le nom de la relation utilisée dans la jointure et le nom de la colonne (package.log). Comme on recherche un log qui contient « CVE », un simple critère comme package.log = 'CVE' > ne suffit pas, car ce critère spécifie une égalité stricte. Il faut utiliser un hash ref pour spécifier un critère assez proche du SQL : like = '%CVE%'>. En SQL, le « % » signifie « n'importe quoi ». On recherche donc une chaîne quelconque contenant « CVE » quelque part.

D'autres formes de jointure sont possibles en jouant sur la structure de données passée au join. La doc DBIx::Class::Manual::Joining contient beaucoup d'autres possibilités.

2. « Show me the code ! »

Vous trouverez sur [GITHUBADOD] un petit script pour générer le schéma (gen-schema.sh) et un petit programme de test qui reprend tout le code de cet article (test-db.pl).

3. Oui, mais qu'est-ce que je montre à mon chef ?

Résumons : on a une base de données, de quoi y accéder en Perl sans trop se fatiguer. Avec ça, on peut écrire des programmes qui tournent dans un bon vieux terminal. Pour un hacker, c'est parfait. Mais ce n'est pas bien alléchant pour mon chef. Il faudrait lui montrer un truc à la mode, genre une application web qui permette de voir et manipuler les versions de produit.

Pour remédier à ça, je vous propose de découvrir dans un prochain article comment exploiter DBIx::Class dans une application web.

Remerciements

Les Mongueurs de Perl pour leur accueil et la relecture de cet article.

Liens

[DBICLASS] Class::DBI, un ORM pour Perl : Application à la gestion de DNS, GNU/Linux Magazine n°96 (http://articles.mongueurs.net/magazines/linuxmag96.html)

[MANYTOMANY] http://search.cpan.org/dist/DBIx-Class/lib/DBIx/Class/Relationship.pm#many_to_many

[GITHUBADOD] https://github.com/dod38fr/glmf-article-dbix-class-web

Tags : MySQL, Perl