Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Mon livre « Cyberstructure »

Ève

De l'intérêt des règles d'intégrité dans un SGBD

Première rédaction de cet article le 17 janvier 2008


Il semble que l'utilisation des règles d'intégrité dans les SGBD se fasse plus rare depuis quelques années. C'est dommage, car ces règles permettent un accès bien plus sécurisé à la base de données et documentent le schéma. Pourquoi mettre de telles règles et comment ?

Tous les SGBD (même MySQL, longtemps en retard, s'y est mis) permettent d'exprimer des contraintes d'intégrité, permettant d'exprimer les règles que les données de la base doivent respecter. Ces règles permettent d'augmenter la sécurité en limitant le risque que des commandes SQL boguées ne laissent la base dans un état incorrect. SQL offre plusieurs moyens (pas tous standards) pour décrire ces contraintes.

Si on utilise un SGBD, c'est probablement parce les données qu'on lui confie sont importantes. On ne souhaite donc pas qu'elles soient modifiées librement, elles doivent, à tout moment, respecter un certain nombre de règles, par exemple que le code postal soit présent dans une liste des codes postaux effectivement alloués, ou, au minimum, qu'il soit composé de cinq chiffres.

Il y a plusieurs endroits pour mettre de telles règles. Si un seul programme accède à la base en écriture, on peut mettre ces règles dans le programme (qu'il soit en Cobol, en Java ou en Perl). Mais c'est une contrainte ennuyeuse : si on veut développer un second programme, on doit remettre toutes les contraintes. Plus récemment, l'utilisation massive de middleware d'accès à la base de données a poussé certains développeurs à mettre toutes les règles d'intégrité dans ledit middleware : même si plusieurs programmes accèdent à la base, tous passent par le middleware et tous vont donc devoir respecter les règles.

Ce développement du middleware et l'influence de logiciels simples n'incluant pas toutes les fonctions d'un SGBD (comme MySQL) a fait reculer l'utilisation de contraintes d'intégrité dans le SGBD lui-même. C'est dommage. En effet, mettre le plus possible de contraintes dans le SGBD, en SQL (avec extensions), permet d'utiliser plusieurs programmes radicalement différents pour accéder à la base, sans crainte pour la cohérence des données, ce qui donne plus de souplesse au système d'information. En outre, cela permet d'utiliser toutes les possibilités de SQL. Si, par exemple, ce qui arrive parfois, on doit modifier un grand nombre des données de la base pour un changement ou un problème imprévu, écrire une requête SQL est plus facile que de modifier le middleware pour l'adapter à ce nouveau besoin, non prévu, et qui n'arrivera qu'une seule fois. La grande force de SQL est justement de pouvoir facilement exprimer des requêtes non envisagées à l'origine. Cette possibilité soulève parfois de l'inquiétude, mais les règles d'intégrité permettent de travailler, même en SQL, avec un bon filet de sécurité.

Naturellement, aucun filet de sécurité n'est parfait. Toutes les règles ne peuvent pas être exprimées dans la base (le SQL standard n'est pas un langage de Turing) et, donc, il faudra quand même mettre certaines règles dans un langage classique. Mais je trouve la sécurité des règles mises dans le SGBD très rassurante : d'une certaine façon, la base de données se défend toute seule contre les programmes écrits un peu trop hâtivement.

Comment programme t-on des règles d'intégrité ? Il existe de nombreuses méthodes. Commençons par les plus standard, qui font partie de SQL. Tous les exemples sont faits avec PostgreSQL et les liens pointent vers la documentation de PostgreSQL.

Si une donnée doit être présente une fois et une seule dans une table, le mot-clé UNIQUE permet de faire respecter cette règle. Par exemple, un registre de noms de domaine peut avoir une règle analogue, puisque les noms sont uniques.


essais=> CREATE TABLE Domaines (nom TEXT UNIQUE);
NOTICE:  CREATE TABLE / UNIQUE will create implicit index "domaines_nom_key" for table "domaines"
CREATE TABLE
essais=> INSERT INTO Domaines VALUES ('foobar.example');
INSERT 372690 1
essais=> INSERT INTO Domaines VALUES ('autre.example');
INSERT 372691 1
essais=> INSERT INTO Domaines VALUES ('foobar.example');
ERROR:  duplicate key violates unique constraint "domaines_nom_key"

Si une donnée doit appartenir à un ensemble bien défini de valeurs, le système de typage de SQL peut aider. Ainsi, on peut préciser qu'une donnée doit être une date (la syntaxe d'entrée est ISO 8601) :


essais=> CREATE TABLE Nouvelles (pub DATE);
CREATE TABLE
essais=> INSERT INTO Nouvelles VALUES ('2007-1-16');
INSERT 372695 1
essais=> INSERT INTO Nouvelles VALUES ('1879-8-8');
INSERT 372696 1
essais=> INSERT INTO Nouvelles VALUES ('2000-13-32');
ERROR:  date/time field value out of range: "2000-13-32"

On est ainsi protégé contre les erreurs de saisie ou de calcul.

PostgreSQL permet de créer ses propres types, ce qui permet d'obtenir ces contraintes de types (SQL dit de domaines, le terme types provenant plutôt de la programmation classique) avec des types quelconques. Outre les types standards de SQL comme l'entier ou la chaîne de caractères, PostgreSQL dispose d'une grande variété de types comme un type pour les adresses IP, IPv4 et IPv6. (Je cite cet exemple car j'ai vu beaucoup d'applications où un champ censé stocker une adresse IP était une simple chaîne de caractères, sans contrôle particulier ou, à peine mieux, un contrôle uniquement dans le client Web via un peu de Javascript, ce qui permet au client Web d'introduire facilement n'importe quelle valeur, juste en coupant Javascript.)

SQL permet également d'imposer qu'une référence à un tuple pointe réellement vers un tuple existent (contrainte référentielle).


essais=> CREATE TABLE Catalogue (ref VARCHAR(6) UNIQUE, description TEXT);
NOTICE:  CREATE TABLE / UNIQUE will create implicit index "catalogue_ref_key" for table "catalogue"
CREATE TABLE
essais=> INSERT INTO Catalogue VALUES ('456223', 'Lampe de chevet');
INSERT 372714 1
essais=> INSERT INTO Catalogue VALUES ('6778', 'Lampe de poche');
INSERT 372715 1
essais=> INSERT INTO Catalogue VALUES ('99115', 'Allumettes');
INSERT 372716 1

essais=> CREATE TABLE Commandes (quand DATE, quoi VARCHAR(6) REFERENCES Catalogue(ref));
CREATE TABLE
essais=> INSERT INTO Commandes VALUES ('2007-1-17', '6778');
INSERT 372723 1
essais=> INSERT INTO Commandes VALUES ('2007-1-17', '1');
ERROR:  insert or update on table "commandes" violates foreign key constraint "$1"
DETAIL:  Key (quoi)=(1) is not present in table "catalogue".

On note que ces contraintes ne s'appliquent pas qu'à la création (elles suivent l'esprit SQL, ce sont des déclarations, des assertions sur la base, pas des instructions à exécuter). Elles empêchent également de « scier la branche sur laquelle on est assis », par exemple en détruisant des données qu'on référence encore :


essais=> DELETE FROM Catalogue WHERE ref = '6778';
ERROR:  update or delete on "catalogue" violates foreign key constraint "$1" on "commandes"
DETAIL:  Key (ref)=(6778) is still referenced from table "commandes".

Si les contrôles à effectuer sont un peu plus compliquées, et qu'on ne souhaite pas forcément créer ses propres types, CHECK permet bien des choses :


essais=> CREATE TABLE Employes (salaire NUMERIC CHECK (salaire > 1280)); 
                                                           -- Le SMIC     
CREATE TABLE
essais=> INSERT INTO Employes VALUES (3150);
INSERT 372735 1
essais=> INSERT INTO Employes VALUES (1291);
INSERT 372736 1
essais=> INSERT INTO Employes VALUES (940);
ERROR:  new row for relation "employes" violates check constraint "employes_salaire"

Malgré les efforts de Parisot, le SGBD refusera des salaires inférieurs au SMIC.

Les tests faits avec CHECK peuvent être plus complexes, ici on va tester que l'adresse de courrier électronique est valable (attention : beaucoup de logiciels mettent en œuvre ce test n'importe comment.) On note aussi qu'un nom a été donné au test, pour améliorer les messages d'erreur :


essais=> CREATE TABLE Contacts (adresse TEXT NOT NULL
essais(>     CONSTRAINT Adresse_valide CHECK (adresse ~ '^.+@.+$'));
CREATE TABLE
essais=> INSERT INTO Contacts VALUES ('stephane+blog@bortzmeyer.org');
INSERT 372743 1
essais=> INSERT INTO Contacts VALUES ('postmaster@hotmail.com');
INSERT 372744 1
essais=> INSERT INTO Contacts VALUES ('ano nymous');
ERROR:  new row for relation "contacts" violates check constraint "adresse_valide"

On peut tester de manière analogue, toujours avec des expressions rationnelles, le numéro de téléphone :


essais=> CREATE TABLE Contacts (telephone TEXT NOT NULL                                      
essais(>      CONSTRAINT Numero_telephone CHECK (telephone ~ '^\\+[0-9]+[ 0-9]+$'));
CREATE TABLE
essais=> INSERT INTO Contacts VALUES ('+33 1 39 30 83 46');
INSERT 372751 1
essais=> INSERT INTO Contacts VALUES ('+1 123 456');
INSERT 372752 1
essais=> INSERT INTO Contacts VALUES ('22');
ERROR:  new row for relation "contacts" violates check constraint "numero_telephone"

Cette syntaxe (à la norme E.164) peut être jugée trop rigide pour les utilisateurs (par exemple elle oblige à entrer le code international, 33 pour la France, systématiquement). On peut autoriser des syntaxes relatives en entrée mais, à mon avis, pas dans la base, qui doit rester le plus homogène possible. Normaliser un numéro relatif comme 01 39 30 83 46) en numéro E.164 (+33 1 39 30 83 46), accepter des points ou des virgules, etc, doit plutôt se faire dans l'interface utilisateur.

Autre utilisation de CHECK : SQL ne dispose pas de types énumérés, comme beaucoup de langages de programmation CHECK est souvent un moyen utilisé pour les mettre en œuvre :


essais=> CREATE TABLE Personne (statut TEXT 
essais(>      CHECK (statut IN ('prospect', 'fournisseur', 'client', 'presse')));
CREATE TABLE
essais=> INSERT INTO Personne VALUES ('fournisseur');
INSERT 372759 1
essais=> INSERT INTO Personne VALUES ('client');
INSERT 372760 1
essais=> INSERT INTO Personne VALUES ('autre');
ERROR:  new row for relation "personne" violates check constraint "personne_statut"

Il existe des cas où la syntaxe simple de CHECK ou des contraintes référentielles ne suffit pas. Par exemple, un registre de noms de domaines veut faire respecter la syntaxe décrite dans les RFC 1034 et RFC 1123. Les expressions rationnelles comme ^[a-z0-9-\.]+$ ne suffisent pas car elles autorisent des noms illégaux comme pas--terrible..fr. Il faut alors écrire une fonction et s'assurer qu'elle soit appelée avant la création d'un nom. Même chose si la règle dépend de valeurs stockée dans la base de données, autres que la simple référence à une valeur existante. Par exemple, si on veut refuser l'enregistrement d'une commande si la trésorerie est à moins de 10 000 €, il faudra aussi utiliser une fonction.

Voici un exemple de fonction pour tester la syntaxe que le registre de noms de domaine accepte (elle est souvent plus restrictive que ce que le DNS accepte) :


-- Only a small part of the real checks!

CREATE OR REPLACE FUNCTION legal_name(TEXT) RETURNS BOOLEAN
  AS 'DECLARE 
            name_to_check TEXT;
            next_dot INTEGER;
            two_dots INTEGER;
      BEGIN
        SELECT INTO name_to_check lower($1);
        IF name_to_check !~ ''^[\.0-9a-zA-Z\-]+$'' THEN
           RETURN FALSE; -- This test could have been done in a simple CHECK
        END IF;
        two_dots := strpos(name_to_check, ''..'');
        IF two_dots != 0 THEN
           RETURN FALSE;
        END IF;
	-- Other tests can be inserted here
        RETURN TRUE;
      END;'
     LANGUAGE PLPGSQL;

Pour que cette fonction soit appelée avant la création du nom de domaine, on utilise les déclencheurs (triggers) :


CREATE OR REPLACE FUNCTION check_legal() RETURNS TRIGGER
    AS 'BEGIN
          IF NOT legal_name(NEW.name) THEN
            RAISE EXCEPTION ''Illegal syntax for a domain name'';
          END IF;
          RETURN NEW;
        END;'
    LANGUAGE PLPGSQL;

DROP TRIGGER check_legal ON Domains;
CREATE TRIGGER check_legal
  BEFORE INSERT OR UPDATE ON Domains
  FOR EACH ROW
  EXECUTE PROCEDURE check_legal();

Ainsi, toute tentative d'insérer (ou de modifier) un nom de domaine va appeler la function check_legal :


essais=> CREATE TABLE Domains (name TEXT UNIQUE NOT NULL);
NOTICE:  CREATE TABLE / UNIQUE will create implicit index "domains_name_key" for table "domains"
CREATE TABLE
essais=> \i mychecks.sql
CREATE FUNCTION
CREATE FUNCTION
CREATE TRIGGER
essais=> INSERT INTO Domains VALUES ('foobar.example');
INSERT 372800 1
essais=> INSERT INTO Domains VALUES ('foobar..example');
ERROR:  Illegal syntax for a domain name
essais=> INSERT INTO Domains VALUES ('tyuyty ghg');
ERROR:  Illegal syntax for a domain name

Pour le cas où on veut utiliser des données qui se trouvent dans la base, on définit fonction et déclencheur :


CREATE OR REPLACE FUNCTION enough_money(INTEGER) RETURNS BOOLEAN
  AS 'DECLARE
        amount_left INTEGER;
      BEGIN
         SELECT INTO amount_left amount FROM Cash LIMIT 1;
         RETURN (amount_left - $1) >= 10000;
      END;'
     LANGUAGE PLPGSQL;

CREATE OR REPLACE FUNCTION check_money() RETURNS TRIGGER
    AS 'BEGIN
          IF NOT enough_money(NEW.value) THEN
            RAISE EXCEPTION ''Not enough money for this order'';
          END IF;
          RETURN NEW;
        END;'
    LANGUAGE PLPGSQL;

DROP TRIGGER check_money ON Orders;
CREATE TRIGGER check_money
  BEFORE INSERT ON Orders
  FOR EACH ROW
  EXECUTE PROCEDURE check_money();

et on peut les utiliser :


essais=> CREATE TABLE Cash (amount INTEGER);
CREATE TABLE
essais=> CREATE TABLE Orders (value INTEGER);
CREATE TABLE
essais=> \i mychecks.sql
CREATE FUNCTION
CREATE FUNCTION
DROP TRIGGER
CREATE TRIGGER
essais=> SELECT amount FROM Cash;
 amount 
--------
  30000
(1 row)
essais=> INSERT INTO Orders VALUES (30000);
ERROR:  Not enough money for this order
essais=> INSERT INTO Orders VALUES (20000);
INSERT 372829 1

Tout ne peut pas s'exprimer avec ces règles, notamment si on a des règles « métier » très complexes. On doit alors passer aux langages de programmation classiques. Une autre limite, avec PostgreSQL, est que les déclencheurs sont par instruction SQL, pas par transaction (on ne peut pas définir de déclencheur ON COMMIT). Si la contrainte d'intégrité s'étend sur plusieurs tables, il n'y a pas de solution.

Une autre limite des règles d'intégrité dans la base est qu'on souhaite parfois mettre ces mêmes règles à d'autres endroits, par exemple dans un code Javascript exécuté sur le navigateur Web du client, afin de lui fournir un rapide retour s'il tape des données erronnées. Cela mène donc à une certaine duplication du code (dans la base et dans le code Javascript) et il faut donc bien veiller à ce que les deux codes demeurent synchrones.

Pour conclure, disons que les règles d'intégrité mises dans base elle-même sont un excellent outil (même avec leurs limites) et que leur utilisation épargnerait bien des ennuis à beaucoup de DBA.

Parmi les articles sur ce sujet, je recommande chaudement Constraint Yourself! qui donne en outre une bonne liste des techniques possibles.

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)