Menu
CoddyTech

التحقق قبل الحفظ

جزء من قسم قواعد البيانات باستخدام MongoDB في رحلة Node.js على Coddy. الدرس 28 من 29.

يخزّن MongoDB أي كائن يُعطى له. عندما يُمرَّر req.body مباشرةً إلى insertOne، يمكن للعميل حفظ سنة بقيمة "soon" أو حقول إضافية مثل "admin": true.

تحقّق من صحة البيانات باستخدام zod قبل الإدراج، وأدرج ما تُعيده zod، لا req.body. يُسقط مخطط z.object كل مفتاح لا يدرجه، لذلك لا تحتوي البيانات المُحلَّلة إلا على الحقول التي تسمح بها.

const Player = z.object({
    name: z.string().min(1),
    level: z.number().int().min(1),
});

const parsed = Player.safeParse({ name: 'Ada', level: 2, admin: true });
parsed.data; // { name: 'Ada', level: 2 }

ليس للمجموعة بنية ثابتة، لذا فإن المخطط في شيفرتك هو ما يحافظ على تشابه مستنداتها.

challenge icon

التحدي

سهل

يمكن للعميل حاليًا تخزين سنة بقيمة "soon"، أو تمرير حقل مثل "admin": true خلسةً. تحقّق من كل كتاب جديد باستخدام zod قبل وصوله إلى قاعدة البيانات.

  1. في server.js، استورد z من 'zod'. اكتب مخططًا لكتاب جديد: يجب أن تكون title وauthor وisbn وgenre سلاسل نصية غير فارغة، وأن تكون year عددًا صحيحًا. الحقول الخمسة كلها مطلوبة.
  2. في POST /books، تحقّق من req.body باستخدام safeParse الخاص بالمخطط قبل الإدراج. إذا فشل التحقق، فأرسل استجابة برمز الحالة 400 ورسالة خطأ باستخدام sendError، ولا تخزّن أي شيء. يفشل التحقق عند فقدان حقل، أو إذا كانت قيمته سلسلة نصية فارغة، أو إذا لم تكن السنة عددًا صحيحًا، مثل "soon" أو 1965.5. اختر الرسالة التي تناسبك، مثل { "error": "year: Invalid input: expected number, received string" }.
  3. الحقول الإضافية ليست خطأ. يظل الطلب ناجحًا، لكن تُحذف الحقول الإضافية: أدرج البيانات التي يعيدها safeParse، لا req.body. يحذف z.object العادي كل مفتاح لم تُدرجه فيه. لا تضف .strict() هنا، لأنه سيرفض الطلب برمز 400 بدلًا من ذلك.
  4. يظل الكتاب الصالح يحصل على الرمز 201، ويُخزَّن. ويظل تكرار ISBN يؤدي إلى الرمز 409. جرّب ذلك: أرسل POST /books مع { "title": "Beloved", "author": "Toni Morrison", "year": "soon", "isbn": "9781400033416", "genre": "literary" }: ستحصل على الرمز 400. أعد إرساله مع "year": 1987 وحقل إضافي "admin": true: ستحصل على الرمز 201، ولن يحتوي الكتاب في الاستجابة على admin. تعرض علامة التبويب Data كتاب Beloved من دون admin. أرسل كتابًا من دون title: ستحصل على الرمز 400.

جرّب بنفسك

import express from 'express';
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 إذا كان المعرّف غير صالح.
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 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 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.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

تدرّب بنفسك: Playground لـ Express