اذهب إلى المحتوى

ضم الجداول والاستعلامات المشتركة في قواعد البيانات باستخدام Sequelize


ابراهيم الخضور

نستعرض في هذا المقال طريقة هيكلة التطبيق الذي عملنا عليه في المقال السابق، والاستعلام عن معلومات متنوعة تضمها قاعدة البيانات العلاقية.

هيكلية التطبيق

لقد كتبنا حتى اللحظة كامل الشيفرة في نفس الملف، لهذا سنحاول الآن إعطاء التطبيق هيكلًا أوضح. لننشئ إذًا المجلدات والملفات وفق الهيكلية التالية:

index.js
util
  config.js
  db.js
models
  index.js
  note.js
controllers
  notes.js

أما محتوى الملفات فستكون على النحو التالي:

  • الملف "util/config.js": يهتم بالتعامل مع متغيرات البيئة environment variables:
require('dotenv').config()

module.exports = {
  DATABASE_URL: process.env.DATABASE_URL,
  PORT: process.env.PORT || 3001,
}
  • الملف "index.js": ويهتم بتهيئة وتشغيل التطبيق:
const express = require('express')
const app = express()

const { PORT } = require('./util/config')
const { connectToDatabase } = require('./util/db')

const notesRouter = require('./controllers/notes')

app.use(express.json())

app.use('/api/notes', notesRouter)

const start = async () => {
  await connectToDatabase()
  app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`)
  })
}

start()

يختلف تشغيل التطبيق قليلًا عما رأيناه سابقًا، لأننا نريد التأكُّد من نجاح الاتصال بقاعدة البيانات قبل أن يبدأ التطبيق العمل الفعلي.

  • الملف "util/d b.js": ويضم الشيفرة التي تُهيئ قاعدة البيانات:
const Sequelize = require('sequelize')
const { DATABASE_URL } = require('./config')

const sequelize = new Sequelize(DATABASE_URL, {
  dialectOptions: {
    ssl: {
      require: true,
      rejectUnauthorized: false
    }
  },
});

const connectToDatabase = async () => {
  try {
    await sequelize.authenticate()
    console.log('connected to the database')
  } catch (err) {
    console.log('failed to connect to the database')
    return process.exit(1)
  }

  return null
}

module.exports = { connectToDatabase, sequelize }
  • الملف "models/note.js": وتُخزّن فيه الملاحظات في النموذج المقابل للجدول الذي سيُحفظ.
const { Model, DataTypes } = require('sequelize')

const { sequelize } = require('../util/db')

class Note extends Model {}

Note.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  content: {
    type: DataTypes.TEXT,
    allowNull: false
  },
  important: {
    type: DataTypes.BOOLEAN
  },
  date: {
    type: DataTypes.DATE
  }
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'note'
})

module.exports = Note
  • الملف "models/index.js": لا يُستخدم حاليًا تقريبًا بسبب وجود نموذج واحد فقط في التطبيق، لكنه سيصبح أكثر فائدةً عندما نبدأ إضافة نماذج جديدة، إذ سيلغي الحاجة إلى إدراج ملفات منفصلة تُعرِّف بقية النماذج:
const Note = require('./note')

Note.sync()

module.exports = {
  Note
}
  • الملف "controllers/notes.js": ويضم الوجهات المرتبطة بالملاحظات، أي مسار التوجيه إلى ملاحظة:
const router = require('express').Router()

const { Note } = require('../models')

router.get('/', async (req, res) => {
  const notes = await Note.findAll()
  res.json(notes)
})

router.post('/', async (req, res) => {
  try {
    const note = await Note.create(req.body)
    res.json(note)
  } catch(error) {
    return res.status(400).json({ error })
  }
})

router.get('/:id', async (req, res) => {
  const note = await Note.findByPk(req.params.id)
  if (note) {
    res.json(note)
  } else {
    res.status(404).end()
  }
})

router.delete('/:id', async (req, res) => {
  const note = await Note.findByPk(req.params.id)
  if (note) {
    await note.destroy()
  }
  res.status(204).end()
})

router.put('/:id', async (req, res) => {
  const note = await Note.findByPk(req.params.id)
  if (note) {
    note.important = req.body.important
    await note.save()
    res.json(note)
  } else {
    res.status(404).end()
  }
})

module.exports = router

هكذا يبدو هيكل التطبيق جيدًا الآن، لكننا نلاحظ أنّ معالجات التوجيه route handlers الذي يتعامل مع ملاحظة واحدة يضم قليلًا من الشيفرات المكررة، فجميعها تبدأ بالسطر الذي يبحث عن الملاحظة التي يتعامل معها:

const note = await Note.findByPk(req.params.id)

لنعيد كتابة الشيفرة على شكل أداة وسطية middleware خاصةٍ بنا، ونطبّقها في معالجات التوجيه:

const noteFinder = async (req, res, next) => {
  req.note = await Note.findByPk(req.params.id)
  next()
}

router.get('/:id', noteFinder, async (req, res) => {
  if (req.note) {
    res.json(req.note)
  } else {
    res.status(404).end()
  }
})

router.delete('/:id', noteFinder, async (req, res) => {
  if (req.note) {
    await req.note.destroy()
  }
  res.status(204).end()
})

router.put('/:id', noteFinder, async (req, res) => {
  if (req.note) {
    req.note.important = req.body.important
    await req.note.save()
    res.json(req.note)
  } else {
    res.status(404).end()
  }
})

تستقبل معالجات الوجهة ثلاثة معاملات: الأول نصي يُعرّف الوجهة، والثاني الأداة الوسطية noteFinder المُعرفة مُسبقًا والتي تستخلص الملاحظة من قاعدة البيانات وتضعها في الخاصية note للكائن req.

بإمكانك إيجاد الشيفرة الحالية للتطبيق كاملةً في المستودع المخصص على GitHub ضمن الفرع part13-2.

التمرينات 13.5 إلى 13.7

حاول إنجاز التمارين التالية

التمرين 13.5

غيّر هيكل تطبيقك ليشابه المثال السابق أو اتبع هيكليةً أخرى واضحة وملائمة.

التمرين 13.6

قدِّم طريقةً تدعم تغيير عدد الإعجابات بمدوّنة في تطبيقك مستخدمًا العملية "PUT /api/blogs/:id"، إذ ينبغي أن يصل العدد الجديد للإعجابات مع الطلب:

{
  likes: 3
}

التمرين 13.7

استخدم أداةً وسطيةً للتحكم المركزي بمعالجة الأخطاء كما فعلنا في القسم 3 كما يمكنك استخدام الأداة الوسطية express-async-errors كما فعلنا في القسم 4. لا تهتم للبيانات المُعادة في سياق رسالة الخطأ.

حتى اللحظة لا تتطلب سوى حالتين في التطبيق معالجةً للأخطاء، هما: إضافة مدوّنة جديدة وتغيير عدد الإعجابات، لذلك تأكد من قدرة معالج الأخطاء على التعامل مع هاتين الحالتين بما يلائمهما.

إدارة المستخدمين

سنضيف تاليًا جدول قاعدة بيانات يُدعى "users" تُخزّن في فيه بيانات مستخدمي التطبيق، كما سنضيف أيضًا وظيفةً لإضافة مستخدمين جدد وآلية تسجيل دخول مبنية على مفاتيح الاستيثاق كما فعلنا في القسم 4، ولكي نبسط العمل، سنُعدِّل ما أنجزناه سابقًا كي يكون لجميع المستخدمين كلمة المرور ذاتها وهي "secret".

محتوى الملف "models/user.js" الذي يُعرّف المستخدمين واضحٌ تمامًا:

const { Model, DataTypes } = require('sequelize')

const { sequelize } = require('../util/db')

class User extends Model {}

User.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  username: {
    type: DataTypes.STRING,
    unique: true,
    allowNull: false
  },
  name: {
    type: DataTypes.STRING,
    allowNull: false
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user'
})

module.exports = User

سيكون حقل اسم المستخدم "username" ذا قيم فريدة، لكن وعلى الرغم من إمكانية جعله المفتاح الرئيسي للجدول، إلا أننا قررنا أن ننشئ حقلًا field منفصلًا "id" ذا قيم صحيحة ليكون المفتاح الرئيسي.

سيتوسع الملف "models/index.js" قليلًا:

const Note = require('./note')
const User = require('./user')

Note.sync()
User.sync()
module.exports = {
  Note, User
}

لا يضم الملف "controllers/users.js" الذي يحتوي معالجات الوجهة التي تهتم بإنشاء مستخدمين جدد أي شيء مهم حاليًا سوى عرض كل المستخدمين:

const router = require('express').Router()

const { User } = require('../models')

router.get('/', async (req, res) => {
  const users = await User.findAll()
  res.json(users)
})

router.post('/', async (req, res) => {
  try {
    const user = await User.create(req.body)
    res.json(user)
  } catch(error) {
    return res.status(400).json({ error })
  }
})

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id)
  if (user) {
    res.json(user)
  } else {
    res.status(404).end()
  }
})

module.exports = router

أما معالج الوجهة الذي يتحكم بتسجيل الدخول (الملف "controllers/login.js") فسيكون على النحو التالي:

const jwt = require('jsonwebtoken')
const router = require('express').Router()

const { SECRET } = require('../util/config')
const User = require('../models/user')

router.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findOne({
    where: {
      username: body.username
    }
  })

  const passwordCorrect = body.password === 'secret'

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: 'invalid username or password'
    })
  }

  const userForToken = {
    username: user.username,
    id: user.id,
  }

  const token = jwt.sign(userForToken, SECRET)

  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

module.exports = router

سيُرفق اسم المستخدم وكلمة المرور مع طلب POST، ويُستخلص الكائن المتعلق باسم المستخدم أولًا من قاعدة البيانات باستخدام التابع findOne العائد للنموذج User:

const user = await User.findOne({
  where: {
    username: body.username
  }
})

يمكنك أن تلاحظ من خلال الطرفية أن تعليمة SQL المتعلقة باستدعاء التابع السابق هي:

SELECT "id", "username", "name"
FROM "users" AS "User"
WHERE "User". "username" = 'mluukkai';

إن وُجد المستخدم وكانت كلمة المرور صحيحة (وهي "secret" لجميع المستخدمين)، يُعاد مفتاح الاستيثاق "jsonwebtoken" متضمنًا معلومات المستخدم مع الاستجابة. لهذا سنُثبّت الاعتمادية "jsonwebtoken":

npm install jsonwebtoken

سيتوسع الملف "index.js" قليلًا:

const notesRouter = require('./controllers/notes')
const usersRouter = require('./controllers/users')
const loginRouter = require('./controllers/login')

app.use(express.json())

app.use('/api/notes', notesRouter)
app.use('/api/users', usersRouter)
app.use('/api/login', loginRouter)

يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-3.

الاتصال بين الجداول

يمكن الآن إضافة مستخدمين إلى التطبيق، كما يمكن للمستخدمين تسجيل الدخول، لكنها ليست ميزات مفيدة جدًا في وضعها الحالي، لذلك سنضيف ميزات لا يمكن للمستخدم الاستفادة منها ما لم يُسجل دخوله مثل إضافة ملاحظة جديدة، إذ ترتبط هذه الملاحظة بالمستخدم الذي أنشأها، ولهذا لا بُدّ من إضافة مفتاح خارجي foreign key إلى جدول "notes".

يمكن تعريف المفتاح الخارجي عند استخدام مكتبة Sequelize بتعديل الملف "models/index.js" على النحو التالي:

const Note = require('./note')
const User = require('./user')

User.hasMany(Note)
Note.belongsTo(User)
Note.sync({ alter: true })
User.sync({ alter: true })

module.exports = {
  Note, User
}

وهكذا نكون قد عرّفنا علاقة واحد-إلى-متعدد one-to-many تصل بين جدولي المستخدمين "users" والملاحظات "notes"، كما عدّلنا الخيارات في استدعاءات sync لتتطابق جداول قاعدة البيانات مع التغييرات التي حدثت على تعريفات النموذج. سيبدو مخطط قاعدة البيانات على شاشة الطرفية على النحو التالي:

username=> \d users
                                     Table "public.users"
  Column | Type | Collation | Nullable | Default
----------+------------------------+-----------+----------+-----------------------------------
 id | integer | not null | nextval('users_id_seq'::regclass)
 username | character varying(255) | | not null |
 name | character varying(255) | | not null |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL

username=> \d notes
                                      Table "public.notes"
  Column | Type | Collation | Nullable | Default
-----------+--------------------------+-----------+----------+-----------------------------------
 id | integer | not null | nextval('notes_id_seq'::regclass)
 content | text | | not null |
 important | boolean | | | |
 date | timestamp with time zone | | | |
 user_id | integer | | | |
Indexes:
    "notes_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
    "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL

يشير المفتاح الخارجي user_id الذي أُنشئ في الجدول notes إلى أسطر في الجدول users.

لنربط الآن كل ملاحظة جديدة بالمستخدم الذي أنشأها، ولكن قبل تقديم الأمر (ربط الملاحظة بمفتاح استيثاق المستخدم الذي استخدمه لتسجيل دخوله)، لا بُد من كتابة الملاحظة التي ترتبط بالمستخدم الأول الذي يُعثر عليه يدويًا في الشيفرة:

router.post('/', async (req, res) => {
  try {
    const user = await User.findOne()
    const note = await Note.create({...req.body, userId: user.id})
    res.json(note)
  } catch(error) {
    return res.status(400).json({ error })
  }
})

انتبه إلى وجود العمود user_id في جدول الملاحظات "notes" على مستوى قاعدة البيانات، ويُشار إلى اسم كل صف في قاعدة البيانات بالطريقة التقليدية للمكتبة Sequelize، وذلك بكتابته على نقيض أسلوب سنام الجمل "userId"، أي كما تُكتب تمامًا في الشيفرة (حروف صغيرة).

من السهل تنفيذ استعلامات مشتركة في Sequelize، لهذا سنغيّر الوجهة التي تعيد كل المستخدمين لتعرض كل ملاحظات المستخدم:

router.get('/', async (req, res) => {
  const users = await User.findAll({
    include: {
      model: Note
    }
  })
  res.json(users)
})

يُنفَّذ الاستعلام المشترك باستخدام الخيار include مثل معامل استعلام، أما تعليمة SQL المولّدة من الاستعلام، فستُطبع على شاشة الطرفية على النحو التالي:

SELECT "User". "id", "User". "username", "User". "name", "Notes". "id" AS "Notes.id", "Notes". "content" AS "Notes.content", "Notes". "important" AS "Notes.important", "Notes". "date" AS "Notes.date", "Notes". "user_id" AS "Notes.UserId"
FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User". "id" = "Notes". "user_id";

ستبدو النتيجة النهائية كما تتوقع:

النتيجة النهائية للتمرين باستخدام jQuery

الإضافة الملائمة للملاحظات

لنحاول تغيير طريقة إضافة الملاحظات لتعمل بالطريقة التي عملت بها من قبل، بحيث ينجح إنشاء الملاحظات فقط إذا حمل طلب إنشائها مفتاح استيثاق صحيح عند تسجيل الدخول. تُخزّن بعدها الملاحظة ضمن قائمة الملاحظات التي أنشأها المستخدم المُعرّف بواسطة مفتاح الاستيثاق:

const tokenExtractor = (req, res, next) => {
  const authorization = req.get('authorization')
  if (authorization && authorization.toLowerCase().startsWith('bearer ')) {
    try {
      req.decodedToken = jwt.verify(authorization.substring(7), SECRET)
    } catch{
      res.status(401).json({ error: 'token invalid' })
    }
  }  else {
    res.status(401).json({ error: 'token missing' })
  }
  next()
}

router.post('/', tokenExtractor, async (req, res) => {
  try {
    const user = await User.findByPk(req.decodedToken.id)
    const note = await Note.create({...req.body, userId: user.id, date: new Date()})
    res.json(note)
  } catch(error) {
    return res.status(400).json({ error })
  }
})

يُستخلص مفتاح الاستيثاق من ترويسة الطلب ويفُك تشفيره ويوضع ضمن الكائن req بواسطة الأداة الوسطية tokenExtractor. يُضاف زمن الإنشاء إلى الحقل date أيضًا عند إنشاء الملاحظة.

ضبط الواجهة الخلفية

تعمل الواجهة الخلفية حتى اللحظة بنفس الطريقة التي تعمل بها في نسخة القسم 4 من التطبيق ماعدا فكرة معالجة الأخطاء. لنغيّر الآن وجهات إحضار جميع الملاحظات وجميع المستخدمين قليلًا قبل توسيع هذه الواجهة.

سنضيف إلى كل ملاحظة بعض المعلومات المتعلقة بالمستخدم الذي أنشأها:

router.get('/', async (req, res) => {
  const notes = await Note.findAll({
    attributes: { exclude: ['userId'] },
    include: {
      model: User,
      attributes: ['name']
    }
  })
  res.json(notes)
})

كنا قد قيّدنا سابقًا القيم التي يأخذها الحقل المطلوب، إذ كنا نعيد جميع حقول البيانات الخاصة بكل ملاحظة بما في ذلك اسم المستخدم "name" الذي يرتبط بالملاحظة لكن باستثناء حقل المعرّف الفريد للمستخدم "userId".

لنجرِ التغيير ذاته على الوجهة route التي تحضر جميع المستخدمين والملاحظات وذلك بإزالة الحقل userId غير الضروري من الملاحظات المرتبطة بمستخدم معين على النحو التالي:

router.get('/', async (req, res) => {
  const users = await User.findAll({
    include: {
      model: Note,
      attributes: { exclude: ['userId'] }
    }
  })
  res.json(users)
})

يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-4.

قليل من الانتباه إلى تعريفات النماذج

رغم وجود العمود ‍‍‌‍‍user_id، إلا أننا لم نغيّر النموذج الذي يعرّف الملاحظات، ولكن يمكننا مع ذلك إضافة مستخدم إلى كائن الملاحظة:

const user = await User.findByPk(req.decodedToken.id)
const note = await Note.create({ ...req.body, userId: user.id, date: new Date() })

يعود السبب وراء ذلك إلى أننا لم نحدّد وجود علاقة واحد-إلى-متعدد في الاتصال بين جدولي المستخدمين "users" والملاحظات "notes" ضمن الملف "models/index.js":

const Note = require('./note')
const User = require('./user')

User.hasMany(Note)
Note.belongsTo(User)

// ...

تُنشئ المكتبة Sequelize تلقائيًا سمةً تُدعى userId في النموذج Note تمنح وصولًا إلى العمود user_id عندما يُشار إليه. وتذكّر أنه يمكنك إنشاء ملاحظة باستخدام التابع build:

const user = await User.findByPk(req.decodedToken.id)

// إنشاء ملاحظة دون تخزينها بعد
const note = Note.build({ ...req.body, date: new Date() })
 // للملاحظة المُنشأة userId وضع المعرف الفريد للمستخدم في خاصية
note.userId = user.id
// تخزين كائن الملاحظة في قاعدة البيانات
await note.save()

هكذا نرى صراحة أن userId هي سمة attribute لكائن الملاحظات، وقد كان بالإمكان تعريف النموذج على النحو التالي للحصول على النتيجة ذاتها:

Note.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  content: {
    type: DataTypes.TEXT,
    allowNull: false
  },
  important: {
    type: DataTypes.BOOLEAN
  },
  date: {
    type: DataTypes.DATE
  },
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  }
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'note'
})

module.exports = Note

لا يوجد داعٍ للتعريف على مستوى الصنف في النموذج كما فعلنا سابقًا:

User.hasMany(Note)
Note.belongsTo(User)

وبدلًا من ذلك يمكننا تحقيق الأمر بهذه الطريقة، ولا بُد من استخدام إحدى هاتين الطريقتين وإلا لن تُدرك مكتبة Sequelize كيف يتصل الجدولين ببعضهما على مستوى الشيفرة.

التمرينات 13.8 إلى 13.11

حاول إنجاز التمارين الآتية:

التمرين 13.8

زِد دعم التطبيق لمستخدميه، إذ لا بُد أن يضم جدول المستخدمين الحقول التالية إضافةً إلى الحقل "ID":

  • name: ذو قيمة نصية (لا يمكن أن يكون فارغًا).
  • username: ذو قيمة نصية (لا يمكن أن يكون فارغًا).

وعلى خلاف ما أوردنا في الشروحات النظرية، لا تمنع مكتبة Sequelize حاليًا إنشاء البصمتين الزمنيتين create_dat و update_dat لجدول المستخدمين.

يمكن إعطاء كلمة المرور نفسها لجميع المستخدمين، وكذلك اختيار طريقة التحقق من كلمة المرور كما في القسم 4، وعليك أيضًا إنجاز الوجهات التالية:

  • "POST api/users": لإضافة مستخدم جديد.
  • "GET api/users": عرض جميع المستخدمين.
  • "PUT api/users/:username": لتغيير اسم المستخدم وليس المعرّف "id".

تأكد من إدراج البصمات الزمنية تلقائيًا من قِبل مكتبة Sequelize وعلى النحو الصحيح عند إضافة مستخدم أو تغيير اسمه.

التمرين 13.9

تزودنا مكتبة Sequelize بمجموعة قواعد تحقق معرّفةٌ مسبقًا للتأكد من قيم حقول النموذج تُنفّذها قبل تخزين الكائنات في قاعدة البيانات. ولأننا نريد تغيير سياسة إنشاء مستخدم جديد تتطلب أن يكون البريد الإلكتروني المُدخل صحيحًا كما هو حال اسم المستخدم، أنجِز طريقةً للتحقق من ذلك أثناء إنشاء المستخدم.

عدّل أيضًا بالأداة الوسطية المستخدمة في معالجة الأخطاء لتعرض وصفًا أكثر وضوحًا لرسالة الخطأ مثل استخدام رسائل خطأ Sequelize على سبيل المثال.

{
    "error": [
        "Validation isEmail on username failed"
    ]
}

التمرين 13.10

وسّع التطبيق لكي تربط كل مستخدم سجّل دخوله بنجاح عبر مفتاح الاستيثاق بكل مدونة يضيفها. لا بُد من تقديم وصلة endpoint تسجيل دخول "POST /api/login" تُعيد مفتاح الاستيثاق.

التمرين 13.11

تأكد أن المستخدم قادرٌ فقط على حذف المدونات التي يضيفها.

التمرين 13.12

عدّل الوجهات كي تكون قادرًا على استخلاص جميع المدوّنات والمستخدمين كي تعرض كل مدوّنة المستخدم الذي أضافها وأن يعرض كل مستخدم المدوّنات التي أضافها.

استعلامات أوسع

لا يزال تطبيقنا حتى اللحظة بسيطًا من ناحية الاستعلامات التي يجريها، إذ اقتصر البحث على صف واحد بناءً على مفتاح رئيسي من خلال التابع "findByPk"، أو البحث عن كل الصفوف باستخدام التابع "findAll". كانت هذه الاستعلامات كافيةً بالنسبة للواجهة الأمامية في نسخة القسم 5 من التطبيق، لكننا سنوّسع الواجهة الخلفية لنتمكن من تنفيذ استعلامات أعقد قليلًا.

لننجز أولًا طريقة للحصول على ملاحظات مصنّفة على أنها مهمة أو غير مهمة فقط، باستخدام معامل الاستعلام important:

router.get('/', async (req, res) => {
  const notes = await Note.findAll({
    attributes: { exclude: ['userId'] },
    include: {
      model: user,
      attributes: ['name']
    },
    where: {
      important: req.query.important === "true"
    }
  })
  res.json(notes)
})

وهكذا ستتمكن الواجهة الخلفية من استخلاص الملاحظات المهمة عند وصول الطلب:

http://localhost:3001/api/notes?important=true

والملاحظات غير المهمة عند وصول الطلب:

http://localhost:3001/api/notes?important=false

يتضمن استعلام SQL الذي ولّدته Sequelize العبارة WHERE، التي تُرشّح الصفوف المُعادة في الحالة الطبيعية:

SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name"
FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"
WHERE "note". "important" = true;

وبالطبع لن ينفع إنجاز الأمر بهذه الطريقة ما لم يهتم الاستعلام بأهمية الملاحظة، كأن يكون على النحو التالي:

http://localhost:3001/api/notes

يمكن تدارك الموضوع بطرق عدة، أولها -وقد لا تكون الطريقة الأفضل- على النحو التالي:

const { Op } = require('sequelize')

router.get('/', async (req, res) => {
  let important = {
    [Op.in]: [true, false]
  }
  if ( req.query.important ) {
    important = req.query.important === "true"
  }

  const notes = await Note.findAll({
    attributes: { exclude: ['userId'] },
    include: {
      model: user,
      attributes: ['name']
    },
    where: {
      important
    }
  })
  res.json(notes)
})

يُخزّن الكائن important الآن شرط الاستعلام، وسيكون الشكل الافتراضي لهذا الاستعلام على النحو التالي:

where: {
  important: {
    [Op.in]: [true, false]
  }
}

أي أن العمود قد يحمل إحدى القيمتين true أو false باستخدام العامل Op.in، وهو أحد عوامل Sequelize العديدة؛ فإذا حددنا قيمة للمعامل req.query.important، سيتغير الاستعلام ليصبح بأحد الشكلين التاليين:

where: {
  important: true
}

أو

where: {
  important: false
}

بناءً على قيمة معامل الاستعلام.

يمكن زيادة القدرة الوظيفية للتطبيق بالسماح للمستخدم بتخصيص كلمة مفتاحية لإحضار البيانات، إذ يعيد الطلب التالي:

http://localhost:3001/api/notes?search=database

الملاحظات التي تشير إلى الكلمة "database". كما يعيد الطلب التالي:

http://localhost:3001/api/notes?search=javascript&important=true

جميع الملاحظات التي حُددت أنها مهمة "important" وتشير إلى الكلمة "javascript".

سيكون تنفيذ الأمر على النحو التالي:

router.get('/', async (req, res) => {
  let important = {
    [Op.in]: [true, false]
  }

  if ( req.query.important ) {
    important = req.query.important === "true"
  }

  const notes = await Note.findAll({
    attributes: { exclude: ['userId'] },
    include: {
      model: user,
      attributes: ['name']
    },
    where: {
      important,
      content: {
        [Op.substring]: req.query.search ? req.query.search : ''
      }
    }
  })

  res.json(notes)
})

يوّلد التابع Op.substring الاستعلام الذي نريده باستخدام كلمة "LIKE"، فإذا أنشأت الاستعلام التالي مثلًا:

http://localhost:3001/api/notes?search=database&important=true

فسيولّد استعلام SQL ما نتوقعه تمامًا:

SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name"
FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"
WHERE "note". "important" = true AND "note". "content" LIKE '%database%';

لكن لا تزال هناك ثغرة مزعجة تتمثل في أن الطلب التالي:

 http://localhost:3001/api/notes

والذي يعني أننا نريد الحصول على جميع الملاحظات، سيتسبب في وجود عبارة WHERE غير الضرورية في الاستعلام، والتي قد تؤثر في فعاليته وفقًا لآلية عمل محرك قاعدة البيانات:

SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name"
FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"
WHERE "note". "important" IN (true, false) AND "note". "content" LIKE '%%';

لنحسّن الشيفرة لكي تُستخدم عبارة WHERE عند الحاجة فقط:

router.get('/', async (req, res) => {
  const where = {}

  if (req.query.important) {
    where.important = req.query.important === "true"
  }

  if (req.query.search) {
    where.content = {
      [Op.substring]: req.query.search
    }
  }

  const notes = await Note.findAll({
    attributes: { exclude: ['userId'] },
    include: {
      model: user,
      attributes: ['name']
    },
    where
  })

  res.json(notes)
})

وإذا احتوى الطلب على شرط للبحث مثل:

http://localhost:3001/api/notes?search=database&important=true

سيتولد استعلام يضم عبارة WHERE:

SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name"
FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"
WHERE "note". "important" = true AND "note". "content" LIKE '%database%';

وإذا لم يحتوي شرطًا، فلن يكون لوجود WHERE حاجة:

SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name"
FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id";

يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-5.

التمرينات 13.13 إلى 13.16

حاول إنجاز التمرينات التالية:

التمرين 13.13

قدِّم آليةً لترشيح filtering النتائج من خلال كلمة مفتاحية ضمن الوجهة التي تعيد كل المدوّنات، وينبغي أن تعمل الآلية على النحو التالي:

  • "GET /api/blogs?search=react": تعيد كل المدونات التي تضم الكلمة "react" في الحقل علمًا أن البحث حساس لحالة الأحرف.

  • "GET /api/blogs": يعيد جميع المدوّنات.

يمكنك الاستفادة من عوامل Sequelize لتنفيذ التمرين.

التمرين 13.14

وسّع آلية الترشيح للبحث عن كلمة مفتاحية في أحد الحقلين "field" أو "author"، إذ سيعيد الاستعلام:

GET /api/blogs?search=jami

كل المدوّنات التي تضم الكلمة في أحد الحقلين "field" أو "author".

التمرين 13.15

عدّل وجهات المدوّنات لتعيد المدوّنات بناءً على عدد الإعجابات بترتيب تنازلي. اطلع من خلال توثيق Sequelize على تعليمات ترتيب نتائج الاستعلام.

التمرين 13.16

أنشئ الوجهة "api/authors/" كي تعيد عدد المدونات التي أضافها المؤلف والعدد الكلي للإعجابات. قدّم العملية على مستوى قاعدة البيانات. قد تحتاج إلى وظيفة التجميع group by ودالة التجميع sequelize.fn.

قد تبدو المعلومات المُعادة بتنسيق JSON على غرار المثال الآتي:

[
  {
    author: "Jami Kousa",
    articles: "3",
    likes: "10"
  },
  {
    author: "Kalle Ilves",
    articles: "1",
    likes: "2"
  },
  {
    author: "Dan Abramov",
    articles: "1",
    likes: "4"
  }
]

مهمة لعلامة إضافية: رتِّب البيانات المُعادة بناءً على عدد الإعجابات، على أن تُنفّذ عملية الترتيب من خلال استعلام قاعدة البيانات.

ترجمة -وبتصرف- للفصل Join tables and queries من سلسلة Deep Dive Into Modern Web Development

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...