צינורות צבירה
חלק מהיחידה מסדי נתונים עם 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 משאיר חמישה פריטים כלשהם וממיין רק אותם.
אתגר
קלבשלב הזה נפתח פרויקט API מדף הספרים שהוכן בקורס, ולא את קובצי הפרויקט שלך מתוך Grouping with $group (כולל server.js). העבודה הקודמת שלך נשארת בשיעור משלה. קרא את הקבצים שסופקו לפני עריכת server.js. העותק הזה מכיל את הנתיבים של מדף הספרים ואת הסיכום של ספירת הז׳אנרים.
מדף הספרים צריך רשימת "הדירוגים הגבוהים ביותר": שלושת הספרים עם ממוצע הכוכבים הגבוה ביותר. כדי להיכלל ברשימה, ספר צריך לקבל לפחות שני דירוגים, כך שדירוג יחיד ומוצלח של 5 כוכבים לא יציב אותו בראש.
- ב־
server.js, הוסף נתיבGET /stats/top. הוא מריץ צבירה על האוסףratings, ולא עלbooks. - קבץ את הדירוגים לפי
bookId. כל קבוצה מקבלת שלושה שדות:_id, שהוא מזהה הספר;avg, הממוצע שלstarsשלו; ו־count, מספר הדירוגים שלו. שלב$groupאחד יכול לחשב גם אתavgוגם אתcount. - השאר רק את הקבוצות שבהן
countהוא 2 או יותר. הכלל הזה חל על הקבוצה של ספר, ולא על דירוג יחיד, ולכן השלב הזה בא אחרי הקיבוץ. - מיין את הקבוצות לפי
avg, מהגבוה לנמוך, והשאר את 3 הראשונות. - החזר את המערך עם סטטוס 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 (avg4.5,count2), ואחריו את Kindred (avg3,count2). 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');
});
השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.
כל השיעורים ביחידה מסדי נתונים עם MongoDB
תרגלו בעצמכם: Express אונליין