Introduction aux bases de données relationnelles et au SQL

Avril 2023

Charles Martin

Qu’est-ce qu’une base de données ?

Au plus simple, une base de données est une collection de tableaux de données, que l’on nomme dans ce jargon des tables. Chacun de ces tableaux de données comprend plusieurs colonnes (ou variables) que l’on nomme champ dans le vocabulaire spécifique aux bases de données.

Contrairement aux tableaux de données qui existent dans la mémoire de l’ordinateur, les tables sont stockées sur le disque dur. Elles peuvent donc être de beaucoup plus grande taille.

Aussi, contrairement aux tableaux des données, les tables des bases de données sont presque toujours indexées. C’est-à-dire qu’un travail est fait en amont, à mesure que les données sont ajoutées/modifiées/supprimées, pour accélérer les filtres et les tris au moment venu. Remarquez que cette caractéristique n’est pas unique aux bases de données. Par exemple, la librairie data.table peut elle aussi indexer ses données.

Structure et normalisation des données

Contrairement aux tableaux de données qui sont un produit figé, généré à la fin de la collection de données, les bases de données sont prévues pour être dynamiques. Elles comprennent toute une infrastructure pour permettre d’insérer de nouvelles données, de les mettre à jour, de les supprimer, etc., de façon sécuritaire.

Pour que ces modifications se déroulent bien, la façon de structurer nos données revêt une importance particulière, encore plus critique que dans un tableau de données. Tout un champ d’étude avec sa terminologie propre y est d’ailleurs consacré : la normalisation de base de données.

Nous ne pouvons évidemment que survoler ces techniques, mais il est important d’en comprendre quand même le grand principe, qui est de ne jamais dédoubler d’information.

Par exemple, ce tableau de données est parfait pour R :

parcelle site richesse_parcelle ph_site
A X 3 7.5
B X 2 7.5
C X 5 7.5
D Y 1 6.9
E Y 0 6.9
F Y 3 6.9

Mais il ne serait pas approprié dans une base de données relationnelle. Il devrait être remplacé par les tables suivantes :

sites

id nom ph
1 X 7.5
2 Y 6.9

parcelles

id site_id nom richesse
1 1 A 3
2 1 B 2
3 1 C 5
4 2 D 1
5 2 E 0
6 2 F 3

Remarquez dans chaque table la présence d’une colonne id, qui devient l’identifiant unique de chacune des observations. On appelle cet identifiant la clé primaire de la table.

Le lien entre les deux tables est quant à lui spécifié par la colonne site_id, que l’on nomme dans ce jargon la clé étrangère.

La relation ici est donc une relation 1:N, une relation un à plusieurs. Un même site peut avoir plusieurs parcelles, mais chaque parcelle n’appartient qu’à un seul site.

Il existe plusieurs autres types de relations, par exemple les relations N:N, plusieurs à plusieurs. Ce type de relation serait tout à fait approprié pour noter la liste des espèces présentes dans chaque parcelle :

especes

id nom
1 perchaude
2 meunier

presences

parcelle_id espece_id
1 1
1 3

Remarquez que pour définir une relation N:N, il faut se créer une table intermédiaire, qui ne contient que des identifiants associant les deux tables. C’est le travail ici de la table presences, qui relie la table especes et la table parcelles dans une relation N:N.

Survol des logiciels disponibles

Il existe des dizaines, voir des centaines de logiciels de base de données relationnelles différents. Ils peuvent en général être divisés en trois catégories d’usage :

Pour cet atelier, nous nous contenterons d’utiliser une base de données embarquée, soit DuckDB. L’avantage de cette base de données pour des situations d’apprentissage est qu’elle s’efface et se recrée de nouveau à chaque démarrage de R. Elle est aussi extrêmement performante, pouvant gérer des requêtes immenses, beaucoup plus rapidement que son concurrent SQLite.

Cependant, presque tout de ce que nous verrons pourrait être transféré à un autre logiciel sans problème.

Préparation de notre environnement de travail DuckDB

library(tidyverse)
── Attaching packages ─────────────────────────────────────── tidyverse 1.3.2 ──
✔ ggplot2 3.4.0      ✔ purrr   1.0.1 
✔ tibble  3.1.8      ✔ dplyr   1.0.10
✔ tidyr   1.3.0      ✔ stringr 1.5.0 
✔ readr   2.1.2      ✔ forcats 0.5.2 
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
library(DBI)
library(duckdb)

Toutes nos opérations sur des bases de données passeront par la librairie DBI (DataBase Interface). C’est elle qui s’occupera de transférer nos demandes à la base de données. Par contre, comme chaque base de données possède ses petites particularités, nous devrons au moment de la connexion spécifier vers quelle base de données nous allons. Dans notre cas, ce sera vers DuckDB, donc :

connexion <- DBI::dbConnect(duckdb::duckdb())

Si notre base de données avait été un serveur PostgreSQL, on aurait pu spécifier notre connexion comme ceci :

connexion <- DBI::dbConnect(
  RPostgres::Postgres(),
  hostname = "adresse.com",
  port = 1234
)

Enfin, dans un vrai projet d’analyse, il aurait probablement été approprié de spécifier un dossier dans lequel DuckDB pourra stocker la base de données plutôt que de devoir la recréer à chaque démarrage :

connexion <- DBI::dbConnect(duckdb::duckdb(), dbdir = "donnees_projet")

Nous allons maintenant nous créer quelques tables et les remplir pour pouvoir commencer à travailler. Vous pouvez d’ailleurs télécharger le jeu de données associé à cet atelier ici.

Remarquez que les données sont déjà bien organisées pour l’importation : chaque observation possède un identifiant unique associé et la structure de la base de données a déjà été normalisée.

Voici tout d’abord comment créer une table DuckDB à partir d’un fichier CSV :

duckdb_read_csv(connexion, "especes", "FakeData/especes.csv")

Comme nous avons plusieurs tables à créer, nous pouvons automatiser ce processus à l’aide de la fonction map :

liste_tables <- c("especes","sites","parcelles","presences")

map(liste_tables, function(x){
  duckdb_read_csv(connexion, x, str_c("FakeData/",x,".csv"))
})
[[1]]
[1] 166

[[2]]
[1] 26

[[3]]
[1] 1300000

[[4]]
[1] 1e+07

On peut ensuite vérifier que toutes nos tables ont correctement été créées :

dbListTables(connexion)
[1] "especes"   "parcelles" "presences" "sites"    

La base de données avec laquelle nous allons travailler contient 26 sites, dans lesquels un total de 1 300 000 parcelles ont été inventoriées. Dans chacune des parcelles, la liste des toutes les espèces (83 au total) ont été notées, pour un total de 10 000 000 de présences. Ces données sont inventées de toute pièce, mais nous permettront de tester la rapidité et la puissance de l’approche par base de données. Ne craignez rien pour la vitesse d’exécution. Sur mon portable personnel, l’ensemble de l’atelier s’exécute en moins de 30 secondes…

Voici, pour information, la liste de tous les champs dans chacune des tables que nous avons créées :

dbListFields(connexion,"especes")
[1] "id"  "nom"
dbListFields(connexion,"sites")
[1] "id"  "nom" "ph" 
dbListFields(connexion,"parcelles")
[1] "id"          "site_id"     "nom"         "couvert_pct"
dbListFields(connexion,"presences")
[1] "parcelle_id" "espece_id"  

Remarquez que chacune des parcelles est associée à un site par la colonne site_id, qui en fait en relation 1:N (un site contient plusieurs parcelles, mais chaque parcelle n’appartient qu’à un site).

La table de présences quant à elle ne contient que des identifiants, soit les combinaisons espèces-parcelles qui ont été observées, décrivant une relation N:N.

Introduction au language SQL

Le SQL (Structured Query Language) est un langage informatique (relativement) standardisé permettant d’interroger des bases de données. Je dis “relativement” parce que les grandes lignes du langage sont les mêmes d’une base de données à l’autre, mais chacune ajoute aussi des particularités qui leur sont propres.

Le SQL définit trois grandes familles de commandes soit celles pour :

Dans la grande majorité des cas, il est possible de se connecter directement à la base de données à l’aide d’un terminal pour envoyer nos requêtes. Par contre, exception faite des opérations de maintenance, cette façon de faire est peu employée. Les requêtes sont habituellement envoyées par programmation dans le langage de notre choix, ici R.

Requêtes

Pour envoyer des requêtes permettant d’extraire des données, nous utiliserons la fonction dbGetQuery de la librairie DBI. Cette fonction attendra deux choses, soit une connexion vers une base de données, et la commande SQL à exécuter.

Sélectioner quoi, de quelle table?

La base des requêtes SQL est la commande SELECT, qui permet de choisir quels champs on veut extraire de notre table, suivie de FROM, qui spécifie sur quelle table effectuer l’extraction. Par exemple, pour obtenir les noms des sites, on pourrait lancer :

dbGetQuery(connexion, "SELECT nom FROM sites")
   nom
1    A
2    B
3    C
4    D
5    E
6    F
7    G
8    H
9    I
10   J
11   K
12   L
13   M
14   N
15   O
16   P
17   Q
18   R
19   S
20   T
21   U
22   V
23   W
24   X
25   Y
26   Z

La fonction dbGetQuery retourne toujours un objet data.frame, que l’on peut attraper et utiliser normalement ensuite dans R :

x <- dbGetQuery(connexion, "SELECT nom FROM sites")

x %>% 
  slice(1:10) %>% 
  arrange(desc(nom))
   nom
1    J
2    I
3    H
4    G
5    F
6    E
7    D
8    C
9    B
10   A

Notez que l’on a écrit ici les commandes SELECT et FROM en majuscules. Pour la majorité des logiciels de bases de données, le respect des majuscules/minuscules est facultatif. Les commandes SeLeCt, SELECT et select auraient exactement le même résultat. Par contre, par convention, il est d’usage d’inscrire les mots clés (SELECT, FROM, GROUP BY, etc.) en majuscules, et le nom des champs en minuscules.

Plutôt que de spécifier chacun des champs un à un, on peut aussi utiliser un *, qui agit comme un joker indiquant que l’on veut tous les champs de cette table :

dbGetQuery(connexion, "SELECT * FROM sites")
   id nom       ph
1   1   A 7.169245
2   2   B 6.954659
3   3   C 6.798699
4   4   D 6.746626
5   5   E 7.241875
6   6   F 6.970178
7   7   G 6.828074
8   8   H 6.692542
9   9   I 7.213349
10 10   J 7.310457
11 11   K 6.945442
12 12   L 7.693323
13 13   M 7.358573
14 14   N 7.196335
15 15   O 6.865845
16 16   P 7.118721
17 17   Q 6.759258
18 18   R 7.125312
19 19   S 7.078200
20 20   T 6.708648
21 21   U 6.577113
22 22   V 7.569592
23 23   W 6.879513
24 24   X 6.655040
25 25   Y 6.991663
26 26   Z 6.876635

Appliquer des filtres

Évidemment, on ne voudra pas toujours tout récupérer dans une table, on voudra parfois récupérer uniquement certaines informations. Par exemple, uniquement les sites avec un pH de 7 ou plus :

dbGetQuery(connexion, "SELECT * FROM sites WHERE ph >= 7")
   id nom       ph
1   1   A 7.169245
2   5   E 7.241875
3   9   I 7.213349
4  10   J 7.310457
5  12   L 7.693323
6  13   M 7.358573
7  14   N 7.196335
8  16   P 7.118721
9  18   R 7.125312
10 19   S 7.078200
11 22   V 7.569592

On peut aussi combiner plusieurs conditions, avec des ET et des OU :

dbGetQuery(connexion, "SELECT * FROM sites WHERE ph >= 7 AND ph < 7.4")
  id nom       ph
1  1   A 7.169245
2  5   E 7.241875
3  9   I 7.213349
4 10   J 7.310457
5 13   M 7.358573
6 14   N 7.196335
7 16   P 7.118721
8 18   R 7.125312
9 19   S 7.078200

Contrôler l’ordre

On peut changer l’ordre dans lequel apparaîtront les résultats en ajoutant une clause ORDER BY à notre requête, comme ceci :

dbGetQuery(connexion, "SELECT * FROM sites WHERE ph >= 7 ORDER BY ph")
   id nom       ph
1  19   S 7.078200
2  16   P 7.118721
3  18   R 7.125312
4   1   A 7.169245
5  14   N 7.196335
6   9   I 7.213349
7   5   E 7.241875
8  10   J 7.310457
9  13   M 7.358573
10 22   V 7.569592
11 12   L 7.693323

On peut choisir l’ordre décroissant plutôt que croissant en ajoutant le modificateur DESC :

dbGetQuery(connexion, "SELECT * FROM sites WHERE ph >= 7 ORDER BY ph DESC")
   id nom       ph
1  12   L 7.693323
2  22   V 7.569592
3  13   M 7.358573
4  10   J 7.310457
5   5   E 7.241875
6   9   I 7.213349
7  14   N 7.196335
8   1   A 7.169245
9  18   R 7.125312
10 16   P 7.118721
11 19   S 7.078200

Limiter la quantité d’information reçue

Si vous construisez des tableaux de bords, des rapports, etc., il pourrait vous arriver que vous n’ayez pas besoin de l’ensemble des lignes retournées par une requête, mais uniquement des premières. On peut utiliser la clause LIMIT pour couper nos résultats, par exemple en gardant uniquement les 5 premières lignes :

dbGetQuery(connexion, "SELECT * FROM sites ORDER BY ph LIMIT 5")
  id nom       ph
1 21   U 6.577113
2 24   X 6.655040
3  8   H 6.692542
4 20   T 6.708648
5  4   D 6.746626

La clause LIMIT deviendra particulièrement importante dans les requêtes à venir, qui pourraient retourner des millions de lignes…

Connecter plusieurs tables ensemble

Puisque nos données sont bien organisées en différentes tables avec une structure normalisée, on peut facilement exploiter les relations entre les différentes tables.

Comme premier scénario, imaginons que l’on voudrait obtenir toutes les parcelles situées dans des sites au pH de 7 ou plus.

Première étape, préparer une requête pour trouver ces sites :

dbGetQuery(connexion, "SELECT * FROM sites WHERE ph >= 7")
   id nom       ph
1   1   A 7.169245
2   5   E 7.241875
3   9   I 7.213349
4  10   J 7.310457
5  12   L 7.693323
6  13   M 7.358573
7  14   N 7.196335
8  16   P 7.118721
9  18   R 7.125312
10 19   S 7.078200
11 22   V 7.569592

Pour se faciliter la vie, nous allons introduire ici le concept d’alias, qui nous permet de renommer une table dans notre requête pour y faire référence plus facilement. Notez que les alias n’existent qu’au moment de cette requête en particulier. Lors de la prochaine, nous devrons le refaire cet alias si on veut l’utiliser à nouveau.

Nous en profiterons aussi pour clarifier que le pH et le * qui nous intéressent sont ceux de la table de sites :

dbGetQuery(connexion, "SELECT s.* FROM sites AS s WHERE s.ph >= 7")
   id nom       ph
1   1   A 7.169245
2   5   E 7.241875
3   9   I 7.213349
4  10   J 7.310457
5  12   L 7.693323
6  13   M 7.358573
7  14   N 7.196335
8  16   P 7.118721
9  18   R 7.125312
10 19   S 7.078200
11 22   V 7.569592

Maintenant, on peut aller connecter cette requête avec la table de parcelles :

dbGetQuery(connexion, "
           SELECT s.*, p.* 
           FROM sites AS s 
           LEFT JOIN parcelles AS p
           ON p.site_id = s.id
           WHERE s.ph >= 7
           LIMIT 10
          ")
   id nom       ph     id site_id        nom couvert_pct
1   5   E 7.241875 122881       5 megvgtlavc   60.040527
2   9   I 7.213349 122885       9 vnamlyqykq   10.955212
3  10   J 7.310457 122886      10 voynebxemt   87.516474
4  12   L 7.693323 122888      12 dcwdppalwt   91.731660
5  13   M 7.358573 122889      13 wdphqdvvnt   23.368555
6  14   N 7.196335 122890      14 vcvtjgthjp    3.024725
7  16   P 7.118721 122892      16 pjskhveidz   71.662687
8  18   R 7.125312 122894      18 lexdywvieo   91.496724
9  19   S 7.078200 122895      19 jlvqsnbyvy   61.266997
10 22   V 7.569592 122898      22 kfamkmimao   74.941283

Remarquez que l’on a besoin de deux clauses pour connecter les deux tables ensemble.

La première est le LEFT JOIN. Il existe différents types de “jointures” en SQL, la “gauche” étant la plus commune. Dans un LEFT JOIN, toutes les données de la table de gauche (ici les sites) seront présentes, et seules les données de la table de droite ayant des correspondances seront ajoutées.

Comme le SQL ne lit pas dans les pensées, il faut aussi ajouter une clause ON, qui spécifie sur quels champs connecter les deux tables. Ici, on spécifie que la colonne site_id de la table parcelles doit correspondre à la colonne id de la table de sites.

J’ai utilisé ici la clause LIMIT 10 pour éviter d’avoir 100 pages de résultat, mais dans une utilisation réelle, ce n’aurait pas été nécessaire.

Une chose particulièrement contre-intuitive ici est que l’on peut utiliser l’information provenant de la parcelle dans la clause SELECT, même si à ce point, nous n’avons pas mentionné que l’on voulait s’y lier. Le code SQL n’est pas interprété de façon séquentielle. La requête sera déconstruite et interprétée de façon optimale, pour l’ensemble de la requête.

Remarquez aussi que l’on mentionne que l’on veut toute l’info provenant des deux tables (s., p.), mais ce n’est pas obligatoire. On aurait aussi pu être sélectifs :

dbGetQuery(connexion, "
           SELECT s.nom AS nom_site, s.ph, p.nom AS nom_parcelle, p.couvert_pct
           FROM sites AS s 
           LEFT JOIN parcelles AS p
           ON p.site_id = s.id
           WHERE s.ph >= 7
           LIMIT 10
          ")
   nom_site       ph nom_parcelle couvert_pct
1         A 7.169245   yjsvyluwyw    13.35865
2         E 7.241875   dvbqleheht    42.34773
3         I 7.213349   dspjaogwaf    53.68863
4         J 7.310457   wenvtalrhm    33.90705
5         L 7.693323   yiufghvqze    54.29557
6         M 7.358573   segjczoozw    46.62623
7         N 7.196335   bkbedboyii    97.87763
8         P 7.118721   esqsvztcos    13.13683
9         R 7.125312   vzozqzgsnw    19.40764
10        S 7.078200   xnvlnblopy    42.59576

Remarquez ici que l’on peut utiliser des alias dans la clause SELECT de notre requête

Effectuer des opérations par groupe

Le SQL contient aussi toute une série de mots clés permettant de faire des opérations par groupe.

Sachant que COUNT nous fournit le nombre de lignes correspondant dans une requête, p.ex.

dbGetQuery(connexion,"SELECT COUNT(id) FROM sites")
  count(id)
1        26

On peut, par exemple, se demander combien de fois chacune des espèces a été observée :

dbGetQuery(connexion,"
           SELECT COUNT(p.espece_id)
           FROM presences AS p
           GROUP BY p.espece_id
           LIMIT 10
           ")
   count(p.espece_id)
1               75840
2               22797
3              169707
4              160021
5              198948
6              177248
7              118475
8               87996
9              175962
10              27476

Évidemment, cette façon de présenter l’information n’est pas très utile, mais peut facilement le devenir en connectant la table d’espèces :

dbGetQuery(connexion,"
           SELECT COUNT(p.espece_id) AS nb_observations, ANY_VALUE(e.nom) AS nom
           FROM presences AS p
           LEFT JOIN especes AS e ON e.id = p.espece_id
           GROUP BY e.id
           ORDER BY nb_observations DESC
           LIMIT 10
           ")
   nb_observations                            nom
1           439378      African giant pouched rat
2           439314 Thirteen-lined ground squirrel
3           436888               Little brown bat
4           434872                     Musk shrew
5           413442                        Giraffe
6           412510          African striped mouse
7           409380           Round-tailed muskrat
8           408834               African elephant
9           402402         Arctic ground squirrel
10          397896                            Cow

Remarquez que l’on a dû ajouter la clause ANY_VALUE autour de e.nom. Lorsque l’on applique une opération par groupe, toutes les valeurs de la clause SELECT doivent subir une opération de type agrégation (i.e. COUNT, MIN, MAX,AVG, FIRST, LAST, ANY_VALUE, etc.) ou faire partie de la cause GROUP BY.

Vues et sous-requêtes

Enfin, comme dernier exemple d’agrégation, nous allons calculer la richesse en espèce moyenne par parcelle pour chaque site. Ce sera notre requête la plus complexe de l’atelier!

Tout d’abord, construisons la requête qui calcule le nombre d’espèce par parcelle :

dbGetQuery(connexion,"
  SELECT 
    ANY_VALUE(pa.nom) AS nom,
    ANY_VALUE(pa.id) AS id, 
    ANY_VALUE(pa.site_id) AS site_id, 
    COUNT(DISTINCT(pr.espece_id)) AS richesse
  FROM parcelles AS pa
  LEFT JOIN presences as pr ON pr.parcelle_id = pa.id
  GROUP BY pa.id
  LIMIT 10
           ")
          nom      id site_id richesse
1  nldjabkezk 1264584      22        8
2  wanjivedhu 1272225      19       14
3  ggswzbuius   72442       6       21
4  xbjkkbamow  114094       6        6
5  gxdsdbdicm 1287497       3       10
6  dokcidjdqi  814664       6        4
7  zskfrdbata  145840       6       16
8  fccafsyimm 1274512      18       11
9  owgxsxcczv  467450      22       10
10 ymmbyupkzm  186856      20       14

Ensuite, il est extrêmement pratique de savoir qu’il existe dans les bases de données des objets que l’on nomme des vues. Ces dernières sont une façon de sauvegarder une requête et de lui associer un nom, pour la réutiliser plus tard.

On peut créer une vue à partir de la requête ci-haut grâce à la commande CREATE VIEW :

dbExecute(connexion,"
  CREATE VIEW v_richesses AS
  SELECT 
    ANY_VALUE(pa.nom) AS nom,
    ANY_VALUE(pa.id) AS id, 
    ANY_VALUE(pa.site_id) AS site_id, 
    COUNT(DISTINCT(pr.espece_id)) AS richesse
    FROM parcelles AS pa
    LEFT JOIN presences as pr ON pr.parcelle_id = pa.id
    GROUP BY pa.id")
[1] 0

Cette vue peut ensuite être utilisée exactement comme si il s’agissait d’une table :

dbGetQuery(connexion, "SELECT * FROM v_richesses LIMIT 5")
         nom      id site_id richesse
1 nolxmnqmdx 1058277      25       11
2 cwbbnvcniq 1285844      14        4
3 fzlzionnwq  846829       9       12
4 xiewxpzcrz  635508      16       11
5 jorditqaqa 1042151      19       12

Pour calculer la richesse moyenne par site, il ne nous reste maintenant qu’à connecter les deux tables ensemble à l’aide d’une jointure :

  dbGetQuery(connexion, "
    SELECT sites.id, ANY_VALUE(sites.nom) AS nom, ANY_VALUE(sites.ph) AS ph, MEAN(richesse) AS richesse_moyenne
    FROM sites
    LEFT JOIN v_richesses
    ON v_richesses.site_id = sites.id
    GROUP BY sites.id
    LIMIT 5
  ")
  id nom       ph richesse_moyenne
1  1   A 7.169245          7.12628
2  2   B 6.954659          7.13368
3  3   C 6.798699          7.09738
4  4   D 6.746626          7.13422
5  5   E 7.241875          7.11826

Remarquez qu’une stratégie alternative aurait été d’utiliser une sous-requête pour arriver au même résultat, mais de façon beaucoup moins lisible :

dbGetQuery(connexion,"
  SELECT ANY_VALUE(s.nom) AS nom, ANY_VALUE(s.ph) AS ph, AVG(p.richesse) AS richesse_moyenne
  FROM sites AS s
  LEFT JOIN (
    SELECT 
    ANY_VALUE(pa.nom) AS nom,
    ANY_VALUE(pa.id) AS id, 
    ANY_VALUE(pa.site_id) AS site_id, 
    COUNT(DISTINCT(pr.espece_id)) AS richesse
    FROM parcelles AS pa
    LEFT JOIN presences as pr ON pr.parcelle_id = pa.id
    GROUP BY pa.id
  ) AS p ON p.site_id = s.id
  GROUP BY s.id
  ORDER BY richesse_moyenne DESC
           ")
   nom       ph richesse_moyenne
1    L 7.693323          7.15808
2    X 6.655040          7.15736
3    K 6.945442          7.14678
4    Q 6.759258          7.13844
5    R 7.125312          7.13674
6    D 6.746626          7.13422
7    B 6.954659          7.13368
8    M 7.358573          7.13298
9    V 7.569592          7.13278
10   I 7.213349          7.12652
11   A 7.169245          7.12628
12   F 6.970178          7.12502
13   Z 6.876635          7.12202
14   E 7.241875          7.11826
15   U 6.577113          7.11738
16   O 6.865845          7.11498
17   N 7.196335          7.10808
18   G 6.828074          7.10546
19   W 6.879513          7.09800
20   C 6.798699          7.09738
21   S 7.078200          7.09358
22   H 6.692542          7.08934
23   T 6.708648          7.08824
24   Y 6.991663          7.07886
25   J 7.310457          7.07566
26   P 7.118721          7.06196

Insertion, modification et suppression de données

Dans le cadre de votre travail de biologiste, vous utiliserez probablement une base de données relativement statique, créée comme nous l’avons montré plus haut, directement à partir de fichiers CSV.

Néanmoins, il est intéressant de connaître la capacités complètes d’un système de base de données relationnelle. En particulier, comment ajouter, modifier et supprimer des données.

Insertion de données

Comme nous en avons discuté précédemment, il est très important dans une base de données relationnelle que chaque ligne dans une table puisse être identifiée de façon unique.

Certaines bases de données comme MySQL permettent, au moment de créer une table, de mentionner qu’un identifiant unique devra être généré chaque fois qu’une ligne est ajoutée.

Avec DuckDB, il faudra travailler un petit peu plus pour arriver au même résultat. Avant d’effectuer la première insertion, nous devrons créer un objet de type séquence, qui nous permettra d’obtenir la prochaine valeur d’id à utiliser lors de notre insertion.

Pour cette section, nous enverrons nos commandes, non pas à l’aide de la fonction dbGetQuery, mais à l’aide de la fonction dbExecute, puisque nos commandes SQL ne sont pas des requêtes conçues pour retourner des données.

Donc, voyons d’abord comment créer une séquence pour la table de sites, en spécifiant que la première valeur de la séquence devra être l’id maximum de site + 1.

Remarquez que contrairement aux id qui s’auto-incrémentent, cette stratégie est plutôt risquée si plusieurs personnes travaillent simultanément sur la même base de données. Après avoir récupéré l’id maximal, une nouvelle insertion pourrait avoir lieu et changer ce maximum. De plus, il serait possible pour quelqu’un d’insérer une ligne sans utiliser la séquence, etc.

Donc, premièrement, quel est le plus grand id de notre table de sites?

dbGetQuery(connexion,"SELECT MAX(id) FROM sites")
  max(id)
1      26

Deuxièmement, on crée la séquence, en réutilisant ce chiffre

dbExecute(connexion, "CREATE SEQUENCE sequence_sites START 27")
[1] 0

On peut donc ensuite utiliser cette séquence pour les insertions subséquentes :

dbExecute(connexion, "INSERT INTO sites VALUES (nextval('sequence_sites'), 'site ajouté', 7.0)")
[1] 1
dbExecute(connexion, "INSERT INTO sites VALUES (nextval('sequence_sites'), 'deuxième ajout', 6.5)")
[1] 1

On peut maintenant aller voir dans la table que nos sites ont effectivement été ajoutés, avec des identifiants uniques :

dbGetQuery(connexion,"SELECT * FROM sites ORDER BY id DESC LIMIT 10")
   id            nom       ph
1  28 deuxième ajout 6.500000
2  27    site ajouté 7.000000
3  26              Z 6.876635
4  25              Y 6.991663
5  24              X 6.655040
6  23              W 6.879513
7  22              V 7.569592
8  21              U 6.577113
9  20              T 6.708648
10 19              S 7.078200

Sachant cela, il pourrait être tentant d’insérer des données en utilisant des objets existants dans R, par exemple comme ceci :

nom <- "Site de Charles"
pH <- 7

dbExecute(connexion, paste0(
  "INSERT INTO sites VALUES (nextval('sequence_sites'), '",
  nom,
  "', ",
  pH,
  ")"
))
[1] 1

Remarquez d’abord que ce genre d’opération nécessite beaucoup de concentration, pour ne pas mélanger les guillemets doubles qui définissent nos chaînes de caractères dans R, et les guillemets simples, qui définissent les chaînes de caractère en SQL.

Remarquez aussi que, si notre nom de site contient, entre autres, une apostrophe, nous aurons une erreur SQL :

nom <- "L'autre site"
pH <- 7

dbExecute(connexion, paste0(
  "INSERT INTO sites VALUES (nextval('sequence_sites'), '",
  nom,
  "', ",
  pH,
  ")"
))
Error: Parser Error: syntax error at or near "autre"
LINE 1: ... VALUES (nextval('sequence_sites'), 'L'autre site', 7)
                                                  ^

Ce genre de problème a donné lieu a beaucoup de cas de piratage de sites web au début des années 1990. Il suffit par exemple d’imaginer un formulaire dans lequel un visiteur mal intentionné entrerait A'; DELETE * FROM users; ou A';INSERT INTO admin VALUES ''pirate'',''motdepassedupirate''; comme nom d’usager ou mot de passe !!!

Pour ces deux raisons, il est maintenant recommandé de toujours utiliser les fonctions de protection fournies avec la librairie qui gère votre base de données. Dans notre cas, on parle de la combinaison DBI + DuckDB. L’idée derrière ces fonctions de protection sera d’encoder tous les éléments potentiellement dangereux d’une requête pour éviter ces problèmes, qu’ils soient accidentels ou malveillants.

Pour cela, il faudra travailler en deux étapes, soit préparer le squelette de notre requête, et ensuite, dans un deuxième temps, demander à DBI d’effectuer les remplacements.

Dans le squelette de requête, tous les éléments qui proviendront de l’extérieur sont précédés d’un ? :

valeur_tannante <- "L'apostrophe de malheur"
squelette <- "INSERT INTO sites VALUES (nextval('sequence_sites'),?nom,?ph)"

Ensuite, la fonction interpolate nous permet de construire une requête, où on fournit les valeurs à utiliser pour chacun des remplacements

requete <- sqlInterpolate(
  connexion,
  squelette,
  nom="L'autre site",
  ph=6.5
)
print(requete)
<SQL> INSERT INTO sites VALUES (nextval('sequence_sites'),'L''autre site',6.5)
dbExecute(connexion,requete)
[1] 1

Cette façon de faire devrait être appliquée chaque fois qu’une partie de la requête provient d’un objet de R, même lorsqu’il s’agit d’un simple SELECT :

squelette <- "SELECT * FROM sites WHERE nom = ?nom"
requete <- sqlInterpolate(
  connexion,
  squelette,
  nom="L'autre site"
)
print(requete)
<SQL> SELECT * FROM sites WHERE nom = 'L''autre site'
dbGetQuery(connexion, requete)
  id          nom  ph
1 30 L'autre site 6.5

Suppression de données

La suppression de données en SQL s’effectue avec la fonction… DELETE!

Vérifions d’abord que nous avons des sites avec des pH sous 6.6 :

dbGetQuery(connexion, "SELECT * FROM sites ORDER BY ph LIMIT 5")
  id            nom       ph
1 28 deuxième ajout 6.500000
2 30   L'autre site 6.500000
3 21              U 6.577113
4 24              X 6.655040
5  8              H 6.692542

Pour supprimer tous les sites dont le pH est < 6.6, on pourrait lancer la requête suivante :

dbExecute(connexion,"DELETE FROM sites WHERE ph < 6.6")
[1] 3

On peut ensuite vérifier que nos données ont bien été supprimées :

dbGetQuery(connexion, "SELECT * FROM sites ORDER BY ph LIMIT 5")
  id nom       ph
1 24   X 6.655040
2  8   H 6.692542
3 20   T 6.708648
4  4   D 6.746626
5 17   Q 6.759258

Dans les bases de données plus avancées comme MySQL, PostgreSQL, etc., une telle opération aurait aussi pu entraîner la suppression de toutes les parcelles associées au site, de toutes les présences associées aux parcelles, etc. Par contre, comme DuckDB est une base de données locale, et légère conçue pour une seul utilisateur à la fois, cette cascade n’a pas eu lieu :

dbGetQuery(connexion, "SELECT * FROM parcelles WHERE site_id = 21 LIMIT 5")
   id site_id        nom couvert_pct
1  21      21 otuzagswlo    55.11549
2  47      21 orqxestuqr    58.59047
3  73      21 vytwvjjvcr    47.13821
4  99      21 odicuudsgl    42.04843
5 125      21 izpwayhfaq    78.15716

Mise à jour des données

Enfin, on peut mettre à jour des données avec la commande UPDATE.

Par exemple, si on s’est trompés et que toutes les observations de la parcelle 3 étaient en fait la parcelle 4, on pourrait faire :

dbExecute(connexion,"UPDATE presences SET parcelle_id = 4 WHERE parcelle_id = 3")
[1] 7

Et ensuite vérifier que tout s’est bien déroulé :

dbGetQuery(connexion, "
           SELECT parcelle_id, COUNT(*) 
           FROM presences 
           WHERE parcelle_id = 3 OR parcelle_id = 4
           GROUP BY parcelle_id
          ")
  parcelle_id count_star()
1           4           10

Conclusion et références

Comme pour la majorité des ateliers du Numérilab, nous n’avons ici qu’effleuré la surface de ce qu’il est possible de faire avec le SQL. J’espère que vous avez apprécié le fait que notre base de données traitait des requêtes sur des millions de lignes sans même sourciller. Évidemment, les bases de données relationnelles et le SQL ont leur place dans différents systèmes informatiques, allant de quelques Mo de données pour un projet de maîtrise, à plusieurs Po de données pour gérer le contenu d’un site web transactionnel de classe mondiale.

Si vous voulez en apprendre plus sur le SQL, la ressources que je vous recommanderais probablement (et celle qui m’a aidé à me dérouiller après quelques années sans faire de SQL) est le tutoriel de W3Schools : https://www.w3schools.com/sql/.

Je vous encourage aussi fortement à aller explorer SQLite, une autre base de données légère et locale comme DuckDB, qui est utilisée comme stockage dans beaucoup d’applications.