Précédent Index Suivant

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 : 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 : 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 :
# 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 :
  1. sélection, selon un critère donné, d'un ensemble des fiches ;
  2. traitement individuel de chacune des fiches sélectionnées ;
  3. 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 :
  1. (data_card -> bool) -> data_card list -> data_card list
  2. (data_card -> 'a) -> data_card list -> 'a list
  3. ('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>
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 :

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.


Précédent Index Suivant