← Cours BD05

🔍 Exercices BD05 — MongoDB Avancé & Agrégations

5 exercices · Solutions masquées

Exercice 1 — Pipeline : analyse des ventes

À partir d'une collection orders (userId, items[], total, status, createdAt), écrivez un pipeline qui retourne : CA mensuel, nombre de commandes, panier moyen, et variation mensuelle en %.

Voir la solution
const result = await db.collection('orders').aggregate([
  { $match: { status: 'completed' } },
  {
    $group: {
      _id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
      revenue: { $sum: '$total' },
      count:   { $sum: 1 },
      avgCart: { $avg: '$total' }
    }
  },
  { $sort: { _id: 1 } },
  {
    $setWindowFields: {
      sortBy: { _id: 1 },
      output: {
        prevRevenue: {
          $shift: { output: '$revenue', by: -1 }
        }
      }
    }
  },
  {
    $addFields: {
      growth: {
        $cond: {
          if: { $gt: ['$prevRevenue', 0] },
          then: {
            $multiply: [
              { $divide: [{ $subtract: ['$revenue', '$prevRevenue'] }, '$prevRevenue'] },
              100
            ]
          },
          else: null
        }
      }
    }
  },
  { $project: { month: '$_id', revenue: 1, count: 1, avgCart: { $round: ['$avgCart', 2] }, growth: { $round: ['$growth', 1] }, _id: 0 } }
]).toArray();

Exercice 2 — $lookup et jointure multi-collections

Créez un rapport "top 5 produits les plus commandés" en joignant order_items avec products et categories. Le résultat doit inclure : nom du produit, catégorie, total commandé, CA généré.

Voir la solution
const topProducts = await db.collection('order_items').aggregate([
  {
    $group: {
      _id: '$productId',
      totalQty: { $sum: '$qty' },
      revenue:  { $sum: { $multiply: ['$price', '$qty'] } },
      nbOrders: { $sum: 1 }
    }
  },
  { $sort: { totalQty: -1 } },
  { $limit: 5 },
  {
    $lookup: {
      from: 'products',
      localField: '_id',
      foreignField: '_id',
      as: 'product'
    }
  },
  { $unwind: '$product' },
  {
    $lookup: {
      from: 'categories',
      localField: 'product.categoryId',
      foreignField: '_id',
      as: 'category'
    }
  },
  { $unwind: { path: '$category', preserveNullAndEmpty: true } },
  {
    $project: {
      productName:  '$product.name',
      categoryName: '$category.name',
      totalQty: 1,
      revenue: { $round: ['$revenue', 2] },
      nbOrders: 1,
      _id: 0
    }
  }
]).toArray();

Exercice 3 — $unwind et statistiques par tags

Sur une collection articles avec un champ tags[], écrivez un pipeline qui retourne : pour chaque tag, le nombre d'articles, le nombre moyen de vues, et les 3 articles les plus lus.

Voir la solution
const tagStats = await db.collection('articles').aggregate([
  { $match: { published: true } },
  { $unwind: '$tags' },
  {
    $group: {
      _id: '$tags',
      articleCount: { $sum: 1 },
      avgViews: { $avg: '$views' },
      articles: {
        $push: { title: '$title', views: '$views', _id: '$_id' }
      }
    }
  },
  { $sort: { articleCount: -1 } },
  {
    $project: {
      tag: '$_id',
      articleCount: 1,
      avgViews: { $round: ['$avgViews', 0] },
      top3: {
        $slice: [
          { $sortArray: { input: '$articles', sortBy: { views: -1 } } },
          3
        ]
      },
      _id: 0
    }
  }
]).toArray();

Exercice 4 — Transaction MongoDB

Implémentez un virement entre deux comptes bancaires MongoDB (collection accounts). La transaction doit : vérifier le solde, débiter l'expéditeur, créditer le destinataire, créer une entrée transactions. Rollback si solde insuffisant.

Voir la solution
const { MongoClient, ObjectId } = require('mongodb');

async function transfer(fromAccountId, toAccountId, amount, description = '') {
  const client = new MongoClient(process.env.MONGODB_URI);
  await client.connect();
  const db = client.db('banking');
  const session = client.startSession();

  try {
    session.startTransaction({
      readConcern: { level: 'snapshot' },
      writeConcern: { w: 'majority' }
    });

    const accounts = db.collection('accounts');

    const sender = await accounts.findOne(
      { _id: new ObjectId(fromAccountId) }, { session }
    );
    if (!sender || sender.balance < amount) {
      throw new Error(`Solde insuffisant: ${sender?.balance ?? 0} < ${amount}`);
    }

    await accounts.updateOne(
      { _id: new ObjectId(fromAccountId) },
      { $inc: { balance: -amount }, $currentDate: { updatedAt: true } },
      { session }
    );
    await accounts.updateOne(
      { _id: new ObjectId(toAccountId) },
      { $inc: { balance: amount }, $currentDate: { updatedAt: true } },
      { session }
    );

    const txn = await db.collection('transactions').insertOne({
      fromAccountId: new ObjectId(fromAccountId),
      toAccountId: new ObjectId(toAccountId),
      amount, description, status: 'completed',
      createdAt: new Date()
    }, { session });

    await session.commitTransaction();
    return { txnId: txn.insertedId, success: true };
  } catch (err) {
    await session.abortTransaction();
    throw err;
  } finally {
    await session.endSession();
    await client.close();
  }
}

Exercice 5 — Change Stream : alertes en temps réel

Implémentez un système d'alertes qui écoute la collection inventory et déclenche une alerte quand le stock d'un produit tombe en dessous de 5. Gérez la reconnexion en cas d'erreur en sauvegardant le resume token.

Voir la solution
let resumeToken = null;

async function watchInventory(db) {
  const collection = db.collection('inventory');

  const pipeline = [
    {
      $match: {
        operationType: { $in: ['update', 'replace'] },
        'updateDescription.updatedFields.stock': { $exists: true }
      }
    }
  ];

  const options = resumeToken
    ? { resumeAfter: resumeToken, fullDocument: 'updateLookup' }
    : { fullDocument: 'updateLookup' };

  const changeStream = collection.watch(pipeline, options);

  changeStream.on('change', async (change) => {
    resumeToken = change._id; // sauvegarder pour reconnexion

    const doc = change.fullDocument;
    if (doc && doc.stock < 5) {
      await sendLowStockAlert({
        productId: doc._id,
        productName: doc.name,
        currentStock: doc.stock,
        threshold: 5
      });
    }
  });

  changeStream.on('error', (err) => {
    console.error('Change stream error:', err.message);
    changeStream.close();
    // Reconnexion avec exponential backoff
    setTimeout(() => watchInventory(db), 5000);
  });

  process.on('SIGINT', () => changeStream.close());
  return changeStream;
}

async function sendLowStockAlert(info) {
  console.warn(`[ALERT] Stock bas: ${info.productName} → ${info.currentStock} unités`);
  // Intégration : Redis Pub/Sub, email, Slack...
}