Typer des requêtes SQL (Haskell)

Voir aussi : video youtube - code source

Cet article présente deux bibliothèques Haskell pour manipuler des bases de données, l’une via des requêtes SQL classiques, l’autre via des requêtes typées. Le système de types de Haskell aide à écrire des requêtes valides et sécurisées.

Exemple de base de données

Soit une base de données sqlite de films, avec une table décrivant les réalisateurs et une autre les films.

CREATE TABLE director (
    dir_id INTEGER PRIMARY KEY AUTOINCREMENT,
    dir_firstname TEXT NOT NULL,
    dir_lastname TEXT NOT NULL
);

CREATE TABLE movie (
    mov_id INTEGER PRIMARY KEY AUTOINCREMENT,
    mov_dir INTEGER NOT NULL,
    mov_title TEXT NOT NULL,
    mov_year INTEGER,
    FOREIGN KEY(mov_dir) REFERENCES director(dir_id)
);

INSERT INTO director VALUES(1, 'Ted', 'Kotcheff');
INSERT INTO director VALUES(2, 'Stanley', 'Kubrick');

INSERT INTO movie VALUES(1, 1, 'First Blood', 1982);
INSERT INTO movie VALUES(2, 2, 'Paths of Glory', 1957);
INSERT INTO movie VALUES(3, 2, 'Full Metal Jacket', NULL);

On génère la base de données (movies.db) avec la commande :

sqlite3 movies.db < movies.sql

Client mid-level (sqlite-simple)

La bibliothèque sqlite-simple est de type “mid-level”. Elle apporte les fonctionnalités nécessaires pour accéder à la base de données et écrire des requêtes à peu près sécurisées mais nécessite d’écrire du SQL.

Requête basique

Par exemple, pour récupérer le prénom et le nom des réalisateurs, on écrit la requête SQL correspondante et on l’exécute avec query_. La bibliothèque sqlite-simple vérifie que les données retournées par la base de données correspondent bien au type indiqué (ici [(Text, Text)]).

selectDirectors :: Connection -> IO [(Text, Text)]
selectDirectors conn = query_ conn 
    "SELECT dir_firstname, dir_lastname FROM director"

On peut alors appeler notre fonction, lors d’une connection à une base de données :

main :: IO ()
main = withConnection "movies.db" $ \conn -> do

    putStrLn "\n**** selectDirectors ****"
    selectDirectors conn >>= mapM_ print

Résultat :

**** selectDirectors ****
("Ted","Kotcheff")
("Stanley","Kubrick")

Typage

Dans l’exemple précédent, on a récupéré les lignes résultats avec un type pré-existant ([(Text, Text)]) mais on peut aussi définir nos propres types. Par exemple, si on veut récupérer toutes les données d’un film à partir du prénom/nom du réalisateur, on peut définir un type Production et instancier la classe FromRow pour récupérer les champs nécessaires :

data Production = Production
    { dir_firstname :: Text
    , dir_lastname :: Text
    , mov_title :: Text
    , mov_year :: Maybe Int
    } deriving (Generic, Show)

instance FromRow Production where
    fromRow = Production <$> field <*> field <*> field <*> field

On peut ensuite récupérer ces données depuis la base de données : on écrit la jointure dans la requête SQL et on précise le type des résultats ([Production]).

selectProductions :: (Text, Text) -> Connection -> IO [Production]
selectProductions director conn = query conn 
    "SELECT dir_firstname, dir_lastname, mov_title, mov_year \
    \FROM director \
    \INNER JOIN movie ON mov_dir = dir_id \
    \WHERE dir_firstname = ? AND dir_lastname = ?"
    director

En plus du type de retour, les types des paramètres de la requête sont également vérifiés, ce qui permet d’empêcher certaines attaques. Cependant, la structure même de la base de données (table, champs…) n’est vérifiée qu’à l’exécution.

Client type-safe (selda)

Selda est un EDSL permettant de manipuler des bases de données relationnelles. Il permet notamment de typer les tables et les requêtes, ce qui permet au compilateur de vérifier leur cohérence.

Requête basique

Avec selda, on définit des types pour représenter les tables, un peu à la manière des ORM en Programmation Orientée Objet.

Par exemple pour les réalisateurs, on écrit le type Director suivant, on instancie SqlRow pour pouvoir récupérer des lignes résultats de ce type, et on construit une valeur correspondant à la table “director” dans la base de données.

data Director = Director
    { dir_id :: ID Director
    , dir_firstname :: Text
    , dir_lastname :: Text
    } deriving (Generic, Show)

instance SqlRow Director

directoryTable :: Table Director
directoryTable = table "director" [#dir_id :- autoPrimary]

Pour interroger la base de données, on utilise l’EDSL, qui redéfinit les fonctionnalités de SQL :

selectDirectors :: SeldaT SQLite IO [Director]
selectDirectors = query $ select directoryTable

Enfin, on exécute la requête lors d’une connexion à la base de donnés :

main :: IO ()
main = withSQLite "movies.db" $ do

    liftIO $ putStrLn "\n**** selectDirectors ****"
    selectDirectors >>= liftIO . mapM_ print

Résultat :

**** selectDirectors ****
Director {dir_id = 1, dir_firstname = "Ted", dir_lastname = "Kotcheff"}
Director {dir_id = 2, dir_firstname = "Stanley", dir_lastname = "Kubrick"}

Typage

L’EDSL de selda permet de profiter du système de typage de Haskell. Par exemple, les clés des tables sont des types à part entière, si bien que le compilateur peut vérifier les clés étrangères.

data Movie = Movie
  { mov_id:: ID Movie
  , mov_dir :: ID Director
  , mov_title :: Text
  , mov_year :: Maybe Int
  } deriving (Generic, Show)

instance SqlRow Movie

movieTable :: Table Movie
movieTable = table "movie"
    [ #mov_id :- autoPrimary
    , #mov_dir :- foreignKey directoryTable #dir_id ]

Pour les requêtes plus compliquées comme les jointures, selda permet, via un “contexte de requête”, d’écrire des sélections/restrictions assez facilement. Par exemple, pour les productions d’un réalisateur donné :

selectProductions :: (Text, Text) -> SeldaT SQLite IO [Director :*: Movie]
selectProductions (firstname, lastname) = query $ do
    directors <- select directoryTable
    movies <- select movieTable
    restrict (movies ! #mov_dir .== directors ! #dir_id)
    restrict (directors ! #dir_firstname .== literal firstname)
    restrict (directors ! #dir_lastname .== literal lastname)
    return $ directors :*: movies

A noter que selda peut aussi générer la base de données à partir des types définis via l’EDSL, donc sans avoir à écrire du code SQL pour initialiser la base.

Conclusion

Haskell a des bibliothèques pour accéder à différents systèmes de bases de données (MySQL, Postgres, SQLite, etc). Ces bibliothèques peuvent être classées en deux catégories : basées sur du SQL ou basées sur un DSL.

Les bibliothèques basées sur du SQL utilisent des requêtes SQL classiques, que l’on peut paramétrer. Comme le type des paramètres est spécifié, les données de/vers la base sont vérifiées, ce qui empêche certaines erreurs et attaques. Cependant ces vérifications ne sont réalisées qu’à l’exécution. De même, la validité des requêtes par rapport à la structure de la base n’est vérifiée qu’à l’exécution.

Les bibliothèques basées sur un DSL permettent de définir toute la structure de la base ainsi que les requêtes, via du code Haskell. Ceci permet de faire la plupart des vérifications dès l’étape de compilation.

Voir aussi : Which type-safe database library should you use?