Correspondances entre SQL et vues CouchDB

Ce chapitre contient un ensemble de requêtes SQL classiques et la manière de les traduire en vues CouchDB. Il faut se souvenir d’un élément clé : CouchDB ne fonctionne pas comme une base de données SQL et toutes les bonnes pratiques du monde SQL ne se retrouvent guère, voire pas du tout, dans CouchDB. Les recettes données dans ce chapitre partent du principe que vous êtes familiarisé avec CouchDB (vous savez créer et mettre à jour des bases de données et des documents).

Utiliser les vues

Ce que cela donnerait en SQL :

CREATE TABLE

ou :

ALTER TABLE

L’utilisation d’une vue se fait en deux temps : d’abord, vous la définissez, ensuite vous l’interrogez. C’est analogue à la définition de la structure d’une table (avec ses indexes) à l’aide de CREATE TABLE ou ALTER TABLE, puis à la consulter avec une requête SQL.

Définir une vue

On définit une vue dans CouchDB en créant un document particulier. Sa seule particularité est son _id, lequel commence par _design/ (par exemple : _design/application). À cette exception près, c’est un document on ne peut plus ordinaire. Afin que CouchDB comprenne que vous définissez une vue, vous devez préparer le contenu de ce design document selon un format particulier. Voici un exemple :

{
  "_id": "_design/application",
  "_rev": "1-C1687D17",
  "views": {
    "viewname": {
      "map": "function(doc) { ... }",
      "reduce": "function(keys, values) { ... }"
    }
  }
}

Nous définissons une vue viewname. La définition d’une vue consiste en deux fonctions : subdivision (map en anglais) et agrégation (reduce en anglais). Cette dernière est optionnelle. Nous reviendrons sur la nature de ces fonctions. Notez ici que viewname peut prendre la valeur de votre choix : users, by-name, by-date, etc.

Un seul design document peut inclure plusieurs définitions de vues, chacune identifiée par un nom unique :

{
  "_id": "_design/application",
  "_rev": "1-C1687D17",
  "views": {
    "viewname": {
      "map": "function(doc) { ... }",
      "reduce": "function(keys, values) { ... }"
    },
    "anotherview": {
      "map": "function(doc) { ... }",
      "reduce": "function(keys, values) { ... }"
    }
  }
}

Interroger une vue

Le nom du design document et le nom de la vue sont essentiels pour interroger la vue. En effet, pour interroger viewname, vous forgez une requête HTTP GET à l’URI suivante :

/database/_design/application/_view/viewname

database est le nom de la base de données dans laquelle vous avez créé votre design document. Vient ensuite le nom du design document, puis le nom de la vue lui-même préfixé par _view. Pour interroger anotherview, remplacez viewname dans l’URI précédente par anotherview. Si vous souhaitez interroger une vue définie dans un autre design document, ajustez le nom dans l’URI.

Fonctions de subdivision et d’agrégation (MapReduce)

MapReduce est un concept qui résout les problèmes en deux temps. Tout d’abord vient la phase de subdivision (map), puis celle d’agrégation (reduce). La phase de subdivision parcourt les documents les uns après les autres et crée un résultat de subdivision. Celui-ci prend la forme d’une liste ordonnée de couples (clé, valeur). Les deux membres de ce couple peuvent être définis par l’utilisateur lors de la création de la fonction de subdivision. Ainsi, elle peut appeler la fonction emit(key, value) de 0 à N fois par document, créant autant d’entrées dans le résultat de la subdivision.

CouchDB est suffisamment malin pour exécuter une seule et unique fois la fonction de subdivision sur un document, même si d’autres requêtes interrogent la vue. Seules les modifications de documents et les créations de documents nécessitent de l’exécuter à nouveau.

Fonctions de subdivision

Les fonctions de subdivision s’exécutent de manière indépendante pour chaque document. Elles ne peuvent pas modifier le document, pas plus qu’elles ne peuvent communiquer avec l’extérieur — elles ne peuvent avoir d’effets de bord. Cela est rendu nécessaire pour que CouchDB puisse garantir de détenir les bons résultats sans avoir à recalculer tout le résultat dès qu’un document est modifié.

Le résultat de subdivision ressemble à cela :

{"total_rows":3,"offset":0,"rows":[
{"id":"fc2636bf50556346f1ce46b4bc01fe30","key":"Lena","value":5},
{"id":"1fb2449f9b9d4e466dbfa47ebe675063","key":"Lisa","value":4},
{"id":"8ede09f6f6aeb35d948485624b28f149","key":"Sarah","value":6}
]}

C’est une liste d’enregistrements triés par la valeur de la clé key. L’identifiant id est ajouté automatiquement et référence le document qui a donné lieu à cet enregistrement. La valeur value est la donnée que vous cherchez. Dans notre exemple, il s’agit de l’âge de la fille.

La fonction de subdivision qui donne ce résultat est la suivante :

function(doc) {
  if(doc.name && doc.age) {
    emit(doc.name, doc.age);
  }
}

Elle contient l’instruction if comme garde-fou afin de s’assurer que nous travaillons sur les bons champs et appelle la fonction emit avec le nom et l’âge de la fille, respectivement pour la clé et la valeur.

Fonctions d’agrégation

Les fonctions d’agrégation sont expliquées dans la section Fonctions d’agrégation.

Recherche par clé

Ce que cela donnerait en SQL :

SELECT field FROM table WHERE value="searchterm"

Cas d’utilisation : obtenir un résultat (qui peut être un seul ou un jeu d’enregistrements) associé à une clé ("searchterm").

Pour trouver quelque chose rapidement, sans égard pour le mécanisme de stockage, un index est nécessaire. Un index est une structure de données optimisée pour la recherche et la récupération rapide. Le résultat de la subdivision est stocké dans un tel index, lequel est, soit dit en passant, un arbre B+.

Pour chercher une valeur correspondant à "searchterm", nous devons mettre toutes ses valeurs dans les clés d’une vue. Pour cela, il nous faut une simple fonction de subdivision :

function(doc) {
  if(doc.value) {
    emit(doc.value, null);
  }
}

Cela crée une liste des documents ayant un champ value, laquelle est triée par la donnée du champ value. Aussi, pour trouver tous les enregistrements correspondants à "searchterm", nous interrogeons la vue et précisions la valeur recherchée dans la requête :

/database/_design/application/_view/viewname?key="searchterm"

Reprenons les documents de la section précédente, et disons que nous les indexons sur le champ âgé. Nous cherchons toutes les filles de cinq ans :

function(doc) {
  if(doc.age && doc.name) {
    emit(doc.age, doc.name);
  }
}

Requête :

/ladies/_design/ladies/_view/age?key=5

Résultat :

{"total_rows":3,"offset":1,"rows":[
{"id":"fc2636bf50556346f1ce46b4bc01fe30","key":5,"value":"Lena"}
]}

Facile.

Notez que vous devez émettre une valeur. Le résultat de la vue inclut l’identifiant du document associé à chaque enregistrement. Nous pouvons l’utiliser ensuite pour extraire davantage d’informations du document. Nous pouvons aussi préciser le paramètre ?include_docs=true pour que CouchDB nous joigne chaque document.

Recherche par préfixe

Ce que cela donnerait en SQL :

SELECT field FROM table WHERE value LIKE "searchterm%"

Cas d’utilisation : trouver tous es documents dont un champ commence par la valeur searchterm. Par exemple, vous avez stocké un type MIME (comme text/html ou image/jpg) dans chaque document, et vous voulez maintenant trouver tous les documents qui sont des images en fonction du type MIME déclaré.

La solution ressemble beaucoup à l’exemple précédent. Nous avons juste besoin d’une fonction de subdivision qui soit un peu plus intelligente. Mais avant, un exemple de document :

{
  "_id": "Hugh Laurie",
  "_rev": "1-9fded7deef52ac373119d05435581edf",
  "mime-type": "image/jpg",
  "description": "some dude"
}

Le truc consiste à extraire le préfixe que nous voulons rechercher et le placer dans l’index de notre vue. Pour ce faire, nous utilisons une expression rationnelle :

function(doc) {
  if(doc["mime-type"]) {
    // from the start (^) match everything that is not a slash ([^\/]+) until
    // we find a slash (\/). Slashes needs to be escaped with a backslash (\/)
    var prefix = doc["mime-type"].match(/^[^\/]+\//);
    if(prefix) {
      emit(prefix, null);
    }
  }
}

Nous pouvons alors chercher dans cette vue notre préfixe MIME et trouver, selon notre désir, toutes les images, tous les textes, toutes les vidéos et tous les autres formats :

/files/_design/finder/_view/by-mime-type?key="image/"

Fonctions d’agrégation

Ce que cela donnerait en SQL :

SELECT COUNT(field) FROM table

Cas d’utilisation : calculer une valeur déduite de vos données.

Nous n’avons pas encore expliqué les fonctions d’agrégation. Elles sont similaires aux fonctions éponymes de SQL [NdT : évident avec la traduction française, beaucoup moins avec le terme « reduce »]. Elles calculent des valeurs en se basant sur plusieurs documents.

Pour expliquer le fonctionnement des fonctions d’agrégation, nous allons en concevoir une qui ne fait pas grand sens, mais qui est facile à comprendre. Nous verrons d’autres agrégats plus utiles par la suite.

Les fonctions d’agrégations travaillent sur le résultat des fonctions de subdivision (aussi appelé résultat de subdivision ou résultat intermédiaire). Le rôle de la fonction d’agrégation est, sans surprise, de réduire la liste fournie par la fonction de subdivision [NdT : d’où le MapReduce]).

Voici à quoi ressemble notre fonction de sommation :

function(keys, values) {
  var sum = 0;
  for(var idx in values) {
    sum = sum + values[idx];
  }
  return sum;
}

Voilà la même, plus dans l’esprit JavaScript :

function(keys, values) {
  var sum = 0;
  values.forEach(function(element) {
    sum = sum + element;
  });
  return sum;
}

Cette fonction d’agrégation accepte deux arguments : une liste de clés keys et une liste de valeurs values. Dans notre exemple de sommes, nous pouvons ignorer la liste de clés et exploiter uniquement la liste de valeurs. Nous parcourons la liste et, à chaque itération, incrémentons un total que nous renvoyons à la fin de la fonction.

Vous voyez présentement une différence entre une fonction de subdivision et d’agrégation. La première appelle emit() pour créer un résultat tandis que la seconde renvoie une valeur.

Voici un exemple : à partir de la liste des âges, calculer la somme des années de vie pour affiche en une « 786 années de vie présentes à l’évènement ». Un peu tiré par les cheveux, mais très simple et bon pour notre démonstration. Souvenez-vous des documents précédents sur l’âge des filles.

La fonction d’agrégation pour calculer l’âge total des filles est la suivante :

function(keys, values) {
  return sum(values);
}

Notez que, plutôt que d’itérer sur la liste, nous utilisons la fonction sum() prédéfinie dans CouchDB. Elle fait la même chose, mais c’est un besoin si courant que CouchDB offre un raccourci.

Le résultat renvoyé par notre vue agrégée est le suivant :

{"rows":[
{"key":null,"value":15}
]}

La somme totale de tous les champs âgés dans l’ensemble de nos documents est 15. Pile ce que nous voulions. Le membre key de l’objet résultant est null, car nous ne pouvons plus savoir quel document a permis d’obtenir le résultat. Nous nous intéresserons à des usages plus avancés par la suite.

Une règle fondamentale : une fonction d’agrégation doit renvoyer une unique valeur scalaire. C’est-à-dire un entier, une chaîne de caractères, une liste de taille fixe ou un objet contenant une ou plusieurs valeurs agrégées à partir de l’argument values. Elle ne doit jamais se contenter de retourner values ou quelque chose du genre. CouchDB vous avertira si vous agrégez de la mauvaise manière :

{"error":"reduce_overflow_error","message":"Reduce output must shrink more rapidly: Current output: ..."}

Obtenir des valeurs uniques

Ce que cela donnerait en SQL :

SELECT DISTINCT field FROM table

Obtenir des valeurs uniques n’est pas aussi simple qu’ajouter un mot clé. Toutefois, une vue agrégée et un paramètre de requête particulier nous donnent le même résultat. Imaginons que vous désiriez la liste des tags que vos utilisateurs se sont appliqués, et sans doublon.

Tout d’abord, regardons les documents sources. Nous masquons les attributs _id et _rev :

{
  "name":"Chris",
  "tags":["mustache", "music", "couchdb"]
}

{
  "name":"Noah",
  "tags":["hypertext", "philosophy", "couchdb"]
}

{
  "name":"Jan",
  "tags":["drums", "bike", "couchdb"]
}

Ensuite, nous avons besoin d’une liste de tous les tags. Une fonction de subdivision suffira :

function(dude) {
  if(dude.name && dude.tags) {
    dude.tags.forEach(function(tag) {
      emit(tag, null);
    });
  }
}

Le résultat ressemblera à :

{"total_rows":9,"offset":0,"rows":[
{"id":"3525ab874bc4965fa3cda7c549e92d30","key":"bike","value":null},
{"id":"3525ab874bc4965fa3cda7c549e92d30","key":"couchdb","value":null},
{"id":"53f82b1f0ff49a08ac79a9dff41d7860","key":"couchdb","value":null},
{"id":"da5ea89448a4506925823f4d985aabbd","key":"couchdb","value":null},
{"id":"3525ab874bc4965fa3cda7c549e92d30","key":"drums","value":null},
{"id":"53f82b1f0ff49a08ac79a9dff41d7860","key":"hypertext","value":null},
{"id":"da5ea89448a4506925823f4d985aabbd","key":"music","value":null},
{"id":"da5ea89448a4506925823f4d985aabbd","key":"mustache","value":null},
{"id":"53f82b1f0ff49a08ac79a9dff41d7860","key":"philosophy","value":null}
]}

Comme promis, voici tous les tags, y compris les doublons. Comme la fonction de subdivision est exécutée de manière indépendante sur chaque document, elle ne peut pas savoir ce qui a été émis par ailleurs. À ce stade, nous devons vivre avec. Pour obtenir l’unicité, nous avons besoin d’un agrégat :

function(keys, values) {
  return true;
}

Cette fonction d’agrégation ne fait rien, mais elle nous permet de spécifier un paramètre spécial quand nous appelons la vue :

/dudes/_design/dude-data/_view/tags?group=true

CouchDB répond :

{"rows":[
{"key":"bike","value":true},
{"key":"couchdb","value":true},
{"key":"drums","value":true},
{"key":"hypertext","value":true},
{"key":"music","value":true},
{"key":"mustache","value":true},
{"key":"philosophy","value":true}
]}

Dans ce cas, nous pouvons ignorer les valeurs, car elles seront toujours à vrai. Le résultat nous donne la liste de tous les tags, et sans doublon !

En apportant une légère retouche, nous pouvons exploiter plus utilement la fonction d’agrégation. Voyons combien de tags identiques sont répertoriés. Pour calculer la fréquence d’un tag, nous utilisons la sommation vue précédemment. Dans la fonction de subdivision, nous émettons un 1 au lieu de null :

function(dude) {
  if(dude.name && dude.tags) {
    dude.tags.forEach(function(tag) {
      emit(tag, 1);
    });
  }
}

Dans la fonction d’agrégation, nous retournons la somme des valeurs :

function(keys, values) {
  return sum(values);
}

Désormais, si nous interrogeons la vue avec le paramètre ?group=true, nous obtiendrons le compte de chaque tag :

{"rows":[
{"key":"bike","value":1},
{"key":"couchdb","value":3},
{"key":"drums","value":1},
{"key":"hypertext","value":1},
{"key":"music","value":1},
{"key":"mustache","value":1},
{"key":"philosophy","value":1}
]}

Garantir l’unicité

Ce que cela donnerait en SQL :

UNIQUE KEY(column)

Cas d’utilisation : votre application requiert qu’une valeur ne soit présente qu’une seule fois dans la base de données.

C’est facile : dans une base de données CouchDB, chaque document doit avoir un champ _id unique. Si vous avez besoin de garantir l’unicité d’une valeur, assignez-la au champ _id du document et CouchDB s’occupera du reste.

Toutefois, il y a un défaut : dans le cas d’une base distribuée avec plusieurs instances de CouchDB acceptant les écritures, l’unicité ne peut être garantie que par nœud ou en dehors de CouchDB. CouchDB permettra l’écriture de deux identifiants identiques sur deux nœuds différents. C’est lors de la réplication que le conflit apparaîtra et que CouchDB indiquera le problème.