Le problème
Pour mon travail, je dois collecter une grande quantité de données dans le but de réaliser différentes analyse assez conséquentes dessus. Les données proviennent de différentes sources (dépôts de code source, archives zip, forums, ...), sont extraites par différents logiciels écrits dans différents langages (Java, C, C++, Python, ...), et apparaissent dans différents formats (XML, CSV, ...). Afin que tout se beau monde soit intégré en un lieu unique, j'ai écrit une maître application en Python.
Initialement, les données étaient enregistrées sous la forme de fichiers CSV, mais ce n'était pas une solution adaptée : étant donné la quantité d'information, on se retrouvait avec une multitude de fichiers dont les relations étaient implicitement déterminées par le nom des fichiers et leur place dans le répertoire. De plus, ces données sont, par leur nature, fortement liées. En adoptant une présentation "à plat", on perdait de l'information relative à cette liaison. Une base de données relationnelle a donc été envisagée. SQLite a été retenue par sa simplicité de mise en oeuvre et parce que l'application en est à l'étape de prototype (n'importe quelle base de données supportant SQL pouvant s'y substituer par la suite).
La base comporte une vingtaine de tables, une quinzaine de relations et des milliers (voir millions) d'entrées. Lors de l'étude des données, il est évidemment souvent nécessaire de suivre les relations entre les entités qui peuplent les tables. Une couche ORM, réalisant un mapping entre des classes dans un langage de programmation d'une part et les entités de la base de données d'autre part s'avère nécessaire.
ORM
Un ORM (object-relational mapping) facilite la vie des personnes devant manipuler les bases de données. On part du principe que les entités stockées dans la base de données sont des objets (au sens de la programmation orientée objet). Certaines bases de données, comme PostgreSQL, sont orientées objet : on peut effectuer des requêtes manipulant les objets. Elles sont cependant peu répandue et/ou plus complexe à mettre en oeuvre que les bases de données "traditionnelles".
Les ORM utilisent typiquement deux design patterns pour associer des objets aux bases de données : Active Record et Data Mapper. Active Record est actuellement le plus utilisé. Une classe est composé d'attributs. A chaque classe correspond une table dans la base de données, et chaque attribut de la classe correspond à une colonne dans la table correspondante. Un objet correspond à un tuple dans la table, et les relations entre objets correspondent à des clés étrangères.
SQLAlchemy
Un des ORM les plus réputés lorsqu'on programme en Python est sans conteste SQLAlchemy. Il tire sa force de plusieurs points. Tout d'abord, c'est plus qu'un ORM : c'est un ensemble d'outils pour SQL en python. Vous pouvez ainsi effectuer toutes les opérations SQL imaginables dans un langage de haut niveau et abstrayant les spécificités de la base de données que vous utilisez. Ensuite, c'est un outil très complet et très efficace, qui couvre tous les besoins que vous pourriez avoir. Le prix à payer est une certaine lourdeur d'utilisation. En particulier, lorsque vous l'utilisez comme ORM, vous devez explicitement définir le mappage entre vos classes et votre base de données, ce qui ne facilite pas la maintenance et l'évolution de votre programme. Vous pouvez créer une base de données à partir de vos classes Python, mais, en plus de vos attributs, vous devrez également indiquer explicitement les relations entre les classes, ce qui introduit une redondance.
Elixir
Heureusement, Elixir vient sauver la mise. Il s'agit d'une surcouche à SQLAlchemy. Ce dernier reste donc nécessaire, mais vous ne devez plus l'utiliser directement (vous le pouvez toujours, cependant). La surcouche est très légère, et les performances sont au rendez-vous (bien que je n'ai pas encore personnellement testé cet aspect avec attention). Elixir propose uniquement des fonctions d'ORM, ce qui est bien moins complet que ce que permet SQLAlchemy.
Utilisation
Le Wiki d'Elixir est très bien pensé, et permet un démarrage rapide. Pour l'exemple, réalisons le traditionnel blog, où des auteurs peuvent publier des billets recevant des commentaires.
Tout d'abord, on crée le module blog dans le fichier blog.py contenant les classes :
# -*- coding: utf-8 -*- from elixir import * from datetime import datetime class Blog(Entity): # Toutes les classes à mapper doivent hériter de Entity name = Field(String(50)) # sera traduit par un varchar(50) subjet = Field(String(50)) # pas nécessaire pour utiliser Elixir, il ne l'utilise # jamais de lui-même def __init__(self, name, subjet): self.name = name self.subjet = subjet # Autre méthode de convenance : # juste pour une représentation "humaine", # pas nécessaire pour Elixir def __repr__(self): return "Blog<%s>" % self.name class User(Entity): # Bien sûr, l'héritage est géré, et vous pouvez # hériter d'une classe héritant de Entity. name = Field(String(50)) firstname = Field(String) # La taille du string n'est pas nécessaire, mais plusieurs SGDB la demande email = Field(String) # 1- Un lien one-to-many : on peut avoir écrit plusieurs billets # 2- A ce niveau du fichier, on ne connaît pas encore la classe Poll, # donc, on l'écrit comme une chaîne de caractère. polls = OneToMany('Poll') # L'utilisateur est aussi propriétaire de commentaires comments = OneToMany("Comment") def __init__(self, firstname, name): self.name = name self.firstname = firstname def __repr__(self): return "User<%s %s>" % self.firstname, self.name class Poll(Entity): # Bien sûr, l'héritage est géré, et vous pouvez # hériter d'une classe héritant de Entity. title = Field(String(50)) content = Field(Text) # Un texte arbitrairement grand publication = Field(DateTime) # La date de publication owner = ManyToOne(User) # Un seul propriétaire du billet comment = OneToMany("Comment") def __init__(self, title, content): self.title = title self.content = content self.publication = datetime.now() def __repr__(self): return "Poll<%s>" % self.title class Comment(Entity): content = Field(Text) owner = ManyToOne(User) poll = ManyToOne(Poll) publication = Field(DateTime) def __init__(self, content, poll): self.content = content self.poll = poll # Création d'un lien self.publication = datetime.now() def __repr__(self): return "Comment<%s>" % self.content
Ensuite, utilisons ces classes pour créer la base de données correspondante et mémoriser quelques entités :
# -*- coding: utf-8 -*- from elixir import * from blog import * # On se connecte à une base de données (ici : sqlite) metadata.bind = 'sqlite:////home/mg/myBDD.sqlite' # On veut afficher les requêtes SQL en console metadata.bind.echo = True # On créer la relation entre les classes et les tables setup_all() # On crée toutes les tables nécessaires create_all()
OK, on commence
moi = User(u"Mathieu", u"Goeminne") lui = User(u"Raymond", u"Barre") blob = Blog(u"myBlog", u"Un blog intéressant") billet = Poll(u"La question ultime?", u"D'après vous, quelle est la réponse à la grande question sur la vie, l'univers et le reste") billet.owner = moi
Tout travail n'est pas pris en compte tant qu'on n'a pas commité, même si la bdd ne gère pas les transactions.
session.commit()
Modifions un peu notre base de données.
comment = Comment(u"Ca rétame du chacal unijambiste à la pince-monseigneur! Et même, ça décalotte de la taupe-garou apatride à la tronçonneuse", lui) comment.poll = billet
Décidemment, ce commentaire est peu crédible. Annulons toute modification depuis le dernier commit!
session.rollback()
Quelques requêtes sur les tables :
# Récupération de tous les utilisateurs l = User.query.all() # On les compte compte = User.query.count() # On les filtre, et on les trie lt = Poll.query.filter_by(title.like('%google')).order_by(title) for p in lt: print p # On veut tous les billets ayant été publiés par moi-même Poll.query.filter_by(owner=moi)
Finalement, mon billet n'était pas terrible. Supprimons-le.
billet.delete() session.commit
Commentaires
Bien sûr, ce n'est qu'on aperçu de ce qu'il est possible de réaliser avec Elixir. Cependant, les amateurs de Django auront remarqué que la syntaxe est semblable (mais pas similaire!) à cette utilisée dans leur framework web préféré. Pour plus d'informations sur la syntaxe et les possibilités de l'ORM, n'hésitez pas à consulter la documentation officielle, assez bien faite. En particulier, les attributs peuvent être plus finement décrits afin de prendre en compte les clés primaires, étrangères, etc.
Remarquez aussi que les relations sont déterminées par un attribut de classe, ce qui permet de les indiquer de manière explicite. Dans les relations de type Many, si plusieurs relations sont établies vers une même classe, l'option field permet de déterminer de quelle relations il est question de manière explicite. Dans beaucoup de cas, le nom de l'attribut suffit à savoir de quelle relation on parle à tout moment.
Limitations
Elixir a tout de même quelques limitations qu'il faut soulever.
Tout d'abord, comme cela a été dit, ce n'est qu'une surcouche d'SQLAlchemy, qui échange une simplicité d'utilisation contre une diminution des possibilités d'utilisation. Il est donc nécessaire de mettre en balance le pour et le contre, essentiellement en déterminant si vous avez besoin de plus qu'un ORM. Si non, Elixir sera un outil suffisant pour 99% des besoins.
Il s'agit d'un tout jeune projet, qui doit encore faire ses preuves dans le cadre de grands développements. Il est bien conçu, et simple d'utilisation, mais certaines imperfections doivent encore se faire, surtout des petits bugs internes pas vraiment gênant pour l'utilisateur final. En principe, il doit supporter les mêmes SGDB que SQLAlchemy (SQLite, MySQL, PostgreSQL, Oracle, MSSQL, Firebird, Sybase, Access). En pratique, une meilleur intégration est à l'ordre du jour. Je n'ai cependant rencontré aucun problème particulier avec SQLite et MySQL.
Le design pattern Data Mapper considère d'une part les classes, d'autre part les tables, et utilise une entité médiatrice qui sait comment associer les deux parts et comment les modifications des éléments d'une part influencent les éléments de l'autre part. C'est une solution plus souple, car elle profite d'un découplage très fort entre les classes et les tables. Elle permet également des comportements plus complexes, avec des mappages non triviaux. C'est cependant une solution peu utilisée, car il est rare qu'on ait besoin d'autant de souplesse. En général, représenter un objet par un tuple est une solution satisfaisante, et en particulier dans mon cas.
Une dernière limitation concerne la redondance introduite par les relations : la relation doit être décrite dans les deux classes intervenantes. Il est ainsi possible que les classes soient dans un état inconsistant, si la relation n'est déclarée que dans une seule des classes. Cela viole le principe DRY si cher à de nombreux frameworks, dont Django.
En fin de compte, il s'agit d'une solution intéressante qui, malgré quelques défauts, n'aura pas de mal à vous séduire dès lors que vous devez gérer des bases de données en python.

