← Étape 07 · Sommaire · Étape suivante →
🎯 Objectif : modifier des documents avec les bons opérateurs, comprendre
upsert, manipuler des tableaux, et savoir quandreplace_oneest dangereux. ⏱️ Durée : 60 minutes. 📁 On crée :boutique/modifier.py.
from db import get_db
db = get_db()
resultat = db.produits.update_one(
{"sku": "KBD-0010"}, # 1. QUI : le filtre
{"$set": {"nom": "Clavier TKL silencieux (v2)"}, # 2. QUOI : les opérateurs
"$inc": {"stock": -1},
"$currentDate": {"maj_le": True}},
)
print(resultat.matched_count, resultat.modified_count) # 1 1Une mise à jour a toujours deux parties : le filtre (les mêmes opérateurs qu'à l'étape 07) et les modifications, exprimées avec des opérateurs $.
update_one ne modifie qu'un seul document, même si le filtre en matche plusieurs - le premier trouvé.
1. Oublier l'opérateur.
db.produits.update_one({"sku": "KBD-0010"}, {"nom": "Nouveau nom"})
# → WriteError: Update document requires atomic operatorsUne mise à jour est partielle : sans $set, MongoDB ne sait pas si vous voulez modifier un champ ou remplacer tout le document. Pour remplacer, c'est replace_one (§ 8.6).
2. Confondre matched_count et modified_count.
res = db.produits.update_one({"sku": "KBD-0010"}, {"$set": {"stock": 8}})
# Si le stock valait déjà 8 : matched_count = 1, modified_count = 0| Attribut | Répond à la question |
|---|---|
matched_count |
le document existe-t-il ? |
modified_count |
quelque chose a-t-il réellement changé ? |
upserted_id |
un document a-t-il été créé ? (voir § 8.4) |
Pour signaler "produit introuvable" à l'utilisateur, testez matched_count, jamais modified_count.
3. Croire qu'un filtre vide est sans danger.
db.produits.update_many({}, {"$set": {"actif": True}}) # touche TOUS les documentsC'est parfois exactement ce qu'on veut (§ 8.5), mais dans un script écrit à la va-vite, c'est un accident.
| Opérateur | Effet | Exemple |
|---|---|---|
$set |
définir un champ (le crée s'il n'existe pas) | {"$set": {"prix": Decimal128("99.00")}} |
$unset |
supprimer un champ | {"$unset": {"promo": ""}} |
$inc |
incrémenter (ou décrémenter) | {"$inc": {"stock": -1, "vues": 1}} |
$mul |
multiplier | {"$mul": {"prix": 0.9}} |
$min / $max |
n'écrire que si plus petit / plus grand | {"$max": {"record": 42}} |
$rename |
renommer un champ | {"$rename": {"libelle": "nom"}} |
$currentDate |
horodater | {"$currentDate": {"maj_le": True}} |
$push |
ajouter à un tableau | {"$push": {"avis": {...}}} |
$addToSet |
ajouter sans doublon | {"$addToSet": {"tags": "promo"}} |
$pull |
retirer d'un tableau | {"$pull": {"tags": "promo"}} |
$pop |
retirer le premier (-1) ou le dernier (1) |
{"$pop": {"avis": -1}} |
# L'abonnement n'a PAS de champ tags
db.produits.update_one({"sku": "ABO-0013"}, {"$addToSet": {"tags": "service"}})
# → le tableau est créé avec l'élément dedans
# Le livre n'a PAS de champ stock
db.produits.update_one({"sku": "LIV-0014"}, {"$inc": {"stock": 5}})
# → le champ est créé à 5 (0 + 5)$set, $inc, $push et $addToSet créent le champ s'il est absent. C'est ce qui rend les évolutions de schéma triviales en MongoDB… et ce qui permet aussi de créer discrètement des incohérences : rien ne vous empêche d'ajouter stock à un abonnement, où le concept n'a pas de sens.
resultat = db.produits.update_many(
{"categorie": "clavier"},
{"$addToSet": {"tags": "promo"}, "$mul": {"prix": 0.9}},
)
print(f"{resultat.modified_count} claviers en promotion")C'est l'outil des évolutions de schéma. Exemple typique : ajouter un champ à tous les documents qui ne l'ont pas encore.
db.produits.update_many(
{"actif": {"$exists": False}}, # 👈 seulement ceux à qui il manque
{"$set": {"actif": True}},
)
update_manyn'est pas atomique dans son ensemble. Chaque document est modifié atomiquement, mais un lecteur concurrent peut voir un état intermédiaire - certains documents modifiés, d'autres non. Pour une atomicité globale, il faut une transaction (survolée à l'étape 13), qui exige un replica set.
resultat = db.produits.update_one(
{"sku": "NEW-0099"},
{"$set": {"nom": "Produit tout neuf", "categorie": "divers"},
"$setOnInsert": {"cree_le": datetime.now(timezone.utc)}},
upsert=True,
)
print(resultat.upserted_id) # ObjectId si créé, None si simplement mis à jourLe document créé est composé du filtre d'égalité + des opérateurs : le sku du filtre se retrouve donc dans le document, sans qu'on ait à le répéter.
$setOnInsert n'est appliqué qu'à la création. C'est ce qui permet à un import réexécutable de ne pas écraser la date de création à chaque passage - vous l'avez déjà utilisé dans seed.py.
⚠️ Un upsert avec un filtre non trivial peut créer des documents inattendus.update_one({"prix": {"$lt": 100}}, {"$set": {...}}, upsert=True)sur une collection vide crée un document sans prix : seules les égalités du filtre sont reprises. Un upsert se fait sur une clé d'identité (sku,_id, e-mail), jamais sur un critère de recherche.
from datetime import datetime, timezone
# Ajouter un avis
db.produits.update_one(
{"sku": "KBD-0010"},
{"$push": {"avis": {"auteur": "alice", "note": 5, "texte": "Parfait",
"date": datetime.now(timezone.utc)}}},
)
# Ajouter un tag sans créer de doublon
db.produits.update_one({"sku": "KBD-0010"}, {"$addToSet": {"tags": "best-seller"}})
# Retirer un tag
db.produits.update_one({"sku": "KBD-0010"}, {"$pull": {"tags": "promo"}})Un tableau qui grossit sans limite finit par heurter les 16 Mo du document - et ralentit chaque lecture bien avant. On garde les N derniers éléments :
db.produits.update_one(
{"sku": "KBD-0010"},
{"$push": {"avis": {
"$each": [{"auteur": "bob", "note": 4, "date": datetime.now(timezone.utc)}],
"$sort": {"date": -1}, # les plus récents d'abord
"$slice": 10, # on n'en garde que 10
}}},
)# Marquer tous les avis dont la note est ≤ 2
db.produits.update_one(
{"sku": "KBD-0010"},
{"$set": {"avis.$[a].signale": True}},
array_filters=[{"a.note": {"$lte": 2}}],
)$[a] désigne "chaque élément qui satisfait le filtre nommé a". Sans array_filters, on ne peut modifier que le premier élément trouvé, avec l'opérateur positionnel $.
db.produits.replace_one(
{"sku": "MSE-0012"},
{"sku": "MSE-0012", "nom": "Souris verticale MK2", "categorie": "souris",
"prix": Decimal128("84.00"), "stock": 10},
)replace_one remplace intégralement le contenu, sauf l'_id. Tous les champs absents du nouveau document disparaissent : ici, poids_g, sans_fil et cree_le sont perdus.
update_one + $set |
replace_one |
|
|---|---|---|
| Analogie HTTP | PATCH |
PUT |
| Champs non mentionnés | conservés | supprimés |
| Usage typique | 99 % des cas | import d'un document complet venu d'ailleurs |
# ❌ Écrase les modifications faites par quelqu'un d'autre entre-temps
doc = db.produits.find_one({"sku": "KBD-0010"})
doc["prix"] = Decimal128("109.00")
db.produits.replace_one({"sku": "KBD-0010"}, doc)Entre la lecture et l'écriture, un autre processus a pu changer le stock : votre réécriture le remet à la valeur que vous aviez lue. C'est le lost update, et il est invisible en test à un seul utilisateur.
Deux solutions :
# ✅ Solution 1 : ne modifier que le champ concerné
db.produits.update_one({"sku": "KBD-0010"}, {"$set": {"prix": Decimal128("109.00")}})
# ✅ Solution 2 : verrouillage optimiste, si vous devez vraiment remplacer
res = db.produits.replace_one(
{"_id": doc["_id"], "version": doc["version"]},
{**nouveau, "version": doc["version"] + 1},
)
if res.matched_count == 0:
raise RuntimeError("le document a été modifié entre-temps")from pymongo import ReturnDocument
doc = db.compteurs.find_one_and_update(
{"_id": "commandes"},
{"$inc": {"seq": 1}},
upsert=True,
return_document=ReturnDocument.AFTER, # ou BEFORE (défaut)
)
numero_commande = doc["seq"]C'est la primitive à connaître pour tout ce qui ressemble à un compteur, une réservation ou une file de tâches. Elle remplace le classique "je lis, je décide, j'écris" qui perd des mises à jour dès qu'il y a deux utilisateurs.
L'exemple qui parle à tout le monde - réserver du stock sans jamais passer sous zéro, sans transaction :
produit = db.produits.find_one_and_update(
{"sku": "KBD-0010", "stock": {"$gte": 2}}, # 👈 la condition est DANS le filtre
{"$inc": {"stock": -2}},
return_document=ReturnDocument.AFTER,
)
if produit is None:
raise ValueError("stock insuffisant")
print("stock restant :", produit["stock"])Le secret est là : la condition métier fait partie du filtre. Si deux clients tentent la même réservation en même temps, un seul verra son filtre matcher - l'autre obtient None. Un document est toujours modifié atomiquement, quelle que soit sa profondeur ; c'est la garantie la plus utile de MongoDB.
Variantes : find_one_and_replace, find_one_and_delete.
- Augmentez de 10 % le prix de tous les écrans (attention aux types :
$mulsur unDecimal128fonctionne, sur une chaîne non). - Ajoutez le champ
actif: Trueuniquement aux produits qui ne l'ont pas. - Ajoutez deux avis au clavier
KBD-0010, en ne conservant que les 3 plus récents. - Écrivez
reserver(sku, quantite)qui décrémente le stock de façon atomique et lève une exception si le stock est insuffisant ou si le produit n'est pas géré en stock (champ absent). - Renommez le champ
hzenfrequence_hzsur tous les écrans.
Voir la correction
from datetime import datetime, timezone
from pymongo import ReturnDocument
from db import get_db
db = get_db()
# 1
db.produits.update_many({"categorie": "ecran"}, {"$mul": {"prix": 1.10}})
# 2
db.produits.update_many({"actif": {"$exists": False}}, {"$set": {"actif": True}})
# 3
db.produits.update_one(
{"sku": "KBD-0010"},
{"$push": {"avis": {
"$each": [
{"auteur": "alice", "note": 5, "date": datetime.now(timezone.utc)},
{"auteur": "bob", "note": 4, "date": datetime.now(timezone.utc)},
],
"$sort": {"date": -1},
"$slice": 3,
}}},
)
# 4
def reserver(sku: str, quantite: int) -> dict:
doc = db.produits.find_one_and_update(
{"sku": sku, "stock": {"$gte": quantite}},
{"$inc": {"stock": -quantite}},
return_document=ReturnDocument.AFTER,
)
if doc is None:
# deux causes possibles : stock insuffisant, ou champ stock absent
existe = db.produits.count_documents({"sku": sku}) == 1
raise ValueError("stock insuffisant" if existe else "produit inconnu")
return doc
# 5
db.produits.update_many({"hz": {"$exists": True}}, {"$rename": {"hz": "frequence_hz"}})Le point de la question 4 : find_one_and_update ne peut pas distinguer "produit inconnu", "stock insuffisant" et "pas de champ stock" - dans les trois cas, le filtre ne matche pas et vous obtenez None. Si vous devez donner un message précis, faites une seconde requête de diagnostic, seulement dans le cas d'échec. Ne renversez jamais la logique en vérifiant avant : ce serait rouvrir la fenêtre de concurrence que find_one_and_update vient justement de fermer.
Le point de la question 5 : $rename ne touche que les documents où le champ existe - les claviers et les livres ne sont pas concernés. Après cette opération, si une partie de votre code cherche encore hz, elle ne trouvera plus rien, silencieusement. Toute renommage de champ en production se fait en trois temps : (1) l'application écrit et lit les deux noms, (2) on migre les documents existants, (3) on retire l'ancien nom du code. C'est le sujet de l'étape suivante sur le schéma souple.
- Une mise à jour = un filtre + des opérateurs (
$set,$inc,$push…). Sans opérateur : erreur. matched_countrépond "le document existe-t-il ?",modified_count"a-t-il changé ?".$set,$inc,$addToSetcréent les champs absents : c'est ainsi qu'on fait évoluer un schéma.upsert=True+$setOnInsert= l'idiome de l'import réexécutable ; on n'upsert que sur une clé d'identité.replace_onesupprime les champs non fournis ; préférez$set, sinon utilisez un verrouillage optimiste.find_one_and_updateavec la condition métier dans le filtre rend une opération atomique sans transaction.