הטמעה או הפניה
חלק מהיחידה מסדי נתונים עם MongoDB במסלול ה-Node.js של Coddy. שיעור 29 מתוך 29.
ביקורות ודירוגים שייכים שניהם לספר, והמקום הטוב ביותר לאחסון כל אחד מהם שונה.
הטמעה מציבה נתונים קשורים בתוך המסמך, כך שקריאה אחת מחזירה את הספר ואת הביקורות שלו. היא מתאימה לנתונים שנקראים יחד עם פריט האב שלהם ונשארים קטנים. הפניה שומרת את הנתונים באוסף משלהם, עם המזהה של פריט האב, כפי שעושה ratings. היא מתאימה לנתונים שגדלים ללא הגבלה או שמתבצעת עליהם שאילתה בנפרד, כמו ממוצע של כל הדירוגים.
מסמך יכול להכיל לכל היותר 16 MB, לכן יש להגביל את גודל המערך המוטמע. בתוך $push, הערכים ב-$each הם הערכים שיש להוסיף, ו-$slice: -3 משאיר את שלושת האחרונים.
await players.updateOne(
{ name: 'Ada' },
{ $push: { recentScores: { $each: [120], $slice: -3 } } }
);אתגר
קלביקורות קצרות, תמיד מוצגות לצד הספר שלהן, ובספר מוצגות רק 5 הביקורות האחרונות. לכן שמור אותן בתוך מסמך הספר, במערך reviews, ולעולם אל תיתן למערך הזה לגדול מעבר ל־5.
- ב־
server.js, הוסף אתPOST /books/:id/reviews. גוף הבקשה נראה כך:{ "user": "ada", "text": "Loved it" }. השתמש ב־parseIdעבור מזהה לא תקין, שמחזיר 400. - בעדכון אחד, הוסף את הביקורת, למשל
{ "user": "ada", "text": "Loved it" }, לסוף מערךreviewsשל הספר. באותו עדכון, קצר את המערך כך שיכלול רק את 5 הפריטים האחרונים, כדי שהביקורת הישנה ביותר תוסר. ספר ללא ביקורות יקבל מערך חדש. - כשאין ספר עם
_idהזה, החזר 404. - אחרת, החזר סטטוס 201 ורק את מערך
reviewsשל הספר, ולא את הספר כולו, כשהביקורת הישנה ביותר ראשונה. אחרי הביקורת הראשונה, התגובה היא:נסה זאת: שלח[ { "user": "ada", "text": "Loved it" } ]GET /booksוהעתק את_idשל Dune. שלחPOST /books/<Dune's _id>/reviewsעם{ "user": "ada", "text": "Loved it" }: תקבל 201 ואת המערך שלמעלה. שלח חמש ביקורות נוספות, עם הטקסטים"Review 2"עד"Review 6". התגובה האחרונה מכילה חמש ביקורות, מ־Review 2עדReview 6, ו־Loved itהוסרה. שלחGET /books/<Dune's _id>: ל־Dune יש את אותן חמש ביקורות בתוךreviews, והכרטיסייה Data מציגה אותן בתוך הספר.
נסו בעצמכם
import express from 'express';
import { z } from 'zod';
import { ObjectId } from 'mongodb';
import { books, ratings } from './db.js';
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;
}
const BookInput = z.object({
title: z.string().min(1),
author: z.string().min(1),
year: z.number().int(),
isbn: z.string().min(1),
genre: z.string().min(1),
});
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 page = Number(req.query.page) || 1;
const limit = Number(req.query.limit) || 10;
const items = await books
.find(filter)
.skip((page - 1) * limit)
.limit(limit)
.toArray();
const total = await books.countDocuments(filter);
res.json({ items, total });
});
app.post('/books', async (req, res) => {
const parsed = BookInput.safeParse(req.body);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return sendError(res, 400, `${issue.path.join('.')}: ${issue.message}`);
}
const book = parsed.data;
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.get('/stats/top', async (req, res) => {
const top = await ratings
.aggregate([
{ $group: { _id: '$bookId', avg: { $avg: '$stars' }, count: { $sum: 1 } } },
{ $match: { count: { $gte: 2 } } },
{ $sort: { avg: -1 } },
{ $limit: 3 },
])
.toArray();
res.json(top);
});
app.get('/books/:id/full', async (req, res) => {
const id = parseId(req, res);
if (!id) return;
const [book] = await books
.aggregate([
{ $match: { _id: id } },
{
$lookup: {
from: 'authors',
localField: 'author',
foreignField: 'name',
as: 'authorInfo',
},
},
])
.toArray();
if (!book) {
return sendError(res, 404, 'No such book');
}
res.json(book);
});
app.listen(3000, () => {
console.log('Bookshelf is running on port 3000');
});
השיעור הזה כולל חידון קצר. התחילו את השיעור כדי לענות עליו ולעקוב אחרי ההתקדמות.
כל השיעורים ביחידה מסדי נתונים עם MongoDB
תרגלו בעצמכם: Express אונליין