Traductions pour la plateforme Java

Cours MongoDB semaine 2: CRUD

La seconde semaine est dédiée à l'apprentissage des opérations CRUD avec mongo. La terminologie MongoDB est la suivante:

  • Create → insert
  • Read → find
  • Update → update (hé oui !)
  • Delete → remove

Dans l'univers relationnel, on utilise un langage particulier, SQL, pour ces opérations et ce quel que soit le langage utilisé pour le reste de l'application. Avec MongoDB, on reste dans le langage hôte et on utilise le driver approprié qui fournit les APIs nécessaires.

Nous verrons dans un premier temps comment exécuter ces opérations de base dans un shell mongo, puis à l'aide du driver Java.

CRUD dans un shell mongo

Le shell mongo, comme nous l'avons vu précédemment, est un intérpréteur Javascript interactif. Les raccourcis sont assez semblables à ceux du bash, on n'est donc pas dépaysés. Il propose aussi une complétion automatique des mots-clés JS. La commande help offre une aide intégrée et help keys vous donnera une liste de raccourcis utiles.

Les documents sont stockés par MongoDB au format BSON. La principale différence avec JSON est un nombre de types de données beaucoup plus important (dates, binaires, entiers courts ou longs, ...). Ainsi les langages possédant nativement ces types de données n'ont pas de souci à se faire, les drivers feront le travail de traduction. Le cas de Javascript est un peu particulier car il ne possède pas tous ces types de données. Par défaut, le shell considérera par exemple que les nombres sont à virgule flottante. Afin de pallier à ce problème, le shell (et donc le driver Javascript) met à notre disposition des constructeurs tels que NumberInt(), NumberLong(), ISODate(), ...

Premiers exemples d'insertions et de requêtes

Insertion et recherche avec le shell mongo

Lors du find(), on voit qu'un champ de nom _id et de type ObjectId (un des types de données BSON justement) a été inséré. Tout document doit en effet avoir un identifiant unique dans la collection, et si l'on ne le précise pas, MongoDB se charge de le créer à notre place. Cet identifiant ne peut pas être modifié et constitue la clé primaire de la collection. L'identifiant est généré, si besoin, sur la base du timestamp et d'informations concernant le processus qui a créé l'objet, ce qui implique que la probabilité d'obtenir deux identifiants identiques est quasi-nulle.

Différents types de requêtes

On s'intéresse ensuite à la commande findOne() qui renvoit au plus un document. Sans argument, elle retourne un document quelconque de la collection, ce qui est assez utile pour examiner rapidement la structure des documents.

Syntaxe du findOne()

On voit ensuite qu'elle peut prendre deux paramètres:

  1. Le premier est un document qui constitue le critère de recherche. Le document retourné sera identique au document spécifié (sur les champs fournis bien sûr). Cette première partie correspond donc à la clause WHERE de SQL.
  2. Le second est un document qui permet de restreindre les champs du document retourné, à la manière de la clause SELECT de SQL. En son absence, tous les champs sont retournés. On note aussi que l'identifiant est toujours retourné, à moins que l'on dise explicitement le contraire.

Nous avons déjà utilisé la méthode qui permet de retourner plusieurs résultats: il s'agit de find(), dont la syntaxe est identique à findOne(). Dans le shell mongo, les résultats sont paginés et l'on passe aux résultats suivants en tapant la commande it (pour iterate). En fait, le serveur maintient quelques temps (10 minutes par défaut) un curseur qui permet d'itérer sur les résultats de recherche.

Enfin la méthode count() retourne comme vous vous en doutez le nombre de documents satisfaisant la requête.

Utilisation d'opérateurs

Jusqu'à présent, les critères de recherche étaient extrêmement basiques: on se contentait de spécifier des égalités. Dans la plupart des cas bien sûr, les recherches sont plus complexes. On peut alors utiliser des opérateurs logiques au lieu de simplement donner une valeur. Voici quelques opérateurs courants, avec des exemples d'utilisation:

OpérateurSignificationExemple
$gt, $gte, $lt, $lte>, >=, <, <=score: { $gt: 95, $lte: 98 }
$existstest sur l'existence d'un champprofession: { $exists: true }
$typetest sur le type d'un champname: { $type: 2 }
$regexrecherche de pattern dans une chainename: { $regex: "e$" }
$orOU logique sur les clauses fournies dans le tableau$or: [ { name: { $regex: "e$"} }, age: { $exists: true} ]
$andET logique sur les clauses fournies dans le tableau$and: [ { name: { $regex: "e$"} }, age: { $exists: true} ]

Les opérateurs d'inégalité marchent aussi avec les chaines de caractères, auquel cas c'est l'ordre lexicographique qui est utilisé. Attention toutefois, MongoDB n'a aucune notion de langue régionale, c'est l'ordre des caractères dans le codage UTF-8 qui compte.

On note aussi le comportement des opérateurs dans le cas où des valeurs de types différents, par exemple chaine et nombre, sont stockés dans le même champ. Si on spécifie un nombre comme valeur de l'opérateur, seuls les documents contenant un nombre seront pris en compte et vice-versa.

L'opérateur $type permet de ne prendre en compte que les documents dont un certain champ est d'un type donné. Malheureusement, la valeur numérique que l'on passe en paramètre à l'opérateur est déterminée par la spécification BSON. Si besoin est, il faut donc se rapprocher de la documentation de l'opérateur pour connaitre la valeur appropriée.

MongoDB utilise le moteur Perl compatible regular expressions pour l'évaluation des expressions régulières, on reste donc dans du connu (pour autant qu'on aime ça...). D'ailleurs, il faut faire attention à ne pas en abuser car bien entendu les expressions régulières pénalisent en général le requêtage. Les seules qui soient réellement optimisables sont celles du type "^A" qui:

  1. spécifient un début de chaine
  2. ne contiennent pas de joker

Quand on arrive au $or, on réalise que la syntaxe MongoDB devient assez vite lourde et qu'il faut faire très attention en écrivant ! Le shell nous aide un peu en faisant apparaitre l'accolade associée quand le curseur est sur une accolade. De plus, si on s'est trompé et qu'on lance la requête, le shell pensera qu'elle est incomplète et affichera trois petits points pour signifier qu'il attend la suite.

L'opérateur $and est d'utilisation assez rare puisqu'on le comportement par défaut de MongoDB est de faire un ET logique sur toutes les clauses fournies. Il peut avoir son utilité si on construit des requêtes dynamiques.

Un petit avertissement en passant, au moyen de ce quizz amusant: question piège

Si l'on donne plusieurs conditions pour le même champ, c'est la dernière qui va gagner car le shell va construire un premier objet correspondant à la première condition, puis écraser cet objet quand il va lire la seconde condition. Dans ce cas, il faudrait soit mettre les deux conditions dans le même objet, soit utiliser l'opérateur $and. Cela ne se produira d'ailleurs pas forcément qu'avec Javascript, mais avec tous les langages qui utilisent un dictionnaire dont la clé est le champ pour spécifier les conditions. Logique, mais bon à savoir !

Requêtes et tableaux

Nous avons vu précédemment que les documents peuvent contenir des tableaux. Comment faire pour requêter sur les valeurs contenues dans un champ de ce type ? La recherche traite en fait les champs de type tableau de la même manière que les champs simples. Prenons par exemple la requête suivante:

db.accounts.find( { beer: "guiness" } )

Elle retournera les documents qui contiennent un champ nommé 'beer' de type chaine ayant la valeur 'fine' et ceux qui ont un champ 'beer' de type tableau dont l'une des valeurs est 'fine'. Le processus n'est toutefois pas récursif: si le tableau contient lui-même un tableau, la recherche n'ira pas dans le tableau imbriqué.

Pour des requêtes un peu plus compliquées, nous disposons de ces opérateurs supplémentaires :

OpérateurSignificationExemples
$allToutes les valeurs spécifiées doivent se trouver dans le tableau$all: ["guiness", "leffe"]
$inL'une des valeurs spécifiées doit se trouver dans le tableau$in: ["guiness", "leffe"]

Requêtes et sous-documents

Nous avons également vu qu'il est possible d'embarquer des documents dans des documents. Comment aller requêter dans des sous-documents ? C'est là qu'intervient la notation pointée. Supposons pour l'exemple que nous ayons une collection d'utilisateurs, et que chaque document utilisateur contient un sous-document représentant la liste de ses adresses électroniques. Nous pourrions alors rechercher les utilisateurs ayant une certaine adresse professionnelle, parmi d'autres, de cette manière:

db.users.find( {"email.work": "jocelyn@test.com"})

alors que la requête suivante ne ramènerait que les utilisateurs n'ayant qu'une adresse professionnelle et qui aurait cette valeur, ce qui ne serait pas le bon résultat dans la plupart des cas.

db.users.find( {email: { work: "jocelyn@test.com" }})

Curseurs

Pour l'instant, nous nous sommes contentés d'afficher le résultat des requêtes. Mais nous pouvons aussi récupérer un curseur et itérer sur les résultats pour nos propres traitements. La manière de faire est très familière pour les développeurs Java, puisqu'elle ressemble furieusement à l'interface Iterable. Nous avons donc deux méthodes, hasNext() pour savoir s'il y a encore un document à visiter, et next() pour y accéder.

Mais ce n'est pas tout ! Un curseur possède encore d'autres méthodes qui vont être utiles:

  • limit(): pour récupérer les n premiers résultats uniquement
  • sort(): pour trier les résultats
  • skip(): pour "sauter" n résultats

Par exemple, pour avoir l'antépénultième utilisateur selon le nom, on pourrait faire:

cur = db.users.find();
cur.sort( {name: -1} ).limit(1).skip(2);

Il faut noter que tant que l'on ne cherche pas à utiliser les résultats (pas de hasNext() ou de next()), aucune transmission n'est effectuée vers le serveur et on peut donc modifier à loisir le curseur. Par contre, une fois que le curseur a été transmis au serveur, il est trop tard pour le modifier. Enfin précisons que que soit l'ordre dans lequel on a enchainé les méthodes (vous avez vu qu'on pouvait les chainer ?), l'ordre d'exécution sur le serveur est toujours sort(), puis skip(), puis limit().

Nous avons beaucoup parlé du R de CRUD, passons maintenant au U...

Mettre à jour les documents

La méthode update() de MongoDB possède quatre variantes. La première permet la mise à jour globale des documents:

db.people.update( {name: "Smith"}, {name: "Thompson", salary: 50000} )

Le premier paramètre correspond à la sélection, exactement comme pour find(). Le second indique quels seront les champs du document après la mise à jour. Dans l'exemple ci-dessus, nous allons donc récupérer tous les "Smith", et les remplacer par des "Thompson" dont le salaire est 50000. Tous les champs qui pouvaient exister précédemment sont perdus (sauf l'identifiant bien sûr), et seuls les champs précisés dans le second paramètre subsisteront après la mise à jour. Cette variante est assez dangereuse car c'est l'application qui décide de ce qui va composer le document, et tout ce qui n'est pas connu d'elle disparait.

La seconde variante permet une mise à jour sélective des champs. On utilise pour cela de nouveaux opérateurs. Le premier et probablement le plus utile est l'opérateur $set:

db.people.update( {name: "Smith"}, {$set: {salary: 50000}} )

Cette fois, seul le champ salaire sera modifié. S'il n'existait pas, il sera tout simplement créé.

On peut aussi utiliser l'opérateur $inc sur les champs entiers:

db.people.update( {name: "Alice"}, {$inc: {age: 1}} )

La pauvre Alice vient de prendre un an ! Et si elle n'avait pas encore d'âge ? Alors elle aurait un an, logique non ?

L'opérateur suivant, $unset, permet de supprimer un champ d'un document:

db.people.update( {name: "Bob"}, {$unset: {profession: 1}} )

La valeur donnée au champ à supprimer (1 ici), n'a aucun impact sur l'opération.

Voyons maintenant comment mettre à jour des champs de types complexes.

Mettre à jour un champ de type tableau

Là encore, plusieurs opérateurs sont disponibles pour nous permettre d'arriver à nos fins. Tout d'abord, l'opérateur $set, que nous avons déjà abordé  lors de la mise à jour sélective, peut être utilisé pour modifier une valeur d'un tableau:

db.people.update( {_id: 0}, {$set: {"loisirs.2": "camping"}})

Avec ceci, nous allons modifier la troisième valeur (le premier indice des tableaux est 0, comme de coutume) du tableau "loisirs" pour lui attribuer la valeur "camping".

Maintenant, si nous voulons ajouter une valeur au tableau des loisirs, nous utiliserons l'opérateur $push:

db.people.update( {_id: 0}, {$push: {loisirs: "yachting"}})

Inversement, l'opérateur $pop permet de retirer une valeur du tableau:

db.people.update( {_id: 0}, {$pop: {loisirs: 1}})

Si la valeur passée à l'opérateur est 1, comme ici, ce sera la dernière valeur qui sera supprimée, si c'est -1 ce sera la première valeur. Pas mal, mais ce serait encore mieux si l'on pouvait spécifier quel élément on veut retirer, n'est-ce-pas ? L'opérateur $pull est là pour çà :

db.people.update( {_id: 0}, {$pull: {loisirs: "cocooning"}})

Il est encore possible d'ajouter ou de supprimer plusieurs valeurs d'un coup à un tableau avec les opérateurs $pushAll et $pullAll qui ont la même syntaxe que les précédents, sauf qu'ils prennent un tableau de valeurs en paramètre.

db.people.update( {_id: 0}, {$pull: {loisirs: "cocooning"}})

Tous ces opérateurs ont en commun de traiter les tableaux comme une liste d'éléments. Si l'on ajoute plusieurs une valeur déjà dans le tableau, rien ne nous en empêche. Si l'on veut supprimer une valeur et qu'elle se trouve plusieurs fois dans le tableau, toutes les occurences seront supprimées.

Pour traiter le tableau comme un ensemble (c'est à dire sans doublon) lors de l'ajout, on peut utiliser l'opérateur $addToSet, dont la syntaxe est la même que $push mais qui n'ajoutera pas la valeur si elle est déjà présente dans le tableau.

Mises à jour de plusieurs documents

Les requêtes de mise à jour que nous avons vu jusqu'à présent ne touchaient intentionnellement qu'un seul document. Si nous souhaitons mettre à jour plusieurs documents, il ne faudra pas se contenter de spécifier une clause WHERE qui correspond à ces documents. Si nous le faisions, MongoDB ne ferait que mettre à jour le premier qu'il trouve ! Pour obtenir le comportement désiré, il faut passer un troisième argument à la méthode update(), comme dans cet exemple qui va donner un titre de docteur à tout le monde:

db.people.update( {}, {$set: {title: "Dr"}}, {multi: true})

Répétons-le, car c'est important: le comportement par défaut du driver javascript utilisé par le shell est de ne mettre à jour qu'un seul document. Si nous voulons mettre à jour plusieurs documents, il faut passer l'option multi. D'autres drivers peuvent opérer différemment en mettant à notre disposition deux méthodes, ce qui est clairement plus lisible.

Enfin, il est important de noter que MongoDB n'assurera jamais que la mise à jour multiple est une opération isolée, c'est-à-dire invisible des autres processus tant qu'elle n'est pas totalement terminée. En fait, la mécanique interne de MongoDB est ainsi faite qu'il est complètement possible que le processus de mise à jour traite une partie des documents, se mette en pause pour donner une chance à d'autres processus d'agir (et donc ces processus verront qu'une partie des documents a été modifiée), puis reprenne la main plus tard. Par contre une opération sur un seul document est toujours atomique et isolée: aucun processus concurrent ne verra jamais un document à moitié modifié.

Suppression de documents

Il existe plusieurs moyens de supprimer les documents d'une collection. Tout d'abord, la méthode remove() de la collection, qui là encore prend comme argument un objet qui représente une clause WHERE de la même manière que pour le find().

Si on ne donne aucun argument à la méthode remove(), tous les documents de la collection sont supprimés un par un. Mais ce n'est pas une méthode très efficace pour réinitialiser une collection. Mieux vaut dans ce cas utiliser la méthode drop() qui peut supprimer d'un seul coup tout l'espace alloué à cette collection et est donc beaucoup plus rapide. Mais attention, la méthode drop() supprime aussi les index qui existaient sur cette collection ! Il faudra donc les recréer après coup...

D'autre part, les remarques que nous avons fait sur la non isolation des transactions en cas de mise à jour de plusieurs documents valent aussi pour la suppression de plusieurs documents.

Connaitre le résultat d'une opération

Le shell n'est pas très bavard, et si une opération se passe bien, aucun message ne sera affiché. Par contre si une erreur se produit le shell va afficher le message correspondant. Si l'on souhaite en savoir plus sur le résultat de la dernière opération, on peut utiliser la commande getLastError (dont le nom devrait être getLastOperationStatus).

Qu'est-ce-qu'une commande ? En fait ce sont simplement des messages envoyés au serveur avec éventuellement des paramètres supplémentaires. Certaines APIs, comme le framework d'aggrégation, sont également disponibles sous forme de commande. Evidemment la syntaxe d'une commande est du coup plus lourde que l'utilisation directe d'une API. Le point d'entrée est la méthode runCommand() de la base de documents.

Nous voyons comment utiliser l'API runCommand() pour envoyer la commande getLastError sur l'exemple suivant:

Utilisation de la commande getLastError

Ici nous tentons tout d'abord d'insérer une nouvelle personne avec un identifiant dupliqué, et nous souhaitons obtenir le résultat de l'opération avec la commande getLastError (on expliquera ce que signifie le paramètre "1" passé à la commande lors de la semaine 6: Application Engineering). On voit que lorsqu'il y a eu erreur l'objet retourné par la commande contient pas mal d'informations:

  • err: message d'erreur (c'est celà qui est affiché par le shell quand une erreur s'est produite)
  • code: code d'erreur
  • n: nombre de documents existant dans la base qui ont été modifiés par la commande
  • connectionId: identifiant de la connexion. La commande retourne le statut de l'opération pour la dernière commande issue de la même connexion bien sûr, et non pas de la dernière commande reçue par le serveur quelle que soit la connexion.
  • ok: je ne sais pas... Faites-moi savoir si vous trouvez !

Dans le même exemple, on insère ensuite un nouveau document avec un identifiant qui ne produit pas d'erreur. On voit que dans ce cas le champ err est à nul, l'opération s'est donc bien passée.

Voyons deux autres exemples qui vont nous permettre de voir comment cette commande nous permet non seulement de savoir si l'opération a échoué ou non mais aussi de connaitre le détail de son résultat:

Utilisation de la commande getLastError

En premier lieu on tente de mettre à jour tous les documents de la collection people. Le résultat de getLastError nous permet effectivement de constater que deux documents préexistants ont été mis à jour, comme le confirme le champ updatedExisting.

La seconde requête nous permet de voir une option de la méthode update() dont nous n'avions encore pas parlé: l'upsert. Avec cette option, la méthode update créera un document s'il n'existe pas, et le mettra à jour s'il existe, d'où le nom (UPdate inSERT). Dans l'exemple, l'appel à getLastError nous apprend qu'un document a été créé, et nous donne l'identifiant généré.

La troisième et dernière requête est une simple suppression des documents de la collection et comme on pouvait s'y attendre, 3 documents ont été supprimés.

Nous en avons terminé avec le CRUD au sein du shell, voyons comment faire les même choses avec le driver Java.

CRUD en Java

Il faut tout d'abord comprendre comment nous allons passer les paramètres aux différentes méthodes que nous allons utiliser. En Javascript, nous utilisions des objets au format JSON, mais en Java ?

En première approche, on pourrait estimer que des Map<String, Object> sont suffisants (et c'est presque le cas), mais l'ordre des champs peut dans certains cas être important, il faut donc pouvoir préserver cet ordre. LinkedHashMap<String, Object> alors ? Les concepteurs de MongoDB ont fait un choix différent: ils proposent une interface DBOBject qui reprend la plupart des méthodes de l'interface Map, sans qu'il y ait aucune relation entre elles toutefois. Le driver fournit une implémentation de cette interface au moyen de la classe BasicDBObject, en utilisant d'ailleurs LinkedHashMap.

L'exemple suivant montre comment construire un tel objet, y compris avec des champs de type tableau et des sous-documents:

DBObject torrefacteur = new BasicDBObject();
torrefacteur.put("main", "Java");
torrefacteur.put("followers", 1000000);
torrefacteur.put("since", new Date(123456789));
torrefacteur.put("lazy", true);
torrefacteur.put("hobbies", Arrays.asList("pool", "jacuzzi"));
torrefacteur.put("somedoc", new BasicDBObject("field1", "value1).append("field2", value2));

On voit immédiatement que la manipulation est très proche de celle des Maps Java, que l'insertion de champs de type tableau n'est pas plus compliquée que l'insertion de types simples, et que les sous-documents sont simplement des champs de types DBObject eux-aussi.

On note également que l'on peut ajouter des champs dans un BasicDBObject avec une syntaxe classique de type put(), ou bien en chainant les ajouts avec la méthode append().

Une fois que l'on sait cela, il y a très peu de différences entre le CRUD Java et le CRUD shell. Les méthodes portent les mêmes noms, il faut juste passer des DBObject là où dans le shell on passait des objets JSON.

Le listing suivant donne un petit exemple récapitulatif de la création d'un client et de quelques manipulations simples:

MongoClient client = new MongoClient();
DB db = client.getDB("course");
DBCollection collection = db.getCollection("findTest");
collection.drop();

// Insertion de 10 documents avec des champs de valeurs aléatoires
Random rand = new Random(); for (int i = 0; i < 10; i++) {
DBObject o = new BasicDBObject("x", rand.nextInt(2));
o.put("y", rand.nextInt(100)); collection.insert(o); } DBObject one = collection.findOne(); System.out.println(one);
DBObject query = new BasicDBObject("x", 0)
.append("y", new BasicDBObject("$gt", 10).append("$lt",90)); DBCursor cursor = collection.find(query,
new BasicDBObject("y", true).append("_id", false))
.sort(new BasicDBObjet("y", -1)); try { while (cursor.hasNext()) { DBObject cur = cursor.next(); System.out.println(cur); } } finally { cursor.close(); } long count = collection.count(); System.out.println(count);

On remarque que l'on a bien pris soin de refermer le curseur retourné par find() même en cas d'exception. Le find() lui-même ne lancera pas d'exception, mais c'est bien l'appel à hasNext() ou next() qui est susceptible de le faire.

Peut-être êtes-vous désagréablement surpris par la construction du critère de recherche ? C'est très verbeux, pas forcément simple à lire et on utilise des chaines de caractères comme "$lt" en dur... Pour pallier à ces problèmes et nous assister dans la construction des requêtes, le driver Java met à notre disposition la classe QueryBuilder. Voici la même requête que dans l'exemple ci-dessus créée par ce moyen:

QueryBuilder builder = QueryBuilder.start("x").is(0)
  .and("y").greaterThan(10).lessThan(90);
...
DBCursor cursor = collection.find(builder.get());

C'est quand même mieux, même si une fois de plus je trouve que la méthode get() est bien mal nommée.

Je vous épargne le plus gros des méthodes update() et remove qui sont très semblables à celles du shell, tout comme le find(). Je ne parlerai donc que des différences.

La méthode update() du shell pouvait prendre un troisième argument optionnel: un objet qui spécifiait le comportement de la mise à jour avec les champs suivants:

  • multi: mise à jour de documents multiples (faux par défaut)
  • upsert: le document est créé s'il n'existe pas

Le principe est le même mais on ne va pas créer un DBObject pour passer ces paramètres. Il suffira de passer des booléens supplémentaires à la méthode update().

Enfin, le driver fournit la méthode findAndModify(), qui n'existe que sous forme de commande dans le shell. L'intérêt de cette méthode assez complexe (elle possède un nombre impressionnant d'arguments) est de faire une mise à jour et de retourner le résultat en une opération atomique. Autrement dit, c'est la fusion d'un update() et d'un find().

Pour illustrer on nous montre un cas concret d'utilisation: la création d'une pseudo-séquence.Je ne rentrerai pas dans le détail du code, mais le point important est que l'on a besoin de lire la valeur actuelle d'un compteur dans une collection, et d'incrémenter ce compteur de la valeur souhaitée. Mais il faut garantir que cet ensemble est atomique, sinon on risquerait de récupérer deux fois les mêmes numéros. C'est typiquement le genre de cas ou l'utilisation de findAndModify() est très pratique.

Conclusion

Nous voilà arrivés à la fin de la deuxième semaine du cours. Nous avons vu comment faire du CRUD avec MongoDB, d'abord dans le shell puis en Java (les différences sont minimes). Nous avons utilisé différents opérateurs pour construire des requêtes, et nous avons constaté qu'il existe des moyens pour manipuler simplement des documents complexes qui contiennent des tableaux ou des sous-documents. Nous nous sommes servis de curseurs pour itérer sur les résultats et nous avons utilisé la commande getLastError pour récupérer le résultat de la dernière opération.

La prochaine semaine de cours portera sur la conception d'un schéma MongoDB, ce qui promet d'être intéressant vu l'absence de transactions et de jointures. Restez avec moi pour la suite donc !

Commentaires

je te remecie cordialment

je te remecie cordialment ,vraiment un tutorial intéressant  :)

Bonne initiative

Je participe moi aussi à cette formation et ce n'est que ce week-end que j'aurai le temps de la suivre. Le programme est, sans surprise, très dense par rapport à la première. Merci pour cet avant-goût et hâte de pouvoir m'y mettre.