Une base de données simple

 

Traduit à partir de http://mini.net/tcl/1598.html

Sur http://mini.net/tcl/496.html on peut trouver un grand nombre de bases de données compliquées. Je veux montrer ici comment implémenter simplement une base de données dans l'esprit de Tcl, et jusqu'où cette approche nous mène. Considérons le modèle suivant : Une base de données est un ensemble d'enregistrements. Un enregistrement est un ensemble non-vide de champs avec un ID unique. Un champ est une paire tag et valeur non-vide, tous deux étant des chaînes. Les champs seront bien implémentés comme des entrées de tableau, donc nous pouvons avoir un tableau par enregistrement, ou mieux un tableau pour toute la base de données, où la clé est composée de l'ID et du tag. Les IDs uniques peuvent l'être avec un simple compte (en incrémentant l'ID). Le processus de création d'une base de données simple consiste seulement dans le choix d'une valeur initiale pour l'ID :

  set db(lastid) 0

Prenons pour exemple une application de bibliothèque. Ajouter un livre à la base de données pourrait être fait comme ceci :

  set id [incr db(lastid)]
  set db($id,auteur) "Shakespeare, William"
  set db($id,titre) "The Tempest"
  set db($id,imprime) 1962
  set db($id,numero) S321-001

Notez que, comme nous n'avons pas spécifié quels champs un enregistrement contiendra, nous pouvons ajouter ce que nous voulons. Pour une gestion plus facile, il est sensé classer les enregistrements (nous voudrons stocker plus que des livres), donc ajoutons :

  set db($id,categorie) livre

Retrouver un enregistrement est aussi facile que ça (bien que les champs se présentent dans un ordre indéfini) :

  array get db $id,*

et effacer un enregistrement est à peine plus compliqué :

  foreach i [array names db $id,*] {unset db($i)}

ou, plus facile et rapide à partir de Tcl 8.3 :

  array unset db $id,*

Voici comment faire une "colonne", de tous les champs d'un tag donné :

  array get db *,titre

Mais les vraies colonnes ont des champs vides, que nous ne voudrons pas stocker. Trouver les champs qui n'existent pas physiquement nécessite une fonction :

  proc db'get {_db id champ} {
     upvar $_db db
     if {[array names db $id,$champ]=="$id,$champ"} {
         return $db($id,$champ)
     } else {return ""}
  }

Dans une base de données classique, nous devons définir des tables : leurs champs ainsi que leur type et leur taille. Ici nous faisons ce que nous voulons, même retrouver les champs que nous avons utilisé (avec un tableau temporaire pour tracer les noms de champs) :

  proc db'champs {_db} {
    upvar $_db db
    foreach i [array names db *,*] {
       set tmp([lindex [split $i ,] 1]) ""
    }
    lsort [array names tmp]
  }

Rechercher les enregistrements satisfaisant une condition précise pourra être réalisé séquentiellement. Par exemple, nous recherchons tous les livres imprimés avant 1980 :

  foreach i [array names *,imprime] {
     if {$db($i)<1980} {
         set id [lindex [split $i ,] 0]
         puts "[db'get db $id auteur]: [db'get db $id titre] $db($i)"
     }
  }

Nous pourrions également enregistrer nos clients dans la même base de données (ici dans un style différent) :

  set i [incr $db(lastid)]
  array set db [list $i,name "John F. Smith" $i,tel (123)456-7890 $i,categorie client}

Sans utiliser un concept de "tables", nous pouvons introduire des structures comme dans une base de données relationnelle. Supposons que John Smith emprunte The Tempest. Nous avons les IDs de client et de livre dans des variables et nous tenons une comptabilité double :

  lappend db($client,emprunté) $book ;# il peut avoir emprunté d'autres livres
  set db($livre,emprunteur) $client
  set db($livre,date_de_retour) 2001-06-12

Quand il ramène le livre, le processus est inversé :

  set pos [lsearch $db($client,emprunté) $livre]
  set db($client,emprunté) [lreplace $db($client,emprunté) $pos $pos]
  unset db($livre,emprunteur) ;# les champs vides ne nous intéressent pas
  unset db($livre,date_de_retour)

Le champ date_de_retour (le format %Y-%M-%d est pratique pour les tris et les comparaisons) est utile pour vérifier si les livres n'ont pas été ramenés à l'heure :

  set today [clock format [clock seconds] -format %Y-%M-%d]]
  foreach i [array names db *,date_de_retour] {
     if {$db($i)<$today} {
         set livre [lindex [split $i ,] 0] ;# ou: set livre[idof $i] - see below
         set client $db($livre,emprunteur)
         #rédaction d'une lettre-type
         puts "Cher $db($client,nom), "
         puts "veuillez nous retourner $db($livre,title) qui devait revenir le\
         $db($book,date_de_retour)"
     }
  }

De la même façon, les parties comptables (bons de commande, et factures des libraires) seront ajoutées avec peu d'effort, et également reliées à des fichiers externes (en donnant un nom au fichier).


Index :

Comme montré, nous pouvons retrouver les données par une recherche séquentielle sur array names. Mais si la base de données croît, c'est une bonne idée de créer des index qui mettent en place des références croisées entre les tags, les valeurs et les IDs. Par exemple, voici comment créer un index des auteurs en quatre lignes :

  foreach i [array names db *,author] {
     set book [lindex [split $i ,] 0]
     lappend db(author=[string toupper $db($i)]) $book
  } ;# et puis..
  foreach i [lsort [array names db author=SHAK*]] {
     puts "[lindex [split $i =] 1]:" ;# could be wrapped as 'valueof'
     foreach id $db($i) {
         puts "[db'get db $id title] - [db'get db $id label]"
     }
  }

nous donne une liste de livres de tous les auteurs satisfaisant le modèle "glob" (nous réutilisont les fonctionnalités Tcl, au lieu de les réinventer...). Les index sont utiles pour des informations répétées qui sont souvent recherchées. Particulièrement, l'indexation du champ categorie permet l'itération sur les "tables" (que nous ne possédons explicitement pas ;-) :

  regsub -all categorie= [array names db categorie=*] "" tables
  foreach client$db(categorie=client) {...}

Et au-dela du standard SQL, nous pouvons chercher plusieurs indices dans une requête :

  array names db *=*MARK*

vous donne (indépendamment de la casse) toute les occurrences de MARK, qu'il soit dans les noms des clients, ou dans les auteurs ou les titres des livres. Aussi versatile que le bon vieux grep...


Persistance :

Les bases de données sont supposées exister entre les sessions, donc voici comment sauvegarder une base de données dans un fichier :

  set fp [open Library.db w]
  puts $fp [list array set db [array get db]]
  close $fp

et charger cette base de données est encore plus facile (en rechargeant, il vaut mieux effacer le tableau avant) :

  source Library.db

Si vous utilisez des caractères en dehors de votre encodage système (pas de problème pour écrire des titres de livres Japonais en Kanji), vous devrez utiliser fconfigure (ex. -encoding utf-8) au chargement et à l'enregistrement. Sauvegarder est une bonne manière de procéder à ce qui est cérémonieusement appelé "committing" (vous aurez besoin de poser des verrous en écriture sur les systèmes multi-utilisateurs), alors que charger (sans avoir sauvegardé avant) pourrait être appelé un "one-level rollback", où vous voudrez annuler les derniers changements.

Notez que nous n'avons défini qu'une courte proc, toutes les autres opérations ont été réalisées avec des commandes internes Tcl. Dans un but de clarté, il est conseillé de factoriser les opérations fréquentes en procs, ex.

  proc idof {index} {lindex [split $index ,] 0}
  proc db'add {_db data} {
     upvar $_db db
     set id [incr db(lastid)]
     foreach {tag valeur} $data {set db($id,$tag) $valeur}
     #il faut aussi mettre les index à jour
  }
  proc db'tablerow {_db id tags} {
     upvar $_db db
     set res {}
     foreach tag $tags {lappend res [db'get db $id $tag]}
     set res
  }

Bien sur, avec la croissance de la base de données nous risquons d'atteindre les limites de la mémoire : les tableaux ont besoin d'espace supplémentaire pour l'administration. D'un autre coté, cette approche est vraiment économique, car elle n'utilise pas de taille de champs (toutes les chaînes sont "shrink-wrapped"), et omet les champs vides, bien qu'elle permet en même temps d'ajouter tous les champs que vous souhaitez. Une optimisation supplémentaire pourrait être de pointer la valeur des chaînes, et de remplacer les plus fréquentes avec "@$id", où db(@$id) conserve la valeur, et seule db'get devrait être adaptée pour rediriger la requête.

D'autre part, les limites de la mémoire sur les machines récentes sont plutôt élevées... Donc vous devrez seulement dans un futur éloigné (et peut être vous ne voudrez pas) changer pour une base de données compliquée ;-)


Au sujet des limites :

Les tableaux Tcl peuvent devenir assez gros (on m'a rapporté l'existence d'une application de 800000 clés en caractères Grecs), et à un certain point l'énumération de toutes les clés avec array names db (qui produit une longue liste) pourrait dépasser la mémoire disponible impliquant l'usage du swap. Dans cette situation, vous pouvez vous retourner vers l'usage (plus lent, et plus laid) d'un itérateur dédié :

  set search [array startsearch db]
  while {[array anymore db $search]} {
     set key [array nextelement db $search]
     # faire quelque chose avec db($key) - mais lisez ci-dessous !
  }
  array donesearch db $search

Mais vous ne pourrez ni filtrer la clé obtenue avec un modèle glob, ni ajouter ou effacer des éléments de tableau dans la boucle - la recherche sera immédiatement stoppée.

Une autre approche relativement courte pour une base de données avec un grand nombre d'enregistrements est de stocker tous les champs, comme une liste de paires, en un élément :

  array set db($id) {fnm Mary lnm Smith tel (123)234-4567 isa patron}

Pour traiter un enregistrement, vous utilisez juste un tableau temporaire :

  array set tmp $db($id)
  # faites vos modifications...
  set db($id) [array get tmp]

Et l'indexation dans cette approche se présenterait comme ceci :

  foreach {tag valeur} $db($id) {lappend db($tag=[string touppar $value]) $id}