Node.js و Express

MongoDB مع Mongoose

25 دقيقة الدرس 16 من 40

مقدمة إلى MongoDB و Mongoose

MongoDB هي قاعدة بيانات مستندات NoSQL تخزن البيانات في مستندات مرنة تشبه JSON. Mongoose هي مكتبة Object Data Modeling (ODM) لـ MongoDB و Node.js توفر حلاً قائماً على المخططات لنمذجة بيانات التطبيق مع تحويل الأنواع المدمج، والتحقق من الصحة، وبناء الاستعلامات، وخطافات منطق الأعمال.

لماذا MongoDB مع Node.js؟

  • كلاهما يستخدم JavaScript/JSON للبيانات
  • مخطط مرن للتطوير السريع
  • قابلية التوسع الأفقي
  • لغة استعلام غنية وإطار تجميع
  • مجتمع ونظام بيئي قوي

إعداد MongoDB و Mongoose

أولاً، قم بتثبيت MongoDB محلياً أو استخدم MongoDB Atlas (خدمة سحابية)، ثم قم بتثبيت Mongoose في مشروعك:

# تثبيت Mongoose
npm install mongoose

# لمتغيرات البيئة
npm install dotenv

أنشئ ملف تكوين قاعدة البيانات:

// config/database.js
const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
    });

    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(`Error: ${error.message}`);
    process.exit(1);
  }
};

module.exports = connectDB;

قم بتحديث ملف الخادم الرئيسي للاتصال بـ MongoDB:

// server.js
require('dotenv').config();
const express = require('express');
const connectDB = require('./config/database');

const app = express();

// الاتصال بـ MongoDB
connectDB();

app.use(express.json());

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

تنسيق سلسلة الاتصال: في ملف .env الخاص بك، استخدم:
MONGODB_URI=mongodb://localhost:27017/myapp لـ MongoDB المحلي، أو
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/myapp لـ MongoDB Atlas.

إنشاء مخططات ونماذج Mongoose

تحدد المخططات بنية المستندات داخل مجموعة. النماذج هي مُنشئات مجمعة من المخططات تنشئ وتقرأ المستندات.

// models/User.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'الاسم مطلوب'],
    trim: true,
    minlength: [2, 'يجب أن يكون الاسم حرفين على الأقل'],
    maxlength: [50, 'لا يمكن أن يتجاوز الاسم 50 حرفاً']
  },
  email: {
    type: String,
    required: [true, 'البريد الإلكتروني مطلوب'],
    unique: true,
    lowercase: true,
    trim: true,
    match: [/^\S+@\S+\.\S+$/, 'الرجاء إدخال بريد إلكتروني صالح']
  },
  password: {
    type: String,
    required: [true, 'كلمة المرور مطلوبة'],
    minlength: [6, 'يجب أن تكون كلمة المرور 6 أحرف على الأقل'],
    select: false // لا تُضمّن في الاستعلامات افتراضياً
  },
  role: {
    type: String,
    enum: ['user', 'admin', 'moderator'],
    default: 'user'
  },
  age: {
    type: Number,
    min: [18, 'يجب أن يكون العمر 18 سنة على الأقل'],
    max: [120, 'يجب أن يكون العمر واقعياً']
  },
  isActive: {
    type: Boolean,
    default: true
  },
  avatar: String,
  tags: [String],
  createdAt: {
    type: Date,
    default: Date.now
  }
}, {
  timestamps: true // يضيف createdAt و updatedAt تلقائياً
});

// إنشاء وتصدير النموذج
const User = mongoose.model('User', userSchema);
module.exports = User;

خيارات المخطط:

  • required: يجب أن يحتوي الحقل على قيمة
  • unique: ينشئ فهرساً فريداً
  • default: القيمة الافتراضية إذا لم تُقدم
  • enum: يجب أن تكون القيمة من القائمة المحددة
  • min/max: القيم الدنيا/القصوى
  • match: يجب أن تتطابق مع نمط regex
  • trim: إزالة المسافات البيضاء
  • lowercase/uppercase: تحويل الحالة

عمليات CRUD مع Mongoose

إنشاء المستندات

// إنشاء مستند واحد
const createUser = async (req, res) => {
  try {
    const user = await User.create({
      name: 'جون دو',
      email: 'john@example.com',
      password: 'password123',
      age: 25
    });

    res.status(201).json({
      success: true,
      data: user
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

// طريقة بديلة
const createUserAlt = async (req, res) => {
  try {
    const user = new User(req.body);
    await user.save();

    res.status(201).json({
      success: true,
      data: user
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

// إدراج مستندات متعددة
const createMultipleUsers = async (req, res) => {
  try {
    const users = await User.insertMany([
      { name: 'مستخدم 1', email: 'user1@example.com', password: 'pass123' },
      { name: 'مستخدم 2', email: 'user2@example.com', password: 'pass123' }
    ]);

    res.status(201).json({
      success: true,
      count: users.length,
      data: users
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

قراءة المستندات

// البحث عن جميع المستندات
const getAllUsers = async (req, res) => {
  try {
    const users = await User.find();

    res.status(200).json({
      success: true,
      count: users.length,
      data: users
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// البحث مع شروط الاستعلام
const getActiveUsers = async (req, res) => {
  try {
    const users = await User.find({ isActive: true });

    res.status(200).json({
      success: true,
      count: users.length,
      data: users
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// البحث عن مستند واحد بواسطة المعرف
const getUserById = async (req, res) => {
  try {
    const user = await User.findById(req.params.id);

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'المستخدم غير موجود'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// البحث عن واحد مع الشروط
const getUserByEmail = async (req, res) => {
  try {
    const user = await User.findOne({ email: req.params.email });

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'المستخدم غير موجود'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

تحديث المستندات

// التحديث بواسطة المعرف
const updateUser = async (req, res) => {
  try {
    const user = await User.findByIdAndUpdate(
      req.params.id,
      req.body,
      {
        new: true,        // إرجاع المستند المحدث
        runValidators: true  // تشغيل التحققات من المخطط
      }
    );

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'المستخدم غير موجود'
      });
    }

    res.status(200).json({
      success: true,
      data: user
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

// تحديث واحد مع الشروط
const updateUserEmail = async (req, res) => {
  try {
    const result = await User.updateOne(
      { _id: req.params.id },
      { email: req.body.email }
    );

    res.status(200).json({
      success: true,
      data: result
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

// تحديث مستندات متعددة
const deactivateOldUsers = async (req, res) => {
  try {
    const sixMonthsAgo = new Date();
    sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);

    const result = await User.updateMany(
      { createdAt: { $lt: sixMonthsAgo }, isActive: true },
      { isActive: false }
    );

    res.status(200).json({
      success: true,
      modifiedCount: result.modifiedCount
    });
  } catch (error) {
    res.status(400).json({
      success: false,
      error: error.message
    });
  }
};

حذف المستندات

// الحذف بواسطة المعرف
const deleteUser = async (req, res) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);

    if (!user) {
      return res.status(404).json({
        success: false,
        error: 'المستخدم غير موجود'
      });
    }

    res.status(200).json({
      success: true,
      data: {}
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// حذف واحد مع الشروط
const deleteInactiveUser = async (req, res) => {
  try {
    const result = await User.deleteOne({
      _id: req.params.id,
      isActive: false
    });

    if (result.deletedCount === 0) {
      return res.status(404).json({
        success: false,
        error: 'لا يوجد مستخدم غير نشط بهذا المعرف'
      });
    }

    res.status(200).json({
      success: true,
      data: {}
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// حذف مستندات متعددة
const deleteInactiveUsers = async (req, res) => {
  try {
    const result = await User.deleteMany({ isActive: false });

    res.status(200).json({
      success: true,
      deletedCount: result.deletedCount
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

الحذف مقابل الإزالة: الطريقة remove() مهملة. استخدم deleteOne()، أو deleteMany()، أو findByIdAndDelete() بدلاً من ذلك.

الاستعلامات المتقدمة

// معاملات الاستعلام
const advancedQuery = async (req, res) => {
  try {
    // معاملات المقارنة
    const adults = await User.find({ age: { $gte: 18 } });
    const youngAdults = await User.find({ age: { $gte: 18, $lt: 30 } });
    const specificAges = await User.find({ age: { $in: [20, 25, 30] } });

    // المعاملات المنطقية
    const activeAdmins = await User.find({
      $and: [
        { role: 'admin' },
        { isActive: true }
      ]
    });

    const adminOrModerator = await User.find({
      $or: [
        { role: 'admin' },
        { role: 'moderator' }
      ]
    });

    // معاملات السلسلة
    const usersStartingWithJ = await User.find({
      name: { $regex: '^J', $options: 'i' }
    });

    // معامل الوجود
    const usersWithAvatar = await User.find({ avatar: { $exists: true } });

    // معاملات المصفوفة
    const usersWithTag = await User.find({ tags: 'javascript' });
    const usersWithAllTags = await User.find({
      tags: { $all: ['javascript', 'nodejs'] }
    });

    res.status(200).json({
      success: true,
      data: {
        adults: adults.length,
        youngAdults: youngAdults.length,
        activeAdmins: activeAdmins.length
      }
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// الفرز والتحديد واختيار الحقول
const getUsersFiltered = async (req, res) => {
  try {
    const users = await User
      .find({ isActive: true })
      .select('name email age -_id') // تضمين name و email و age؛ استبعاد _id
      .sort({ age: -1 }) // الفرز حسب العمر تنازلياً
      .limit(10) // تحديد النتائج بـ 10
      .skip(0); // تخطي أول 0 (الترقيم)

    res.status(200).json({
      success: true,
      count: users.length,
      data: users
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// الترقيم
const getUsersPaginated = async (req, res) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const skip = (page - 1) * limit;

    const users = await User
      .find()
      .limit(limit)
      .skip(skip)
      .sort({ createdAt: -1 });

    const total = await User.countDocuments();

    res.status(200).json({
      success: true,
      count: users.length,
      pagination: {
        page,
        limit,
        total,
        pages: Math.ceil(total / limit)
      },
      data: users
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

التعبئة (الإشارة إلى المستندات)

تتيح لك التعبئة الإشارة إلى المستندات في مجموعات أخرى واستبدال المسارات المحددة تلقائياً بالمستندات من مجموعات أخرى.

// models/Post.js
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true
  },
  content: {
    type: String,
    required: true
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  comments: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Comment'
  }],
  likes: [{
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }]
}, {
  timestamps: true
});

const Post = mongoose.model('Post', postSchema);
module.exports = Post;
// استخدام التعبئة
const getPostWithAuthor = async (req, res) => {
  try {
    const post = await Post
      .findById(req.params.id)
      .populate('author', 'name email'); // تعبئة المؤلف، اختيار الاسم والبريد الإلكتروني فقط

    if (!post) {
      return res.status(404).json({
        success: false,
        error: 'المنشور غير موجود'
      });
    }

    res.status(200).json({
      success: true,
      data: post
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// تعبئة متعددة
const getPostWithDetails = async (req, res) => {
  try {
    const post = await Post
      .findById(req.params.id)
      .populate('author', 'name email avatar')
      .populate('comments')
      .populate('likes', 'name');

    res.status(200).json({
      success: true,
      data: post
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

// التعبئة المتداخلة
const getPostWithCommentAuthors = async (req, res) => {
  try {
    const post = await Post
      .findById(req.params.id)
      .populate('author', 'name')
      .populate({
        path: 'comments',
        populate: {
          path: 'author',
          select: 'name avatar'
        }
      });

    res.status(200).json({
      success: true,
      data: post
    });
  } catch (error) {
    res.status(500).json({
      success: false,
      error: error.message
    });
  }
};

تمرين تطبيقي:

  1. أنشئ نموذج User بحقول name و email و password و role
  2. نفذ عمليات CRUD للمستخدمين
  3. أضف مرشحات استعلام للحصول على المستخدمين حسب الدور ونطاق العمر
  4. أنشئ نموذج Post مع إشارة إلى User كمؤلف
  5. نفذ التعبئة لجلب المنشورات مع تفاصيل المؤلف
  6. أضف الترقيم إلى نقطة النهاية للحصول على جميع المستخدمين
  7. أنشئ نقطة نهاية للبحث عن المستخدمين بالاسم باستخدام regex

أفضل الممارسات:

  • استخدم الفهارس للحقول المستعلمة بشكل متكرر
  • لا تُفرط في التعبئة؛ اختر الحقول المطلوبة فقط
  • استخدم lean() للاستعلامات للقراءة فقط (أسرع)
  • تعامل مع أخطاء التحقق بشكل صحيح
  • استخدم المعاملات للعمليات التي تؤثر على مجموعات متعددة
  • أنشئ ملفات منفصلة لكل نموذج
  • استخدم طرق المخطط للمنطق القابل لإعادة الاستخدام