Requêtes sur une base de données
La réalisation d'une base de données, de son interface et de son
langage de requêtes est un projet trop ambitieux pour le cadre de ce
livre et pour les connaissances en Objective CAML du lecteur rendu à ce
point. Pourtant, en restreignant le problème et en profitant au
mieux des possibilités offertes par la programmation fonctionnelle,
nous allons pouvoir réaliser un outil intéressant pour le
traitement des requêtes. Nous verrons, en particulier, comment
utiliser les itérateurs ainsi que l'application partielle pour formuler et
exécuter des requêtes. Nous montrerons également l'utilisation
d'un type de données encapsulant des valeurs fonctionnelles.
Tout au long de cette application, nous utiliserons en guise d'exemple
une base de données rassemblant les informations sur
les adhérents d'une association. Elle est supposée être contenue
dans le fichier association.dat.
Format des données
La plupart des logiciels classiques de base de données utilisent un
format dit << propriétaire >> pour stocker les informations qu'ils
manipulent. Cependant, il est généralement possible de les sauver
sous un format texte auquel on peut donner la forme suivante :
-
la base est une suite de fiches séparées par des
retour-chariot ;
- chaque fiche est une suite de champs séparés par un
caractère quelconque que nous supposerons ici être le caractère
':' ;
- un champ est une chaîne de caractères qui ne
contient ni retour-chariot, ni le caractère
':' ;
- la première fiche est la liste des noms associés aux champs
séparés par
'|'.
Le fichier de l'association débute par :
Num|Nom|Prenom|Adresse|Tel|Email|Pref|Date|Montant
0:Chailloux:Emmanuel:Université P6:0144274427:ec@lip6.fr:mail:25.12.1998:100.00
1:Manoury:Pascal:Laboratoire PPS::pm@lip6.fr:adr:03.03.1997:150.00
2:Pagano:Bruno:Cristal:0139633963::adr:25.12.1998:150.00
3:Baro:Sylvain::0144274427:baro@pps.fr:mail:01.03.1999:50.00
La signification des champs est la suivante :
-
Num est le numéro d'adhérent ;
- Nom, Prenom, Adresse, Tel et Email
parlent d'eux-mêmes ;
- Pref indique comment l'adhérent désire recevoir les
informations : par courrier postal (adr), par courrier
électronique (mail) ou par téléphone (tel) ;
- Date et Montant sont respectivement la date et le
montant de la dernière cotisation perçue.
Il nous faut dès à présent choisir la représentation que le
programme adoptera pour stocker une base de données. Une
alternative s'offre : soit une liste de fiches, soit un tableau de
fiches. La liste présente l'avantage d'être aisément
modifiable : l'ajout et la suppression d'une fiche sont des
opérations simples. Le tableau, pour sa part, offre un accès en
temps constant à n'importe quelle fiche. Comme notre but est de
travailler sur l'ensemble des fiches et non pas sur certaines en
particulier, chaque requête devra traiter la totalité des
fiches. On peut donc choisir une structure de liste. La même
alternative se pose pour les fiches : listes ou tableaux de chaînes
de caractères ? Cette fois-ci, la réponse est inverse car, d'une
part le format d'une fiche est figé pour toute la base, il n'est
donc pas question de rajouter de nouveaux champs ; d'autre part, selon
les traitements envisagés, seuls certains champs seront utiles, il
est donc important d'y accéder rapidement.
La solution la plus naturelle pour une fiche serait d'avoir recours
à un tableau indexé par le nom des champs. Un tel type n'étant
pas possible en Objective CAML, nous allons le remplacer
par un tableau classique (indexé par des entiers) et une fonction
associant à un champ, l'indice du tableau lui correspondant.
# type data_card = string array ;;
# type data_base = { card_index : string -> int ; data : data_card list } ;;
L'accès au champ de nom n d'une fiche dc de la base
db est réalisé par la fonction :
# let field db n (dc:data_card) = dc.(db.card_index n) ;;
val field : data_base -> string -> data_card -> string = <fun>
Le type de dc a été forcé à data_card pour
contraindre la fonction field à n'accepter que des tableaux
de chaînes de caractères et non des tableaux quelconques.
Voici un petit exemple en guise d'illustration :
# let base_ex =
{ data = [ [|"Chailloux"; "Emmanuel"|] ; [|"Manoury";"Pascal"|] ] ;
card_index = function "Nom"->0 | "Prenom"->1 | _->raise Not_found } ;;
val base_ex : data_base =
{card_index=<fun>;
data=[[|"Chailloux"; "Emmanuel"|]; [|"Manoury"; "Pascal"|]]}
# List.map (field base_ex "Nom") base_ex.data ;;
- : string list = ["Chailloux"; "Manoury"]
L'expression field base_ex "Nom" s'évalue comme une
fonction qui prend une fiche et renvoie son champ
"Nom". Par l'utilisation de List.map, cette fonction
est exécutée sur chacune des fiches de la base base_ex
et on obtient la liste des résultats : soit la liste des champs
"Nom" de la base.
Cet exemple nous permet de voir comment nous souhaitons utiliser la
fonctionnalité dans notre programme. Ici, l'application partielle de
field nous permet de définir une fonction d'accès à un
champ précis, utilisable sur un nombre quelconque de fiches. Cela
nous permet aussi de voir que notre implantation de cette même
fonction field souffre d'un léger défaut car, alors que nous
accédons toujours au même champ, son indice est tout de même
recalculé à chaque fois. Pour cette raison, nous lui préférons
l'implantation suivante :
# let field base name =
let i = base.card_index name in fun (card:data_card) -> card.(i) ;;
val field : data_base -> string -> data_card -> string = <fun>
Ici, après application de deux arguments, l'indice du champ est
effectivement calculé et sert pour toutes les applications
ultérieures.
Lecture d'une base depuis un fichier
Depuis Objective CAML, un fichier contenant une base est une suite de lignes.
Notre travail va consister à lire chacune d'entre elles comme une chaîne
de caractères puis à la découper en repérant les séparateurs et à construire d'une part
les données et d'autre part la fonction d'indexation des champs.
Utilitaires de traitement de ligne
Nous nous dotons d'une fonction split qui découpe une
chaîne de caractères selon un caractère séparateur. Cette
fonction utilise la fonction suffix retournant le suffixe
d'une chaîne s à partir d'une position i. On
utilise pour cela trois fonctions prédéfinies :
-
String.length donne la longueur d'une chaîne;
- String.sub retourne la sous-chaîne de s à partir de la position i de longueur l;
- String.index_from calcule dans la chaîne s, à partir de la
position n, la position de la première occurrence du caractère c.
# let suffix s i = try String.sub s i ((String.length s)-i)
with Invalid_argument("String.sub") -> "" ;;
val suffix : string -> int -> string = <fun>
# let split c s =
let rec split_from n =
try let p = String.index_from s n c
in (String.sub s n (p-n)) :: (split_from (p+1))
with Not_found -> [ suffix s n ]
in if s="" then [] else split_from 0 ;;
val split : char -> string -> string list = <fun>
La seule chose à noter dans l'implantation de ces fonctions est la
gestion des exceptions, en particulier, l'exception
Not_found.
Calcul de la structure data_base
Obtenir un tableau de chaînes à partir d'une liste ne pose pas de
problème : le module Array fournit la fonction nécessaire
(of_list). Le calcul de la fonction d'indice à partir d'une
liste de noms de champs pourrait paraître plus compliqué mais le
module List fournit tous les outils dont nous avons besoin.
Nous partons d'une liste de chaînes et nous devons obtenir une
fonction qui à une chaîne associe un indice qui correspond à sa place
dans la liste.
# let mk_index list_names =
let rec make_enum a b = if a > b then [] else a::(make_enum (a+1) b) in
let list_index = (make_enum 0 ((List.length list_names) - 1)) in
let assoc_index_name = List.combine list_names list_index in
function name -> List.assoc name assoc_index_name ;;
val mk_index : 'a list -> 'a -> int = <fun>
Pour obtenir la fonction d'association entre noms de champ et indices,
nous construisons la liste des indices que nous combinons avec la
liste de noms. Nous obtenons une liste d'association de type
string * int list. Pour chercher l'indice associé à un nom nous
avons recours à la fonction assoc de la bibliothèque
List qui est dédiée à cette tâche. Le résultat de
make_index est une fonction qui prend un nom et appelle
assoc avec ce nom sur la liste d'association précédemment
construite.
Nous pouvons maintenant définir la fonction de lecture des fichiers
contenant une base au format imposé.
# let read_base name_file =
let channel = open_in name_file in
let split_line = split ':' in
let list_names = split '|' (input_line channel) in
let rec read_file () =
try
let data = Array.of_list (split_line (input_line channel )) in
data :: (read_file ())
with End_of_file -> close_in channel ; []
in
{ card_index = mk_index list_names ; data = read_file () } ;;
val read_base : string -> data_base = <fun>
La lecture des enregistrements du fichier est réalisée par la fonction
auxiliaire read_file qui opère récursivement sur le canal
d'entrée. Le cas de base de la récurrence correspond à la fin du
fichier signalée par l'exception End_of_file. Notons que
nous profitons de ce cas retournant la liste vide pour refermer le
canal.
Nous pouvons maintenant charger le fichier de notre association :
# let base_ex = read_base "association.dat" ;;
val base_ex : data_base =
{card_index=<fun>;
data=
[[|"0"; "Chailloux"; "Emmanuel"; "Universit\233 P6"; "0144274427";
"ec@lip6.fr"; "mail"; "25.12.1998"; "100.00"|];
[|"1"; "Manoury"; "Pascal"; "Laboratoire PPS"; ...|]; ...]}
Principes généraux des traitements
La richesse et la complexité du traitement de l'ensemble des
données d'une base sont proportionnelles à la richesse et à la
complexité du langage de requêtes utilisé. Puisqu'ici, notre
volonté est d'utiliser Objective CAML comme langage de requêtes, il n'y
a a priori pas de limite à l'expression des requêtes !
Cependant, nous souhaitons également fournir quelques outils simples
de manipulation des fiches et de leurs données. Pour obtenir cette
simplicité, il nous faut nécessairement limiter la trop grande
richesse du langage Objective CAML en posant quelques objectifs et principes
généraux de traitement.
L'objectif d'un traitement de données est d'obtenir ce que l'on
appelle un état de la base. On peut décomposer la
construction d'un état en trois étapes :
-
sélection, selon un critère donné, d'un ensemble des fiches ;
- traitement individuel de chacune des fiches sélectionnées ;
- traitement de l'ensemble des données collectées sur chacune
des fiches.
Cette décomposition est illustrée à la figure
6.1.
Figure 6.1 : décomposition d'une requête
Suivant cette décomposition, nous avons besoin de trois fonctions
possédant les types suivants :
-
(data_card -> bool) -> data_card list -> data_card list
- (data_card -> 'a) -> data_card list -> 'a list
- ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b
Objective CAML nous fournit trois fonctions d'ordre supérieur, ou
itérateurs, présentés à la page ??, qui répondent à
notre spécification :
# List.find_all ;;
- : ('a -> bool) -> 'a list -> 'a list = <fun>
# List.map ;;
- : ('a -> 'b) -> 'a list -> 'b list = <fun>
# List.fold_right ;;
- : ('a -> 'b -> 'b) -> 'a list -> 'b -> 'b = <fun>
Nous pourrons les utiliser pour réaliser les trois étapes de
construction d'un état lorsque nous aurons fixé la valeur de leur
argument fonctionnel.
Pour certaines requêtes particulières, on
utilisera également :
# List.iter ;;
- : ('a -> unit) -> 'a list -> unit = <fun>
En effet, si le traitement d'un ensemble d'informations se limite à
leur affichage, il n'y a pas réellement de valeur à calculer.
Nous allons, dans les paragraphes suivants, voir comment définir des
fonctions exprimant les critères de sélection ainsi que quelques
traitements simples. Nous conclurons ce chapitre par un petit exemple
mettant en oeuvre ces fonctions selon le principe énoncé
ci-dessus.
Critères de sélection
Dans la pratique, la fonction booléenne qui exprime le critère de
sélection d'une fiche est obtenue par combinaison booléenne de
propriétés données sur tout ou partie des champs de la fiche.
Chaque champ d'une fiche, quoique donné comme une chaîne de
caractères, peut être porteur d'une information d'un autre type :
un flottant, une date, etc.
Critères de sélection sur un champ
La composante d'un critère de sélection concernant un champ
particulier sera, en général, obtenue à partir d'une fonction de type
data_base -> 'a -> string -> data_card -> bool.
Le paramètre de type 'a correspond au type de l'information
contenue dans le champ. L'argument de type string correspond
au nom du champ.
Informations de type chaînes de caractères
Nous définissons deux tests simples sur ces types de champ :
l'égalité avec une autre chaîne et la non vacuité.
# let eq_sfield db s n dc = (s = (field db n dc)) ;;
val eq_sfield : data_base -> string -> string -> data_card -> bool = <fun>
# let nonempty_sfield db n dc = ("" <> (field db n dc)) ;;
val nonempty_sfield : data_base -> string -> data_card -> bool = <fun>
Informations de type flottant
Pour réaliser des tests sur des informations de type flottant, il
suffit simplement d'opérer la conversion d'une valeur de type
string représentant un nombre décimal en une valeur de
type float. Voici quelques exemples obtenus à partir d'une
fonction générique
tst_ffield :
# let tst_ffield r db v n dc = r v (float_of_string (field db n dc)) ;;
val tst_ffield :
('a -> float -> 'b) -> data_base -> 'a -> string -> data_card -> 'b = <fun>
# let eq_ffield = tst_ffield (=) ;;
# let lt_ffield = tst_ffield (<) ;;
# let le_ffield = tst_ffield (<=) ;;
(* etc. *)
Ces trois fonctions sont de type :
data_base -> float -> string -> data_card -> bool.
Les dates
Ce type d'information est un peu plus complexe à traiter. Il
dépend de la représentation de la date dans la base et réclame
de définir un codage des comparaisons de dates.
Nous fixons qu'une date est représentée dans une fiche par une
chaîne de caractères au format jj.mm.aaaa. Pour les besoins
des comparaisons que nous souhaitons obtenir,
nous enrichissons ce
format en autorisant le remplacement du jour, du mois ou de l'année
par le caractère souligné ('_').
Les dates sont comparées selon l'ordre lexicographique sur des
triplets d'entiers de la forme (année, mois, jour). Pour
pouvoir exprimer des requêtes telles : << est antérieure à
juillet 1998 >>, on utilisera le motif de date :
"_.07.1998". La comparaison d'une
date avec un motif sera réalisée par la fonction
tst_dfield qui analysera le motif pour en extraire la
fonction de comparaison ad hoc. La définition de cette fonction
générique de test sur les dates nécessite un certain nombre de
fonctions auxiliaires.
On se donne deux fonctions de conversion de dates
(ints_of_string) et de motifs de dates
(ints_of_dpat) en triplets d'entiers. Le caractère
'_' d'un motif sera remplacé par l'entier 0 :
# let split_date = split '.' ;;
val split_date : string -> string list = <fun>
# let ints_of_string d =
try match split_date d with
[j;m;a] -> [int_of_string a; int_of_string m; int_of_string j]
| _ -> failwith "Bad date format"
with Failure("int_of_string") -> failwith "Bad date format" ;;
val ints_of_string : string -> int list = <fun>
# let ints_of_dpat d =
let int_of_stringpat = function "_" -> 0 | s -> int_of_string s
in try match split_date d with
[j;m;a] -> [ int_of_stringpat a; int_of_stringpat m;
int_of_stringpat j ]
| _ -> failwith "Bad date format"
with Failure("int_of_string") -> failwith "Bad date pattern" ;;
val ints_of_dpat : string -> int list = <fun>
Étant donnée une relation r sur les entiers, on écrit la
fonction d'application du test. C'est un codage de l'ordre
lexicographique prenant en compte la valeur particulière 0
que l'on ignore :
# let rec app_dtst r d1 d2 = match d1, d2 with
[] , [] -> false
| (0::d1) , (_::d2) -> app_dtst r d1 d2
| (n1::d1) , (n2::d2) -> (r n1 n2) || ((n1 = n2) && (app_dtst r d1 d2))
| _, _ -> failwith "Bad date pattern or format" ;;
val app_dtst : (int -> int -> bool) -> int list -> int list -> bool = <fun>
On définit enfin la fonction générique tst_dfield
qui prend comme arguments une relation r, une base de données db,
un motif dp, un nom de champ nm et une fiche dc. Cette
fonction vérifie que le motif et le champ extrait de la fiche satisfont
la relation.
# let tst_dfield r db dp nm dc =
r (ints_of_dpat dp) (ints_of_string (field db nm dc)) ;;
val tst_dfield :
(int list -> int list -> 'a) ->
data_base -> string -> string -> data_card -> 'a = <fun>
On l'applique alors à trois relations.
# let eq_dfield = tst_dfield (=) ;;
# let le_dfield = tst_dfield (<=) ;;
# let ge_dfield = tst_dfield (>=) ;;
Ces trois fonctions sont de type :
data_base -> string -> string -> data_card -> bool.
Composition de critères
Les tests que nous avons définis ci-dessus ont pour
trois premiers arguments une base de données, une valeur et le nom
d'un champ. Lorsque nous formulons des requêtes particulières,
les valeurs de ces trois arguments sont connues. Lorsque l'on
travaille sur la base base_ex, le test << est antérieure
à juillet 1998 >> s'écrit
# ge_dfield base_ex "_.07.1998" "Date" ;;
- : data_card -> bool = <fun>
En pratique, un test est donc une fonction de type
data_card -> bool. Nous voulons obtenir des combinaisons
booléennes des résultats de telles fonctions appliquées à une
même fiche. Nous nous donnons à cette fin l'itérateur ;
# let fold_funs b c fs dc =
List.fold_right (fun f -> fun r -> c (f dc) r) fs b ;;
val fold_funs : 'a -> ('b -> 'a -> 'a) -> ('c -> 'b) list -> 'c -> 'a = <fun>
Où b est la valeur de base, la fonction c sera le
connecteur booléen, fs la liste des fonctions de test sur
un champ et dc une fiche.
On obtient la conjonction et la
disjonction d'une liste de tests par :
# let and_fold fs = fold_funs true (&) fs ;;
val and_fold : ('a -> bool) list -> 'a -> bool = <fun>
# let or_fold fs = fold_funs false (or) fs ;;
val or_fold : ('a -> bool) list -> 'a -> bool = <fun>
Par commodité, on définit également la négation d'un test :
# let not_fun f dc = not (f dc) ;;
val not_fun : ('a -> bool) -> 'a -> bool = <fun>
On peut, par exemple, utiliser ces combinateurs pour définir une
fonction de sélection d'une fiche dont le champ date est compris
dans un intervalle donné :
# let date_interval db d1 d2 =
and_fold [(le_dfield db d1 "Date"); (ge_dfield db d2 "Date")] ;;
val date_interval : data_base -> string -> string -> data_card -> bool =
<fun>
Traitements et calculs
Il est difficile de prévoir ce que peut être a priori le
traitement d'une fiche ou de l'ensemble des données issues de ce
traitement. Néanmoins, on peut considérer deux grandes classes de
tels traitements : un calcul numérique ou le formatage de données
pour impression. Prenons un exemple de chacun des cas envisagés.
Formatage
On désire préparer pour l'impression une ligne contenant
l'identité d'un adhérent suivie d'un certain nombre
d'informations.
On commence par se donner l'opération inverse du découpage d'une
chaîne selon un caractère donné :
# let format_list c =
let s = String.make 1 c in
List.fold_left (fun x y -> if x="" then y else x^s^y) "" ;;
val format_list : char -> string list -> string = <fun>
Afin de construire la liste des champs d'information, on se donne la
fonction extract qui extrait d'une fiche un ensemble de
champs définis par une liste de noms :
# let extract db ns dc =
List.map (fun n -> field db n dc) ns ;;
val extract : data_base -> string list -> data_card -> string list = <fun>
On écrit enfin la fonction de formatage d'une ligne :
# let format_line db ns dc =
(String.uppercase (field db "Nom" dc))
^" "^(field db "Prenom" dc)
^"\t"^(format_list '\t' (extract db ns dc))
^"\n" ;;
val format_line : data_base -> string list -> data_card -> string = <fun>
L'argument ns est la liste des champs d'information
désirés. Les champs d'information sont séparés par le
caractère de tabulation ('\t') et la ligne se termine par un
retour-chariot.
On obtient l'affichage de la liste des noms et prénoms des
adhérents par :
# List.iter print_string (List.map (format_line base_ex []) base_ex.data) ;;
CHAILLOUX Emmanuel
MANOURY Pascal
PAGANO Bruno
BARO Sylvain
- : unit = ()
Calcul numérique
On veut calculer la somme des cotisations perçues pour un ensemble
de fiches. On obtient facilement ce chiffre en composant l'extraction
et la conversion du champ correspondant avec une fonction
d'addition. Pour alléger l'écriture, on définit un opérateur
infixe de composition :
# let (++) f g x = g (f x) ;;
val ++ : ('a -> 'b) -> ('b -> 'c) -> 'a -> 'c = <fun>
On utilise cet opérateur de composition dans la définition suivante :
# let total db dcs =
List.fold_right ((field db "Montant") ++ float_of_string ++ (+.)) dcs 0.0 ;;
val total : data_base -> data_card list -> float = <fun>
On peut l'appliquer à la totalité de la base :
# total base_ex base_ex.data ;;
- : float = 450
Un exemple
Nous donnons, pour conclure ce chapitre un petit exemple d'application
des principes exposés dans les paragraphes précédents.
Nous envisageons deux types de requêtes sur notre base :
-
l'édition de deux listes contenant chacune l'identité de
l'adhérent, puis, selon ses préférences, son adresse postale ou
son adresse électronique.
- l'édition d'un état des cotisations entre deux dates
données. Cet état contiendra la liste des nom, prénom, date et
montant des cotisations ainsi que le total de ces dernières.
Listes d'adresses
Pour obtenir ces listes, on sélectionne d'abord les fiches
pertinentes selon la valeur du champ
"Pref" puis on utilise la fonction de formatage
format_line :
# let adresses_postales db =
let dcs = List.find_all (eq_sfield db "adr" "Pref") db.data in
List.map (format_line db ["Adresse"]) dcs ;;
val adresses_postales : data_base -> string list = <fun>
# let adresses_electroniques db =
let dcs = List.find_all (eq_sfield db "mail" "Pref") db.data in
List.map (format_line db ["Email"]) dcs ;;
val adresses_electroniques : data_base -> string list = <fun>
État des cotisations
Le calcul de l'état des cotisations procède toujours selon le
même esprit : sélection puis traitement. Mais le traitement est
dédoublé en formatage des lignes puis calcul du total.
# let etat_cotisations db d1 d2 =
let dcs = List.find_all (date_interval db d1 d2) db.data in
let ls = List.map (format_line db ["Date";"Montant"]) dcs in
let t = total db dcs in
ls, t ;;
val etat_cotisations : data_base -> string -> string -> string list * float =
<fun>
Le résultat de cette requête est un couple contenant la liste des
chaînes d'information et le montant cumulé des cotisations.
Programme principal
Le programme principal de
l'application est essentiellement une boucle d'interaction avec
l'utilisateur qui affiche le résultat de la requête demandée
par menu. On y retrouve naturellement un style de programmation
impératif, même si l'affichage des résultats fait appel à un
itérateur.
# let main() =
let db = read_base "association.dat" in
let fin = ref false in
while not !fin do
print_string" 1: liste des adresses postales\n";
print_string" 2: liste des adresses électroniques\n";
print_string" 3: cotisations\n";
print_string" 0 sortie\n";
print_string"Votre choix : ";
match read_int() with
0 -> fin := true
| 1 -> (List.iter print_string (adresses_postales db))
| 2 -> (List.iter print_string (adresses_electroniques db))
| 3
-> (let d1 = print_string"Date de début : "; read_line() in
let d2 = print_string"Date de fin : "; read_line() in
let ls, t = etat_cotisations db d1 d2 in
List.iter print_string ls;
print_string"Total : "; print_float t; print_newline())
| _ -> ()
done;
print_string"bye\n" ;;
val main : unit -> unit = <fun>
Cet exemple sera repris au chapitre 21
pour le munir d'une interface utilisant un navigateur WEB.
Pour en faire plus
Une extension naturelle de notre exemple serait de munir la base d'une
indication de type associée à chaque champ de la base. Cette
information pourrait alors être exploitée pour définir des
opérateurs de comparaison généraux de type
data_base -> 'a -> string -> data_card -> bool où le nom du
champ (troisième argument) permettrait d'aiguiller sur les bonnes
fonctions de conversion et de test.