طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 14 · الوحدة 5: تكامل قواعد البيانات مع ORM

العلاقات والاستعلامات في ORM

كتب ومؤلفوها، وطلاب ومقرراتهم: الجداول المرتبطة في ORM ⏱ نحو 45 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تعرّف علاقات واحد إلى متعدد ومتعدد إلى متعدد في Sequelize.
  • تجلب البيانات المرتبطة بـ include وتختار أعمدتها.
  • تكتشف مشكلة N+1 وتعالجها.
  • تستخدم المعاملات لضمان سلامة العمليات المتعددة.

١العلاقات بين الجداول

في قواعد البيانات العلائقية ترتبط الجداول بمفاتيح أجنبية: الكتاب يحمل رقم مؤلفه، والطالب مسجّل في مقررات. في Sequelize نصف هذه العلاقات بدوال على النماذج، فيضيف هو المفاتيح الأجنبية ويكتب استعلامات JOIN بدلًا منا:

العلاقةمثالفي Sequelizeالمفتاح الأجنبي
واحد إلى واحدمستخدم ↔ ملف شخصيUser.hasOne(Profile)
Profile.belongsTo(User)
Profiles.UserId
واحد إلى متعددمؤلف ← كتبAuthor.hasMany(Book)
Book.belongsTo(Author)
Books.AuthorId
متعدد إلى متعددطلاب ↔ مقرراتStudent.belongsToMany(Course, { through: 'Enrollment' })جدول وسيط Enrollment فيه المفتاحان

٢include: جلب البيانات المرتبطة

الخيار include يجلب الصفوف المرتبطة في الاستعلام نفسه (JOIN)، فيعود كل كتاب ومعه كائن مؤلفه، أو كل مؤلف ومعه مصفوفة كتبه:

const express = require('express');const { Author, Book, seed } = require('./models'); const app = express(); app.get('/api/books', async (req, res) => {  const books = await Book.findAll({    include: { model: Author, attributes: ['name'] },    order: [['year', 'DESC']],  });  res.json(books);}); app.get('/api/authors/:id', async (req, res) => {  const author = await Author.findByPk(req.params.id, { include: Book });  if (!author) return res.status(404).json({ error: 'غير موجود' });  res.json(author);}); seed().then(() => app.listen(3000));

لاحظ شكل JSON: الكتاب فيه "Author": {"name": …}، والمؤلف فيه "Books": [ … ]. هذا الشكل المتداخل هو ما تحتاجه واجهة React مباشرة.

٣مشكلة N+1

بدون include قد تكتب حلقة تجلب مؤلف كل كتاب على حدة: استعلام للكتب، ثم استعلام لكل كتاب. مع 100 كتاب = 101 استعلام! هذه مشكلة N+1، من أشهر أسباب بطء الخوادم. فعّلنا هنا logging لنعدّ الاستعلامات في الطرفية:

const express = require('express');const { sequelize, Author, Book, seed } = require('./models');const app = express(); app.get('/slow', async (req, res) => {  const books = await Book.findAll();  const out = [];  for (const b of books) {    const a = await b.getAuthor();            // استعلام لكل كتاب!    out.push({ title: b.title, author: a.name });  }  res.json(out);}); app.get('/fast', async (req, res) => {  const books = await Book.findAll({ include: Author });   // استعلام واحد  res.json(books.map(b => ({ title: b.title, author: b.Author.name })));}); seed().then(() => {  sequelize.options.logging = (sql) =>    console.log('استعلام:', sql.slice(21, 60) + (sql.includes('JOIN') ? ' … LEFT OUTER JOIN …' : ' …'));  app.listen(3000);});

افتح كل طلب في السجل وانظر إلى الطرفية: /slow نفّذ 4 استعلامات (1 + 3 كتب)، و /fast استعلامًا واحدًا بـ LEFT OUTER JOIN، والنتيجة نفسها.

٤متعدد إلى متعدد: الطلاب والمقررات

الطالب يسجّل في مقررات كثيرة، والمقرر فيه طلاب كثيرون. هذه العلاقة تحتاج جدولًا وسيطًا فيه المفتاحان، يُنشئه Sequelize من الخيار through، ويعطي النماذج دوالًّا مثل student.addCourse(course):

const express = require('express');const { Sequelize, DataTypes } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Student = sequelize.define('Student', { name: DataTypes.STRING }, { timestamps: false });const Course = sequelize.define('Course', { code: DataTypes.STRING }, { timestamps: false });const Enrollment = sequelize.define('Enrollment', { grade: DataTypes.INTEGER }, { timestamps: false }); Student.belongsToMany(Course, { through: Enrollment });Course.belongsToMany(Student, { through: Enrollment }); const app = express();app.use(express.json()); app.post('/api/students/:id/courses', async (req, res) => {  const s = await Student.findByPk(req.params.id);  const c = await Course.findOne({ where: { code: req.body.code } });  if (!s || !c) return res.status(404).json({ error: 'غير موجود' });  await s.addCourse(c, { through: { grade: req.body.grade } });  res.status(201).json({ ok: true });}); app.get('/api/students/:id', async (req, res) => {  const s = await Student.findByPk(req.params.id, {    include: { model: Course, attributes: ['code'], through: { attributes: ['grade'] } },  });  res.json(s);}); sequelize.sync().then(async () => {  await Student.create({ name: 'ريم' });  await Course.bulkCreate([{ code: 'CS242' }, { code: 'CS241' }]);  app.listen(3000);});

٥المعاملات: كل شيء أو لا شيء

بعض العمليات خطوات متعددة يجب أن تنجح معًا أو تُلغى معًا، مثل نقل رصيد بين حسابين: إن نجح الخصم وفشلت الإضافة ضاع المال. المعاملة Transaction تضمن ذلك: إن رُمي خطأ في أي خطوة، تُلغى كل الخطوات (rollback):

const express = require('express');const { Sequelize, DataTypes } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });const Account = sequelize.define('Account', { owner: DataTypes.STRING, balance: DataTypes.INTEGER }, { timestamps: false }); const app = express();app.use(express.json()); app.post('/api/transfer', async (req, res) => {  const { from, to, amount } = req.body;  try {    await sequelize.transaction(async (t) => {      const a = await Account.findByPk(from, { transaction: t });      const b = await Account.findByPk(to, { transaction: t });      await a.decrement('balance', { by: amount, transaction: t });      if (a.balance - amount < 0) throw new Error('الرصيد لا يكفي');      await b.increment('balance', { by: amount, transaction: t });    });    res.json({ ok: true });  } catch (err) {    res.status(400).json({ error: err.message });  }}); app.get('/api/accounts', async (req, res) => res.json(await Account.findAll())); sequelize.sync().then(async () => {  await Account.bulkCreate([{ owner: 'سارة', balance: 500 }, { owner: 'فهد', balance: 100 }]);  app.listen(3000);});

التحويل الثاني خصم من فهد ثم اكتشف أن الرصيد لا يكفي فرمى خطأ، فأُلغي الخصم: الأرصدة النهائية 300 و 300 كما لو أن التحويل الفاشل لم يحدث.

توقّع وتحقّق

أين يُضاف المفتاح الأجنبي في Author.hasMany(Book) ؟

الطرف «المتعدد».

ما الذي يحل مشكلة N+1 غالبًا؟

Eager loading.

علاقة متعدد إلى متعدد تحتاج:

Enrollment.

ماذا يحدث لخطوات المعاملة إن رُمي خطأ في منتصفها؟

rollback.

الخلاصة

تحدٍّ برمجي: مدونة: كتّاب ومقالات

متقدم

واجهة برمجية لمدونة فيها كتّاب ومقالات مرتبطة.

المطلوب:
  • نموذجان Writer (name) و Post (title, views عدد صحيح افتراضيًّا 0) بعلاقة واحد إلى متعدد من الطرفين.
  • POST /api/writers/:id/posts ينشئ مقالًا لهذا الكاتب ويرد بـ 201 (أو 404 إن لم يوجد الكاتب).
  • GET /api/posts يرد بكل المقالات، وفي كل مقال كائن Writer فيه name فقط، مرتبة بـ views تنازليًّا.
  • GET /api/writers/:id يرد بالكاتب ومعه مصفوفة Posts.
👀 اعرض الاستجابات المطلوبة
✓ التحقق الآلي: يشغّل المختبر خادمك من جديد ويرسل إليه هذه الطلبات بالترتيب ثم يفحص الردود
  1. POST /api/writers/1/posts {"title": "مقدمة في React", "views": 40}
  2. POST /api/writers/2/posts {"title": "Express للمبتدئين", "views": 90}
  3. POST /api/writers/1/posts {"title": "Hooks"}
  4. POST /api/writers/9/posts {"title": "x"}
  5. GET /api/posts
  6. GET /api/writers/1
  • الخادم يعمل ويرد على كل الطلبات بلا أخطاء غير معالَجة
  • إنشاء مقال ← 201
  • المقال بلا views يبدأ من 0
  • كاتب غير موجود ← 404
  • المقالات مرتبة بالمشاهدات ومعها اسم الكاتب
  • كائن Writer فيه name فقط
  • الكاتبة هند معها مقالان في Posts

💡 تلميح 1
العلاقة: Writer.hasMany(Post) و Post.belongsTo(Writer).
💡 تلميح 2
لاختيار أعمدة المرتبط: include: { model: Writer, attributes: ['name'] }.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

const express = require('express');const { Sequelize, DataTypes } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Writer = sequelize.define('Writer', { name: DataTypes.STRING }, { timestamps: false });const Post = sequelize.define('Post', {  title: DataTypes.STRING,  views: { type: DataTypes.INTEGER, defaultValue: 0 },}, { timestamps: false });Writer.hasMany(Post);Post.belongsTo(Writer); const app = express();app.use(express.json()); app.post('/api/writers/:id/posts', async (req, res) => {  const w = await Writer.findByPk(req.params.id);  if (!w) return res.status(404).json({ error: 'غير موجود' });  const p = await Post.create({ ...req.body, WriterId: w.id });  res.status(201).json(p);}); app.get('/api/posts', async (req, res) => {  res.json(await Post.findAll({ include: { model: Writer, attributes: ['name'] }, order: [['views', 'DESC']] }));}); app.get('/api/writers/:id', async (req, res) => {  res.json(await Writer.findByPk(req.params.id, { include: Post }));}); sequelize.sync().then(async () => {  await Writer.bulkCreate([{ name: 'هند' }, { name: 'ماجد' }]);  app.listen(3000);});
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس