Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Ève

Adaptation des types Python à PostgreSQL pour psycopg

Première rédaction de cet article le 16 septembre 2008


psycopg est l'interface avec PostgreSQL la plus répandue chez les programmeurs Python. Parmi toutes ses qualités, psycopg adapte automatiquement les types Python aux types PostgreSQL. Mais cette adaptation ne fonctionne pas toujours toute seule et a parfois besoin d'un coup de main.

Avec psycopg, si j'ai créé une table en SQL avec :

 
CREATE TABLE Foobar (t TEXT, i INTEGER);

je peux écrire dans mon programme Python :

cursor.execute("INSERT INTO Foobar (t, i) VALUES (%s, %s)", 
               ["I like Python", 42])

et psycopg trouve tout seul que "I like Python" est une chaîne de caractères alors que 42 est un entier. Il quote (met entre apostrophes) donc le premier et pas le second. PostgreSQL recevra (instruction SQL affichée grâce à log_statement = 'all') :

2008-09-16 13:40:19 CEST STATEMENT:  INSERT INTO Foobar (t, i) VALUES ('I like Python', 42)

Cela marche bien dans ce cas car Python connait les types utilisés et peut donc le dire à psycopg2 :


>>> type(42)
<type 'int'>
>>> type("I like Python")
<type 'str'>

Mais si on a affaire à des types qui existent dans PostgreSQL et pas dans Python ? Le cas est fréquent car PostgreSQL dispose de nombreux types comme par exemple les points, les adresses IP ou encore les UUID (RFC 4122). Essayons avec les adresses IP. Créons la table  :

CREATE TABLE Foobar (addr INET);

puis remplissons-la :

cursor.execute("INSERT INTO Foobar (addr) VALUES (%s)", 
                            ["2001:DB8::CAFE:1"])

Ça marche, l'adresse IP 2001:db8::cafe:1 a bien été enregistrée. Python ne connait pas ce type, donc l'a traité comme une chaîne de caractères, que PostgreSQL a su analyser.

Mais si on veut mettre un tableau de telles adresses (petit détour par le DNS : il est tout à fait légal, et même fréquent, qu'à un nom de machine donnée correspondent plusieurs adresses IP) ? Là, Python, psycopg et PostgreSQL ne sont pas assez intelligents. On crée la table :

CREATE TABLE Foobar (addr INET[]);

et on tente de la peupler :


>>> cursor.execute("INSERT INTO Foobar (addr) VALUES (%s)", [["2001:DB8::CAFE:1", "192.168.25.34"]])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
psycopg2.ProgrammingError: column "addr" is of type inet[] but expression is of type text[]
LINE 1: INSERT INTO Foobar (addr) VALUES (ARRAY['2001:DB8::CAFE:1', ...
                            ^
HINT:  You will need to rewrite or cast the expression.

En effet, PostgreSQL aura reçu :

2008-09-16 13:53:01 CEST LOG:  statement: INSERT INTO Foobar (addr) VALUES (ARRAY['2001:DB8::CAFE:1', '192.168.25.34'])

alors qu'il aurait fallu envoyer INSERT INTO Foobar (addr) VALUES ('{2001:DB8::CAFE:1, 192.168.25.34}'). (Le problème serait le même pour le type UUID.)

Quelle est la bonne solution ? psycopg pourrait être plus malin et mieux connaître les types de PostgreSQL mais c'est plus compliqué que ça en a l'air. Heureusement, il existe une solution assez simple. Depuis sa version 2, psycopg permet de définir des adaptateurs soi-même, c'est-à-dire le code qui va faire la conversion d'un objet Python, au moment de l'appel à PostgreSQL. Cette technique est documentée (sommairement) dans le fichier doc/extensions.rst qui est distribué avec psycopg. (On le trouve aussi sur le Web en http://www.initd.org/svn/psycopg/psycopg2/trunk/doc/extensions.rst mais, servi avec une mauvaise indication de son type, il est difficile à lire.) Voici l'exemple que donne la documentation. Il utilise le type POINT de PostgreSQL, qui représente un point dans un espace cartésien à deux dimensions. On crée la table avec :

CREATE TABLE Atable (apoint POINT);

et on peut la peupler en SQL ainsi :

INSERT INTO Atable (apoint) VALUES ('(1, 3.14159)');

Pour que cela soit fait automatiquement depuis le programme Python utilisant psycopg2, le code est :

    from psycopg2.extensions import adapt, register_adapter, AsIs
    
    class Point(object):
        def __init__(self, x=0.0, y=0.0):
            self.x = x
            self.y = y
    
    def adapt_point(point):
        return AsIs("'(%s,%s)'" % (adapt(point.x), adapt(point.y)))
        
    register_adapter(Point, adapt_point)
    
    curs.execute("INSERT INTO atable (apoint) VALUES (%s)", 
                 (Point(1.23, 4.56),))

Et voici enfin le (tout petit) adaptateur que j'ai écrit pour le type PostgreSQL INET, qui permet de représenter des adresses IP :

from psycopg2.extensions import adapt, register_adapter, AsIs

class Inet(object):
    """ Classe pour stocker les adresses IP. Pour que psycopg puisse
    faire correctement la traduction en SQL, il faut que les objets
    soient typés. Les chaînes de caractères ne conviennent donc pas. """
  
    def __init__(self, addr):
        """ Never call it with unchecked data, there is no protection
	against injection """
        self.addr = addr

def adapt_inet(address):
    """ Adaptateur du type Python Inet vers le type PostgreSQL du même
    nom. On utilise un "cast" PostgreSQL, représenté par '::inet' """
    return AsIs("'%s'::inet" % address.addr)

register_adapter(Inet, adapt_inet)

Et on l'utilise ainsi (notez qu'il faut appeler le constructeur Inet() pour « typer » les valeurs) :

cursor.execute("INSERT INTO Foobar (addr) VALUES (%s)", 
                 [[Inet("2001:DB8::CAFE:1"), Inet("192.168.25.34")]])

Désormais, cela fonctionne. PostgreSQL reçoit :

2008-09-16 17:43:48 CEST LOG:  statement: INSERT INTO Foobar (addr) VALUES (ARRAY['2001:DB8::CAFE:1'::inet, '192.168.25.34'::inet])

et exécute bien la requête.

Merci à Chris Cogdon et Federico Di Gregorio (l'auteur de psycopg) pour leur aide sur ce sujet.

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)