Menu
CoddyTech

צינורות צבירה

חלק מהיחידה מסדי נתונים עם MongoDB במסלול ה-Node.js של Coddy. שיעור 24 מתוך 29.

רשימה של ״הדירוג הגבוה ביותר״ זקוקה לממוצעים, למספר מינימלי של דירוגים, לסדר ולחיתוך. כל אחד מאלה הוא שלב.

$match משאיר את המסמכים שעוברים מסנן, בדומה ל־find. $avg מחשב ממוצע של שדה בתוך $group. השלבים מתבצעים לפי הסדר שבו כתבת אותם, וכל שלב רואה רק את מה שהשלב שלפניו העביר. $match לפני $group מסנן את המסמכים המאוחסנים. $match אחרי $group מסנן את הקבוצות לפי שדות שהקבוצה יצרה, כמו ב־{ $match: { orders: { $gte: 3 } } }. $group אחד יכול ליצור כמה שדות בו־זמנית, למשל spent: { $sum: '$total' } וגם orders: { $sum: 1 }.

await orders.aggregate([
    { $match: { status: 'paid' } },
    { $group: { _id: '$customer', spent: { $sum: '$total' } } },
    { $sort: { spent: -1 } },
    { $limit: 5 },
]).toArray();

$sort ו־$limit מקבלים אותם ארגומנטים כמו בסמן, אבל צינור עיבוד מריץ אותם לפי הסדר שבו כתבת אותם. $limit לפני $sort משאיר חמישה פריטים כלשהם וממיין רק אותם.

challenge icon

אתגר

קל

בשלב הזה נפתח פרויקט API מדף הספרים שהוכן בקורס, ולא את קובצי הפרויקט שלך מתוך Grouping with $group (כולל server.js). העבודה הקודמת שלך נשארת בשיעור משלה. קרא את הקבצים שסופקו לפני עריכת server.js. העותק הזה מכיל את הנתיבים של מדף הספרים ואת הסיכום של ספירת הז׳אנרים.

מדף הספרים צריך רשימת "הדירוגים הגבוהים ביותר": שלושת הספרים עם ממוצע הכוכבים הגבוה ביותר. כדי להיכלל ברשימה, ספר צריך לקבל לפחות שני דירוגים, כך שדירוג יחיד ומוצלח של 5 כוכבים לא יציב אותו בראש.

  1. ב־server.js, הוסף נתיב GET /stats/top. הוא מריץ צבירה על האוסף ratings, ולא על books.
  2. קבץ את הדירוגים לפי bookId. כל קבוצה מקבלת שלושה שדות: _id, שהוא מזהה הספר; avg, הממוצע של stars שלו; ו־count, מספר הדירוגים שלו. שלב $group אחד יכול לחשב גם את avg וגם את count.
  3. השאר רק את הקבוצות שבהן count הוא 2 או יותר. הכלל הזה חל על הקבוצה של ספר, ולא על דירוג יחיד, ולכן השלב הזה בא אחרי הקיבוץ.
  4. מיין את הקבוצות לפי avg, מהגבוה לנמוך, והשאר את 3 הראשונות.
  5. החזר את המערך עם סטטוס 200. בכל רשומה יש בדיוק את שלושת השדות האלה:
    [
      { "_id": "6720a1f4c2b9e81d3a5f0c11", "avg": 4.5, "count": 2 },
      { "_id": "6720a1f4c2b9e81d3a5f0c12", "avg": 3, "count": 2 }
    ]
    _id הוא ה־_id של הספר, כך שלקוח יכול לאחזר את הספר באמצעות GET /books/ בצירוף המזהה הזה. אם פחות משלושה ספרים קיבלו שני דירוגים, המערך יהיה קצר יותר. נסה זאת: בכל הרצה, ratings מתרוקן, לכן שלח את הבקשות האלה אחרי ההרצה האחרונה שלך. שלח GET /books והעתק את _id של Dune, Kindred ו־Emma. שלח PUT /books/<Dune's _id>/ratings עם { "user": "ada", "stars": 5 }, ואז שוב עם { "user": "bo", "stars": 4 }. דרג את Kindred באותו אופן, עם 3 כוכבים מ־ada ו־3 מ־bo. דרג את Emma פעם אחת, עם { "user": "ada", "stars": 5 }. שלח GET /stats/top: תקבל תחילה את Dune (avg 4.5, count 2), ואחריו את Kindred (avg 3, count 2). Emma לא מופיעה, כי יש לה רק דירוג אחד. אם Emma מופיעה ראשונה, מסנן הספירה שלך רץ לפני הקיבוץ, או אינו רץ כלל.

נסו בעצמכם

import express from 'express';
import { MongoClient, ObjectId } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('shelf');
const books = db.collection('books');
const ratings = db.collection('ratings');

await books.createIndex({ isbn: 1 }, { unique: true });
await books.insertMany([
    { title: 'Dune', author: 'Frank Herbert', year: 1965, isbn: '9780441013593', genre: 'scifi' },
    { title: 'Kindred', author: 'Octavia E. Butler', year: 1979, isbn: '9780807083697', genre: 'scifi' },
    { title: 'Emma', author: 'Jane Austen', year: 1815, isbn: '9780141439587', genre: 'classic' },
]);

const app = express();
app.use(express.json());

// כל שגיאה מחזירה { error: message }.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// החזרת :id בתור ObjectId, או null לאחר החזרת 400 עבור id לא תקין.
function parseId(req, res) {
    if (ObjectId.isValid(req.params.id)) {
        return new ObjectId(req.params.id);
    }
    sendError(res, 400, 'Not a valid id');
    return null;
}

app.get('/', (req, res) => {
    res.send('Bookshelf API');
});

app.get('/books', async (req, res) => {
    const filter = {};
    if (req.query.author) {
        filter.author = req.query.author;
    }
    if (req.query.from || req.query.to) {
        filter.year = {};
        if (req.query.from) {
            filter.year.$gte = Number(req.query.from);
        }
        if (req.query.to) {
            filter.year.$lte = Number(req.query.to);
        }
    }
    const list = await books.find(filter).toArray();
    res.json(list);
});

app.post('/books', async (req, res) => {
    const book = req.body;
    try {
        await books.insertOne(book);
    } catch (err) {
        if (err.code === 11000) {
            return sendError(res, 409, 'That ISBN is already on the shelf');
        }
        throw err;
    }
    res.status(201).json(book);
});

app.get('/books/newest', async (req, res) => {
    const n = Number(req.query.n) || 3;
    const list = await books
        .find({}, { projection: { title: 1, year: 1, _id: 0 } })
        .sort({ year: -1 })
        .limit(n)
        .toArray();
    res.json(list);
});

app.get('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const book = await books.findOne({ _id: id });
    if (!book) {
        return sendError(res, 404, 'No such book');
    }
    res.json(book);
});

app.patch('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $set: req.body });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/copies', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $inc: { copies: 1 } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.post('/books/:id/tags', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.updateOne({ _id: id }, { $push: { tags: req.body.tag } });
    if (result.matchedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.json(await books.findOne({ _id: id }));
});

app.delete('/books/:id', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const result = await books.deleteOne({ _id: id });
    if (result.deletedCount === 0) {
        return sendError(res, 404, 'No such book');
    }
    res.status(204).end();
});

app.put('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    const { user, stars } = req.body;
    await ratings.updateOne({ bookId: id, user }, { $set: { stars } }, { upsert: true });
    res.json({ user, stars });
});

app.get('/books/:id/ratings', async (req, res) => {
    const id = parseId(req, res);
    if (!id) return;
    res.json(await ratings.find({ bookId: id }).toArray());
});

app.get('/stats', async (req, res) => {
    const filter = req.query.genre ? { genre: req.query.genre } : {};
    res.json({
        books: await books.countDocuments(filter),
        ratings: await ratings.countDocuments(),
    });
});

app.get('/stats/genres', async (req, res) => {
    const genres = await books
        .aggregate([{ $group: { _id: '$genre', count: { $sum: 1 } } }])
        .toArray();
    res.json(genres);
});

app.listen(3000, () => {
    console.log('Bookshelf is running on port 3000');
});
quiz iconבחנו את עצמכם

השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.

כל השיעורים ביחידה מסדי נתונים עם MongoDB

תרגלו בעצמכם: Express אונליין