Menu
CoddyTech

Einbetten oder referenzieren

Teil des Abschnitts Datenbanken mit MongoDB der Node.js-Journey von Coddy. Lektion 29 von 29.

Bewertungen und Rezensionen gehören beide zu einem Buch, doch der beste Speicherort für jede davon ist unterschiedlich.

Einbetten legt zusammengehörige Daten im Dokument ab, sodass ein Lesevorgang das Buch und seine Rezensionen abruft. Das eignet sich für Daten, die zusammen mit ihrem übergeordneten Element gelesen werden und klein bleiben. Referenzieren speichert die Daten in einer eigenen Collection mit der ID des übergeordneten Elements, wie es ratings tut. Das eignet sich für Daten, die unbegrenzt wachsen oder separat abgefragt werden, etwa für den Durchschnitt aller Bewertungen.

Ein Dokument kann höchstens 16 MB groß sein, daher braucht ein eingebettetes Array eine Obergrenze. Innerhalb von $push führt $each die hinzuzufügenden Werte auf, und $slice: -3 behält die letzten drei.

await players.updateOne(
    { name: 'Ada' },
    { $push: { recentScores: { $each: [120], $slice: -3 } } }
);
challenge icon

Aufgabe

Einfach

Bewertungen sind kurz, werden immer zusammen mit ihrem Buch angezeigt, und ein Buch zeigt nur seine 5 neuesten Bewertungen. Bewahre sie daher im Buchdokument in einem reviews-Array auf und lass dieses Array niemals auf mehr als 5 Einträge anwachsen.

  1. Füge in server.js POST /books/:id/reviews hinzu. Der Body sieht so aus: { "user": "ada", "text": "Loved it" }. Verwende bei einer ungültigen ID parseId; die Antwort lautet dann 400.
  2. Füge die Bewertung mit einer einzigen Aktualisierung, zum Beispiel { "user": "ada", "text": "Loved it" }, am Ende des reviews-Arrays des Buches hinzu. Kürze das Array in derselben Aktualisierung auf seine letzten 5 Einträge, sodass die älteste Bewertung herausfällt. Bei einem Buch ohne Bewertungen wird ein neues Array angelegt.
  3. Wenn kein Buch diese _id hat, antworte mit 404.
  4. Antworte andernfalls mit dem Status 201 und nur mit dem reviews-Array des Buches, nicht mit dem gesamten Buch; die älteste Bewertung steht dabei zuerst. Nach der ersten Bewertung lautet die Antwort:
    [ { "user": "ada", "text": "Loved it" } ]
    Probiere es aus: Sende GET /books und kopiere Dunes _id. Sende POST /books/<Dune's _id>/reviews mit { "user": "ada", "text": "Loved it" }: Du erhältst den Status 201 und das obige Array. Sende fünf weitere Bewertungen mit den Texten "Review 2" bis "Review 6". Die letzte Antwort enthält fünf Bewertungen, Review 2 bis Review 6; Loved it ist verschwunden. Sende GET /books/<Dune's _id>: Dune enthält dieselben fünf Bewertungen in reviews, und der Tab „Daten“ zeigt sie im Buch an.

Probier es selbst

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());

// Jeder Fehler liefert { error: message } zurück.
function sendError(res, status, message) {
    res.status(status).json({ error: message });
}

// Die :id als ObjectId oder null, nachdem bei einer ungültigen id mit 400 geantwortet wurde.
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');
});
quiz iconTeste dich selbst

Diese Lektion enthält ein kurzes Quiz. Starte die Lektion, um es zu beantworten und deinen Fortschritt zu speichern.

Alle Lektionen in Datenbanken mit MongoDB

Übe selbstständig: Express-Playground