العلاقات والاستعلامات في 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));
// models.js: النماذج والعلاقات في مكان واحدconst { Sequelize, DataTypes } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Author = sequelize.define('Author', { name: { type: DataTypes.STRING, allowNull: false },}, { timestamps: false }); const Book = sequelize.define('Book', { title: { type: DataTypes.STRING, allowNull: false }, year: DataTypes.INTEGER,}, { timestamps: false }); // واحد إلى متعدد: المؤلف له كتب كثيرة، والكتاب لمؤلف واحدAuthor.hasMany(Book); // يضيف العمود AuthorId إلى جدول BooksBook.belongsTo(Author); async function seed() { await sequelize.sync(); const sara = await Author.create({ name: 'سارة' }); const khaled = await Author.create({ name: 'خالد' }); await Book.bulkCreate([ { title: 'تعلم JavaScript', year: 2021, AuthorId: sara.id }, { title: 'React خطوة بخطوة', year: 2024, AuthorId: sara.id }, { title: 'أساسيات الويب', year: 2023, AuthorId: khaled.id }, ]);} module.exports = { sequelize, Author, Book, seed };
لاحظ شكل 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);});
// models.js: النماذج والعلاقات في مكان واحدconst { Sequelize, DataTypes } = require('sequelize');const sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }); const Author = sequelize.define('Author', { name: { type: DataTypes.STRING, allowNull: false },}, { timestamps: false }); const Book = sequelize.define('Book', { title: { type: DataTypes.STRING, allowNull: false }, year: DataTypes.INTEGER,}, { timestamps: false }); // واحد إلى متعدد: المؤلف له كتب كثيرة، والكتاب لمؤلف واحدAuthor.hasMany(Book); // يضيف العمود AuthorId إلى جدول BooksBook.belongsTo(Author); async function seed() { await sequelize.sync(); const sara = await Author.create({ name: 'سارة' }); const khaled = await Author.create({ name: 'خالد' }); await Book.bulkCreate([ { title: 'تعلم JavaScript', year: 2021, AuthorId: sara.id }, { title: 'React خطوة بخطوة', year: 2024, AuthorId: sara.id }, { title: 'أساسيات الويب', year: 2023, AuthorId: khaled.id }, ]);} module.exports = { sequelize, Author, Book, seed };
افتح كل طلب في السجل وانظر إلى الطرفية: /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.الخلاصة
hasMany + belongsTo: المفتاح الأجنبي في جدول «المتعدد».belongsToManyمعthrough: جدول وسيط، ودوالadd/get/remove.include = JOINفي استعلام واحد؛attributesتحدد الأعمدة.- الاستعلام داخل حلقة =
N+1. sequelize.transaction: كل شيء أو لا شيء.
تحدٍّ برمجي: مدونة: كتّاب ومقالات
متقدمواجهة برمجية لمدونة فيها كتّاب ومقالات مرتبطة.
- نموذجان
Writer(name)وPost(title, viewsعدد صحيح افتراضيًّا 0) بعلاقة واحد إلى متعدد من الطرفين. POST /api/writers/:id/postsينشئ مقالًا لهذا الكاتب ويرد بـ 201 (أو 404 إن لم يوجد الكاتب).GET /api/postsيرد بكل المقالات، وفي كل مقال كائنWriterفيهnameفقط، مرتبة بـ views تنازليًّا.GET /api/writers/:idيرد بالكاتب ومعه مصفوفةPosts.
👀 اعرض الاستجابات المطلوبة
POST /api/writers/1/posts{"title": "مقدمة في React", "views": 40}POST /api/writers/2/posts{"title": "Express للمبتدئين", "views": 90}POST /api/writers/1/posts{"title": "Hooks"}POST /api/writers/9/posts{"title": "x"}GET /api/postsGET /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);});