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

إجراء عملية التهجير وإنشاء علاقة متعدد-إلى-متعدد بين الجداول باستخدام Sequelize


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

نلقي نظرةً في هذا المقال على الطرق المتبعة في نقل التغييرات التي تجري أثناء جلسة العمل إلى قاعدة البيانات العلاقية وتخزينها، إضافةً إلى ربط الجداول ببعضها من خلال المفاتيح الخارجية foreign keys.

ملفات التهجير Migrations

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

يمكننا العمل وفق الآلية السابقة بتعديل النموذج الذي يعرّف الجدول والاعتماد على مكتبة Sequelize في مزامنة التغييرات مع قاعدة البيانات من خلال أسطر الشيفرة التالية في الملف "models/index.js":

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

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

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

module.exports = {
  Note, User
}

ولكن يُعد هذا الأسلوب (تغيير النموذج كلما أردنا توسعة التطبيق) غير منطقي على المدى الطويل، لهذا سنزيل الأسطر التي تدعم مزامنة التغييرات ونستخدم أسلوبًا أكثر قوةً وهو ملفات التهجير migrations التي تقدمها Sequelize وغيرها من المكتبات.

ملف التهجير migration عمليًا هو ملف جافاسكربت JavaScript وحيد يصف التعديلات التي تطرأ على قاعدة البيانات، وينشأ عن تغير وحيد أو مجموعة تغيرات معًا. تحتفظ Sequelize بسجلات عن التهجيرات التي أجريت، أي التغيرات التي طرأت وجرى نقلها ومزامنتها مع قاعدة البيانات، إذ تبقى Sequelize مطلّعةً على التغيرات التي طرأت على مخطط قاعدة البيانات ولم تُطبّق بعد عند إنشاء تهجير جديد، ويمكن بهذه الطريقة التحكم بطريقة تطبيق التغييرات من خلال شيفرة البرنامج المخزنة في منظومة التحكم بالإصدار version control.

لننشئ أولًا ملف تهجير يهيئ قاعدة البيانات. إليك شيفرة هذا الملف:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('notes', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      content: {
        type: DataTypes.TEXT,
        allowNull: false
      },
      important: {
        type: DataTypes.BOOLEAN
      },
      date: {
        type: DataTypes.DATE
      },
    })
    await queryInterface.createTable('users', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      username: {
        type: DataTypes.STRING,
        unique: true,
        allowNull: false
      },
      name: {
        type: DataTypes.STRING,
        allowNull: false
      },
    })
    await queryInterface.addColumn('notes', 'user_id', {
      type: DataTypes.INTEGER,
      allowNull: false,
      references: { model: 'users', key: 'id' },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('notes')
    await queryInterface.dropTable('users')
  },
}

يعرِّف الملف الدالتين up و down؛ إذ تحدّد الأولى كيفية تعديل قاعدة البيانات عندما يجري تهجير؛ وتحدد الثانية آلية التراجع إن كان هناك مبررٌ لذلك.

يتضمن ملف التهجير ثلاثة خيارات؛ إذ يُنشئ الخيار الأول الجدول notes؛ بينما ينشئ الثاني الجدول users؛ ويضيف الخيار الثالث مفتاحًا خارجيًا إلى الجدول notes يشير إلى مُنشئ الملاحظة، وتُحدَّد التغيرات في المخطط باستدعاء توابع الكائن queryInterface.

و على نقيض النماذج، من الضروري أن تتذكر دائمًا كتابة أسماء الأعمدة والجداول بأسلوب الأفعى snake case عند تعريف ملف تهجير:

await queryInterface.addColumn('notes', 'user_id', {
  type: DataTypes.INTEGER,
  allowNull: false,
  references: { model: 'users', key: 'id' },
})

أي ستكتب أسماء الجداول والأعمدة كما تظهر تمامًا في قاعدة البيانات، بينما تُكتب في النماذج بالأسلوب التقليدي للمكتبة Sequelize وذلك عبر استخدام أسلوب سنام الجمل camel Case. خزّن شيفرة ملف التهجير في ملف يحمل الاسم "migrations/20211209_00_initialize_notes_and_users.js". لا بُد أن تُسمى ملفات التهجير أبجديًا عند إنشائها كي تكون التغيرات القديمة قبل الجديدة، وقد تبدأ اسم الملف بتاريخ وتسلسل هذا الملف وهذه طريقةٌ جيدة.

يمكننا تشغيل ملف التهجير انطلاقًا من سطر الأوامر باستخدام أداة سطر الأوامر الخاصة بالمكتبة Sequelize، لكننا قررنا تنفيذ التهجيرات يديويًا انطلاقًا من شيفرة البرنامج باستخدام المكتبة Umzug، لهذا سنثبت هذه المكتبة:

npm install umzug

لنعدّل الملف "util/db.js" الذي يتكفل بمعالجة الاتصال مع قاعدة البيانات:

const Sequelize = require('sequelize')
const { DATABASE_URL } = require('./config')
const { Umzug, SequelizeStorage } = require('umzug')

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

const runMigrations = async () => {
  const migrator = new Umzug({
    migrations: {
      glob: 'migrations/*.js',
    },
    storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }),
    context: sequelize.getQueryInterface(),
    logger: console,
  })

 const migrations = await migrator.up()
  console.log('Migrations up to date', {
    files: migrations.map((mig) => mig.name),
  })
}

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

  return null
}

module.exports = { connectToDatabase, sequelize }

تُنفّذ الدالة runMigrations ملف التهجير في كل مرة يؤسِّس فيها التطبيق اتصالًا مع قاعدة بيانات عند تشغيله، كما تراقب مكتبة Sequelize التهجيرات المُكتملة، فإذا لم يكن هناك تهجيرات جديدة، فلن ينفع تشغيل الدالة runMigrations في أي شيء.

لنبدأ كل شيء من جديد، ونزيل كل الجداول الموجودة من التطبيق:

username => drop table notes;
username => drop table users;
username => \d
Did not find any relations.

عند تشغيل التطبيق، تظهر رسالةً على الطرفية تحيطك علمًا بوضع التهجيرات:

INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name";
Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] }
database connected

إذا أعدنا تشغيل التطبيق، سيُظهر السجل أن التهجير لم يتكرر، وسيبدو مخطط قاعدة بيانات التطبيق على النحو التالي:

username=> \d
                 List of relations
 Schema |     Name     |   Type   |     Owner
--------+--------------+----------+----------------
 public | migrations   | table    | username
 public | notes        | table    | username
 public | notes_id_seq | sequence | username
 public | users        | table    | username
 public | users_id_seq | sequence | username

وهكذا نرى أن Sequelize قد أنشأت جدولًا للتهجيرات يسمح بتتبع ما نُفِّذ منها، ويبدو هذا الجدول على النحو التالي:

username=> select * from migrations;
                   name
-------------------------------------------
 20211209_00_initialize_notes_and_users.js

دعونا نضيف الآن بعض المستخدمين إلى قاعدة البيانات، إضافةً إلى مجموعة من الملاحظات، وبعدها نكون جاهزين لتوسعة التطبيق.

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

مستخدم بصلاحيات مدير وتعطيل مستخدم آخر

علينا بدايةً إضافة حقلين يقبلان قيمًا منطقية إلى الجدول users، هما:

  • admin: ويحدد فيما لو كان المستخدم مديرًا أم لا.
  • disabled: ويحدد فيما لو أُوقف نشاط هذا المستخدم أم لا.

لننشئ أيضًا ملف تهجير يُعدّل قاعدة بيانات في الملف "migrations/20211209_01_admin_and_disabled_to_users.js":

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.addColumn('users', 'admin', {
      type: DataTypes.BOOLEAN,
      default: false
    })
    await queryInterface.addColumn('users', 'disabled', {
      type: DataTypes.BOOLEAN,
      default: false
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.removeColumn('users', 'admin')
    await queryInterface.removeColumn('users', 'disabled')
  },
}

عدّل النموذج بما يتوافق مع الجدول "users":

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

يتغير المخطط كما نريد، عندما تُنفَّذ شيفرة ملف التهجير عند إعادة تشغيل التطبيق:

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 |
 admin    | boolean                |           |          |
 disabled | boolean                |           |          |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_username_key" UNIQUE CONSTRAINT, btree (username)
Referenced by:
    TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

لنوسّع الآن المتحكمات على النحو التالي، بحيث نمنع المستخدم من تسجيل الدخول في حال كانت قيمة الحقل disabled هي true:

loginRouter.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'
    })
  }

  if (user.disabled) {
    return response.status(401).json({
      error: 'account disabled, please contact admin'
    })
  }

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

  const token = jwt.sign(userForToken, process.env.SECRET)

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

دعونا نوقف نشاط المستخدم "jakousa" بالاستعانة بمعرّفه الفريد ID:

username => update users set disabled=true where id=3;
UPDATE 1
username => update users set admin=true where id=1;
UPDATE 1
username => select * from users;
 id | username |       name       | admin | disabled
----+----------+------------------+-------+----------
  2 | lynx     | Kalle Ilves      |       |
  3 | jakousa  | Jami Kousa       | f     | t
  1 | mluukkai | Matti Luukkainen | t     |

تأكد من نجاح الأمر بفشل تسجيل دخوله:

التأكد من نجاح الأمر بفشل تسجيل الدخول

لننشئ تاليًا وجهةً تسمح للمدير بتغيير حالة حساب مستخدم آخر:

const isAdmin = async (req, res, next) => {
  const user = await User.findByPk(req.decodedToken.id)
  if (!user.admin) {
    return res.status(401).json({ error: 'operation not allowed' })
  }
  next()
}

router.put('/:username', tokenExtractor, isAdmin, async (req, res) => {
  const user = await User.findOne({
    where: {
      username: req.params.username
    }
  })

  if (user) {
    user.disabled = req.body.disabled
    await user.save()
    res.json(user)
  } else {
    res.status(404).end()
  }
})

تُستخدم في هذه الشيفرة أداتان وسطيتان، تُدعى الأولى tokenExtractor وتماثل الأداة التي استخدمناها لإنشاء الوجهة route الخاصة بإنشاء ملاحظة، إذ تضع مفتاح الاستيثاق الذي فُكّ تشفيره في الحقل decodedToken من كائن الطلب؛ بينما تتحقق الأداة الثانية isAdmin فيما لوكان المستخدم ذا صلاحيات إدارية، فإن لم يكن كذلك يُضبط رمز حالة الطلب على القيمة 401 وتُعاد رسالة خطأ مناسبة.

لاحظ كيف رُبطت الأداتين إلى الوجهة، إذ تُنفَّذ كلٌ منهما قبل تنفيذ معالج الوجهة الفعلي، ويمكنك ربط العدد الذي تريده من الأدوات الوسيطة إلى طلبٍ ما.

تُنقل الأداة الوسطية tokenExtractor إلى الملف "util/middleware.js" كونها تُستخدم في عدّة مواضع:

const jwt = require('jsonwebtoken')
const { SECRET } = require('./config.js')

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{
      return res.status(401).json({ error: 'token invalid' })
    }
  } else {
    return res.status(401).json({ error: 'token missing' })
  }
  next()
}

module.exports = { tokenExtractor }

يمكن للمدير الآن السماح للمستخدم "jakousa" بمعاودة نشاطه عن طريق إرسال طلب من النوع PUT إلى الوجهة "api/users/jakousa/" إذ يأتي الطلب مع البيانات التالية:

{
    "disabled": false
}

وكما رأينا في نهاية القسم 4، تحمل هذه الطريقة في إيقاف نشاط المستخدمين بعض المشاكل، إذ يجري التحقق من وضع المستخدم عند تسجيل الدخول، وإن امتلك المستخدم مفتاح استيثاق صحيح في لحظة إيقاف نشاطه، فقد يتمكن من متابعة استخدام مفتاحه لعدم وجود فترة زمنية لصلاحيته، ولن يجري التحقق من حالته عند إضافة ملاحظة جديدة.

قبل أن نتابع، سنكتب سكربت npm يسمح لنا بالتراجع عن آخر تهجير، فقد لا ينجح كل شيء في المرة الأولى بالضرورة عند تطوير ملفات التهجير.

لنعدّل الملف "util/db.js" على النحو التالي:

const Sequelize = require('sequelize')
const { DATABASE_URL } = require('./config')
const { Umzug, SequelizeStorage } = require('umzug')

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

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

  return null
}

const migrationConf = {
  migrations: {
    glob: 'migrations/*.js',
  },
  storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }),
  context: sequelize.getQueryInterface(),
  logger: console,
}

const runMigrations = async () => {
  const migrator = new Umzug(migrationConf)
  const migrations = await migrator.up()
  console.log('Migrations up to date', {
    files: migrations.map((mig) => mig.name),
  })
}

const rollbackMigration = async () => {
  await sequelize.authenticate()
  const migrator = new Umzug(migrationConf)
  await migrator.down()
}
module.exports = { connectToDatabase, sequelize, rollbackMigration } // highlight-line

لننشئ الملف "util/rollback.js" الذي يتيح لسكربت npm تنفيذ دالة التراجع عن التهجير:

const { rollbackMigration } = require('./db')

rollbackMigration()

وسيكون السكربت على النحو التالي:

{
  "scripts": {
    "dev": "nodemon index.js",
    "migration:down": "node util/rollback.js"
  },
}

وهكذا سنتمكن من التراجع عن آخر تهجير بتنفيذ الأمر npm run migration:down من خلال سطر الأوامر.

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

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

التمرينان 13.7 و 13.8

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

التمرين 13.7

احذف كل الجداول من قاعدة بيانات تطبيقك ثم أنشئ ملف تهجير ليهيئ قاعدة البيانات. أضف البصمتان الزمنيتان "created_at" و "updated_at" إلى كلا الجدولين، وتذكر أن عليك إضافتها في ملف التهجير بنفسك.

ملاحظة: تأكد من إزالة التعليمتين ()User.sync و ()Blog.sync اللتين تزامنان مخطط النموذج من شيفرتك وإلا سيخفق التهجير.

ملاحظة: إذا كان عليك حذف الجداول باستخدام سطر الأوامر، أي إذا لم تكن تنوي التراجع عن الحذف بالتراجع عن آخر عملية تهجير، فلا بُد حينها من حذف محتوى جدول التهجير migrations إذا أردت من برنامجك تنفيذ التهجير مجددًا.

التمرين 13.18

وسّع تطبيقك (مستخدمًا ملف تهجير) لكي يكون للمدونة سمةً تدل على سنة كتابتها، أي حقلٌ بالاسم يأخذ قيمًا صحيحة تبدأ من 1991 ولا يزيد عن العام الحالي. تأكد من ظهور رسالة خطأ مناسبة إن أُدخلت قيمة غير صحيحة للسنة.

علاقات متعدد-إلى-متعدد many-to-many بين الجداول

سنتابع توسعة التطبيق كي يُضاف كل مستخدم إلى فريقٍ أو أكثر، وطالما يمكن لأي عدد من المستخدمين الانضمام إلى فريق وكذلك يمكن لأي مستخدم الانضمام إلى أي عدد من الفرقاء، فإننا أمام علاقة متعدد-إلى-متعدد many-to-many والتي تنُفّذ تقليديًا في قواعد البيانات من خلال جدول الاتصال connection table.

لنكتب الآن الشيفرة التي يحتاجها الجدول teams إضافةً إلى جدول الاتصال. سيبدو ملف التهجير أولًا على النحو التالي:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('teams', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      name: {
        type: DataTypes.TEXT,
        allowNull: false,
        unique: true
      },
    })
    await queryInterface.createTable('memberships', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      user_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'users', key: 'id' },
      },
      team_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'teams', key: 'id' },
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('teams')
    await queryInterface.dropTable('memberships')
  },
}

تتضمن النماذج نفس شيفرة ملفات التهجير تقريبًا، وإليك شيفرة نموذج الجدول team في الملف "models/team.js":

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

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

class Team extends Model {}

Team.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  name: {
    type: DataTypes.TEXT,
    allowNull: false,
    unique: true
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'team'
})

module.exports = Team

وهذه هي شيفرة نموذج جدول الاتصال الموجودة في الملف "models/membership.js":

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

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

class Membership extends Model {}

Membership.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  user_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  team_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'membership'
})

module.exports = Membership

منحنا جدول الاتصال اسمًا يصف طبيعة محتوياته membership، لكن قد لا تجد بالضرورة اسمًا يصف هذا الجدول، ولهذا يمكنك تسميته بأسماء الجداول التي يربطها تفصل بينها شرطة سفلية، مثل "user_teams" الذي يصلح أيضًا لحالتنا هذه.

أجرينا إضافةً صغيرةً على الملف "models/index.js" لربط الفرقاء والمستخدمين على مستوى الشيفرة باستخدام التابع belongsToMany:

const Note = require('./note')
const User = require('./user')
const Team = require('./team')
const Membership = require('./membership')

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

User.belongsToMany(Team, { through: Membership })
Team.belongsToMany(User, { through: Membership })

module.exports = {
  Note, User, Team, Membership
}

لاحظ الفَرق بين ملف التهجير لجدول الاتصال ونموذجه عند تعريف مفتاح خارجي، إذ تُعرَّف الحقول باستخدام أسلوب الأفعى في ملف التهجير:

await queryInterface.createTable('memberships', {
  // ...
  user_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  team_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  }
})

بينما تُعرَّف نفس الحقول في النموذج باستخدام أسلوب سنام الجمل:

Membership.init({
  // ...
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  teamId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  },
  // ...
})

لننشئ الآن بعض الفُرقاء وبعض الأعضاء باستخدام الطرفية:

insert into teams (name) values ('toska');
insert into teams (name) values ('mosa climbers');
insert into memberships (user_id, team_id) values (1, 1);
insert into memberships (user_id, team_id) values (1, 2);
insert into memberships (user_id, team_id) values (2, 1);
insert into memberships (user_id, team_id) values (3, 2);

تُضاف معلومات أعضاء الفرقاء بعد ذلك إلى الوجهة التي تعيد كل المستخدمين:

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

لاحظ أن الاستعلام الذي طُبع على شاشة الطرفية يجمع ثلاثة جداول. هذا الحل جيدٌ فعلًا، لكن ما تزال هنالك ثغرة، إذ تأتي نتيجة الاستعلام مع جميع سمات الصف المطلوب من جدول الاتصال، علمًا أننا لا نحتاجها كلها.

شكل الاستعلام الذي يطبع على شاشة الطرفية

وبقراءة توثيق Sequelize جيدًا ستجد الحل:

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

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

فكرة عن خاصيات كائن نموذج Sequelize

تظهر خاصيات نموذجنا من خلال الأسطر التالية:

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

User.belongsToMany(Team, { through: Membership })
Team.belongsToMany(User, { through: Membership })

يمكّن ذلك Sequelize من إنشاء استعلامات تستخلص مثلًا كل ملاحظات المستخدمين أو كل أعضاء فريق، وبفضل تلك التعريفات نستطيع أيضًا الوصول مباشرةً إلى ملاحظات مستخدم مثلًا من خلال الشيفرة؛ ففي الشيفرة التالية مثلًا نحاول البحث عن مستخدم معرّفه المميز id=1، ثم نطبع الملاحظات المرتبطة به:

const user = await User.findByPk(1, {
  include: {
    model: Note
  }
})

user.notes.forEach(note => {
  console.log(note.content)
})

يربط التعريف:

 User.hasMany(Note)

الخاصية notes إلى الكائن user الذي يمنح وصولًا إلى الملاحظات التي أنشأها المستخدم، ويربط التعريف:

User.belongsToMany(Team, { through: Membership })

الخاصية teams إلى الكائن user، الذي يمكن استخدامه في الشيفرة:

const user = await User.findByPk(1, {
  include: {
    model: team
  }
})

user.teams.forEach(team => {
  console.log(team.name)
})

لنفترض أننا نريد إعادة كائن JSON من وجهة route تقود إلى مستخدم واحد يتضمن اسم المستخدم وعدد الملاحظات التي أنشأها. يمكننا تجربة الطريقة التالية:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    include: {
        model: Note
      }
    }
  )

  if (user) {
    user.note_count = user.notes.length
    delete user.notes
    res.json(user)

  } else {
    res.status(404).end()
  }
})

حاولنا إضافة الحقل noteCount في الكائن الذي أعادته Sequelize وإزالة الحقل notes منه، لكن ما يحدث أن هذه الطريقة لن تنفع لأن الكائن الذي تعيده Sequelize ليس كائنًا عاديًا تعمل فيه الحقول الإضافية كما نريد؛ ولذلك فإن الحل الأفضل لحالتنا هو إنشاء كائن جديد كليًا بناءً على البيانات المستخلصة من قاعدة البيانات:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    include: {
        model: Note
      }
    }
  )

  if (user) {
    res.json({
      username: user.username,
      name: user.name,
      note_count: user.notes.length
    })

  } else {
    res.status(404).end()
  }
})

نظرة ثانية إلى العلاقات متعدد-إلى-متعدد many-to-many

سنختبر علاقة متعدد-إلى-متعدد أخرى في التطبيق؛ إذ ترتبط كل ملاحظة بالمستخدم الذي أنشأها من خلال مفتاح خارجي، ونريد الآن أن يدعم التطبيق ربط الملاحظة بمستخدمين آخرين، وربط المستخدم بعدد غير محدد من الملاحظات التي أنشأها آخرون، والفكرة هنا أن هذه الملاحظات هي تلك التي أشار المستخدم على أنها تهمّه.

لننشئ جدول الاتصال user_notes في هذه الحالة، وسيكون ملف التهجير بسيطًا:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('user_notes', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      user_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'users', key: 'id' },
      },
      note_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'notes', key: 'id' },
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('user_notes')
  },
}

كما لن تجد أفكارًا جديدةً أيضًا في شيفرة النموذج:

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

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

class UserNotes extends Model {}

UserNotes.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  noteId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'notes', key: 'id' },
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user_notes'
})

module.exports = UserNotes

يضم الملف "models/index.js" بعض التغيرات ليصبح على النحو التالي:

const Note = require('./note')
const User = require('./user')
const Team = require('./team')
const Membership = require('./membership')
const UserNotes = require('./user_notes')

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

User.belongsToMany(Team, { through: Membership })
Team.belongsToMany(User, { through: Membership })

User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' })
Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' })

module.exports = {
  Note, User, Team, Membership, UserNotes
}

يُستخدم التعريف belongsToMany مجددًا، إذ يربط الآن المستخدمين إلى الملاحظات عن طريق النموذج UserNotes المتعلق بجدول الاتصال، لكننا سنستخدم هذه المرة اسمًا بديلًا alias للسمات المُنشأة باستخدام الكلمة المحجوزة as، إذ سيتداخل overlap الاسم الافتراضي ("notes" المستخدم) مع معناه السابق وهو الملاحظات المُضافة من قِبل المستخدم.

سنوسِّع الوجهة التي تقود إلى مستخدم وحيد لتعيد الفُرقاء التي ينتمي إليها المستخدم، وملاحظاتهم، والملاحظات الأخرى التي حددها المستخدم:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        }
      },
      {
        model: Team,
        attributes: ['name', 'id'],
        through: {
          attributes: []
        }
      },
    ]
  })

  if (user) {
    res.json(user)
  } else {
    res.status(404).end()
  }
})

لا بُد من استخدام الاسم البديل الذي عرّفناه من خلال السمة as خلال السياق. سننشئ بعض البيانات الاختبارية في قاعدة البيانات لاختبار الميزة:

insert into user_notes (user_id, note_id) values (1, 4);
insert into user_notes (user_id, note_id) values (1, 5);

ستكون النتيجة النهائية على النحو التالي:

نتائج البيانات الاختبارية في قواعد البيانات

لكن ماذا لو أردنا أن نضمّن معلومات تتعلق بمؤلف الملاحظة إلى الملاحظات التي يحددها مستخدم؟ يُنفَّذ الأمر بإضافة التعليمة include إلى الملاحظات المحدّدة من قبل المستخدم:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        },
        include: {
          model: User,
          attributes: ['name']
        }
      },
      {
        model: Team,
        attributes: ['name', 'id'],
        through: {
          attributes: []
        }
      },
    ]
  })

  if (user) {
    res.json(user)
  } else {
    res.status(404).end()
  }
})

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

النتيجة النهائية في البيانات الاختبارية في قاعدة البيانات

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

التمرينات 13.19 - 13.23

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

التمرين 13.19

اِمنح المستخدمين القدرة على إضافة مدوّنات إلى قائمة قراءة reading list. وعند إضافتها إلى القائمة يجب أن تكون حالتها "غير مقروءة unreaded"، ويمكن لاحقًا تعليم المدوّنة على أنها "مقروءة". نفّذ فكرة قائمة القراءة مستخدمًا جدول اتصال، وعدّل قاعدة البيانات من خلال ملف تهجير.

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

التمرين 13.20

أضف الآن طريقةً كي يدعم التطبيق قوائم القراءة.

تُضاف المدوّنة إلى قائمة القراءة من خلال الطلب HTTP POST إلى الوجهة "api/readinglists/"، ويُرفق مع الطلب المدوّنة ومعرّف المستخدم:

{
  "blogId": 10,
  "userId": 3
}

عدّل الوجهة "GET /api/users/:id" لإعادة قائمة المدوّنات إضافة إلى معلومات المستخدم بالتنسيق التالي:

{
  name: "Matti Luukkainen",
  username: "mluukkai@iki.fi",
  readings: [
    {
      id: 3,
      url: "https://google.com",
      title: "Clean React",
      author: "Dan Abramov",
      likes: 34,
      year: null,
    },
    {
      id: 4,
      url: "https://google.com",
      title: "Clean Code",
      author: "Bob Martin",
      likes: 5,
      year: null,
    }
  ]
}

حتى هذه اللحظة، لا حاجة لإظهار إن كانت المدوّنة مقروءةً أم لا.

التمرين 13.21

عدّل الوجهة التي تصل إلى مستخدم وحيد لكي تعرض فيما إذا كانت كل مدوّنة في قائمة القراءة مقروءةً أم لا، إضافةً إلى المُعرَّف المميز "id" للصف المقابل في جدول الاتصال.

يمكن عرض المعلومات وفق التنسيق الآتي مثلًا:

{
  name: "Matti Luukkainen",
  username: "mluukkai@iki.fi",
  readings: [
    {
      id: 3,
      url: "https://google.com",
      title: "Clean React",
      author: "Dan Abramov",
      likes: 34,
      year: null,
      readinglists: [
        {
          read: false,
          id: 2
        }
      ]
    },
    {
      id: 4,
      url: "https://google.com",
      title: "Clean Code",
      author: "Bob Martin",
      likes: 5,
      year: null,
      readinglists: [
        {
          read: false,
          id: 2
        }
      ]
    }
  ]
}

التمرين 13.22

قدّم طريقةً يستطيع من خلالها التطبيق تعليم مدوّنة ضمن قائمة القراءة على أنها مقروءة، إذ يُنفَّذ الأمر بإجراء طلب PUT إلى الوجهة "api/readinglists/:id/" وإرساله مع القيمة:

{ "read": true }

يمكن للمستخدم أن يعلّم مدوّنة على أنها مقروءةً إذا كانت فقط ضمن قائمة القراءة الخاصة به. يستوثق من المستخدم عادةً من خلال مفتاح الاستيثاق الذي يُرفق مع الطلب.

التمرين 13.23

عدّل الوجهة التي تعيد معلومات مستخدم وحيد لكي يتحكم الطلب بالمدوّنة التي ينبغي إحضارها من قائمة القراءة:

  • "GET /api/users/:id": يعيد كامل قائمة القراءة.
  • "GET /api/users/:id?read=true": يعيد المدوّنات المقروءة.
  • "GET /api/users/:id?read=false": يعيد المدوّنات غير المقروءة.

ملاحظات عامة

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

إحضار البيانات الكسول lazy والمتلهف eager

عندما ننشئ استعلامًا مستخدمين السمة include:

User.findOne({
  include: {
    model: note
  }
})

يحدُث ما يُسمى الإحضار المُتلهِّف eager fetch للبيانات، إذ تُجلب جميع الصفوف في كل الجداول المرتبطة بالمستخدم بواسطة الاستعلام join بنفس الوقت في مثال الملاحظات التي يُنشئها مستخدم. هذا السلوك هو ما نحتاجه عادةً، لكن ستجد في المقابل حالات تحتاج فيها إلى ما يُدعى بالإحضار الكسول أو المحدود lazy fetch مثل البحث عن فُرقاء مرتبطةٍ بمستخدم إذا لزم الأمر.

لنعدّل وجهة إحضار مستخدم واحد كي تُحضر الفُرقاء التي ينتمي إليها مستخدم إذا احتوى الاستعلام على المعامل teams:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        },
        include: {
          model: user,
          attributes: ['name']
        }
      },
    ]
  })

  if (!user) {
    return res.status(404).end()
  }

  let teams = undefined
  if (req.query.teams) {
    teams = await user.getTeams({
      attributes: ['name'],
      joinTableAttributes: []
      })
  }
  res.json({ ...user.toJSON(), teams })
})

وهكذا لن يحضر الاستعلام User.findByPk الفُرقاء، لكنها ستُجلب عند الحاجة باستخدام التابع user.getTeams الذي تولِّده Sequelize تلقائيًا لكائن النموذج. تولَّد Sequelize تلقائيًا توابع -get مماثلة وتوابع أخرى مفيدة عندما تٌعرّف ارتباطات associations بين الجداول على مستوى قاعدة بيانات.

ميزات النموذج

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

class User extends Model {}

User.init({
  // field definition
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user',
  defaultScope: {
    where: {
      disabled: false
    }
  },
  scopes: {
    admin: {
      where: {
        admin: true
      }
    },
    disabled: {
      where: {
        disabled: true
      }
    }
  }
})

module.exports = User

سيضم الاستعلام الناتج عن التابع ()User.findAll عبارة WHERE التالية:

WHERE "user". "disabled" = false;

يمكن أن نعرّف أيضًا مجالات رؤية أخرى للنماذج:

User.init({
  // field definition
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user',
  defaultScope: {
    where: {
      disabled: false
    }
  },
  scopes: {
    admin: {
      where: {
        admin: true
      }
    },
    disabled: {
      where: {
        disabled: true
      }
    },
    name(value) {
      return {
        where: {
          name: {
            [Op.iLike]: value
          }
        }
      }
    },
  }
})

تُستخدم مجالات الرؤية على النحو التالي:

// جميع المدراء
const adminUsers = await User.scope('admin').findAll()

// جميع المستخدمين غير النشطين
const disabledUsers = await User.scope('disabled').findAll()

// في أسمائهم jami المستخدمون الذين لديهم سلسلة نصية
const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll()

كما يمكن سلسلة مجالات الرؤية (ربطها ببعضها):

// في أسمائهم jami المدراء الذين لديهم سلسلة نصية
const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll()

وطالما أن نماذج هي أصناف جافا سكربت JavaScript، من الممكن إضافة توابع جديدة إليها، وإليك مثالين عن ذلك:

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

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

class User extends Model {
  async number_of_notes() {
    return (await this.getNotes()).length
  }
  static async with_notes(limit){
    return await User.findAll({
      attributes: {
        include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]]
      },
      include: [
        {
          model: Note,
          attributes: []
        },
      ],
      group: ['user.id'],
      having: sequelize.literal(`COUNT(notes.id) > ${limit}`)
    })
  }
}

User.init({
  // ...
})

module.exports = User

التابع الأول numberOfNotes هو تابع نسخة instance method، أي أن استدعاءه ممكن من نسخٍ instances عن النموذج:

const jami = await User.findOne({ name: 'Jami Kousa'})
const cnt = await jami.number_of_notes()
console.log(`Jami has created ${cnt} notes`)

تشير الكلمة this في تابع النسخة إلى نسخة النموذج نفسها:

async number_of_notes() {
  return (await this.getNotes()).length
}

أما التابع الثاني للنموذج، فيعيد هؤلاء المستخدمين الذين يملكون على الأقل "X" وهي القيمة التي يحملها المعامل وتدل على كمية الملاحظات في الصنف، أي التي تُستدعى مباشرةً عن طريق النموذج:

const users = await User.with_notes(2)
console.log(JSON.stringify(users, null, 2))
users.forEach(u => {
  console.log(u.name)
})

قابلية التكرار في النماذج وملفات التهجير

لقد رأينا أن الشيفرة في النموذج أو ملف التهجير تتكرر كثيرًا، فلو أخذنا نموذج الفُرقاء teams:

class Team extends Model {}

Team.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  name: {
    type: DataTypes.TEXT,
    allowNull: false,
    unique: true
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'team'
})

module.exports = Team

وملف التهجير فإنهما يضمان كمًّا كبيرًا من نفس الشيفرة:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('teams', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      name: {
        type: DataTypes.TEXT,
        allowNull: false,
        unique: true
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('teams')
  },
}

هل يمكن تحسين الشيفرة كي يُصدّر النموذج مثلًا الأجزاء المشتركة التي يحتاجها ملف التهجير؟

تكمن المشكلة في احتمال تغيُّر تعريف النموذج مع الوقت، فقد يتغير مثلًا الحقل أو يتغير نوع البيانات المُخزّنة فيه، وعلى ملف التهجير أن يُنفَّذ بنجاح في أي وقت من البداية إلى النهاية. إذا اعتمَدت ملفات التهجير على النموذج في الحصول على محتوى معين، فقد لا يكون هذا متاحًا خلال شهر أو سنة؛ لهذا ورغم وجود الكثير من الشيفرة للنسخ واللصق، لكن يُعد فصل ملف التهجير عن النموذج كاملًا أمرًا ضروريًا.

قد يكون أحد الحلول هو استخدام أداة سطر أوامر Sequelize الذي يولّد كلًا من النموذج وملف التهجير بناءً على الأوامر التي تُنفِّذها، إذ سيُنفِّذ الأمر التالي النموذج User الذي يمتلك السمات name و username و admin، إضافةً إلى ملف التهجير الذي يدير شؤون إنشاء جدول قاعدة البيانات:

npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean

يمكننا أيضًا تنفيذ أمر التراجع عن التهجيرات انطلاقًا من سطر الأوامر.

لم يكتمل بعد لسوء الحظ توثيق سطر الأوامر هذا، لهذا قررنا إنشاء النماذج وملفات التهجير يدويًا في المنهاج، وقد يكون أو لايكون ما أنجزناه من حلول جيدًا.

التمرين 13.24

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

الحل الإعتيادي للمشكلة هو تخزين سجلات بكل مفتاح استيثاق مُنح إلى عميل في قاعدة بيانات الواجهة الخلفية، ثم التحقق من صلاحية كل طلب، ويمكن في هذه الحالة إزالة صلاحية هذا المفتاح مباشرةً عند الحاجة. يُشار إلى هذا الأسلوب عادةً بجلسة عمل على الخادم server-side session.

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

قد تحتاج إلى ما يلي لإنجاز الأمر:

  • عمودٌ يضم قيمًا منطقية في جدول المستخدمين يشير إلى كون المستخدم نشطًا أم لا. يكفي في تمريننا أن توقف نشاط مستخدم أو تعيده من خلال قاعدة البيانات مباشرةً.
  • جدولٌ يُخزّن جلسات العمل الجارية
  • تُخزَّن الجلسة عند تسجيل الدخول (عند تنفيذ الطلب "POST /api/login").
  • يجري التحقق من وجود جلسة أو صلاحيتها عندما يُنفِّذ المستخدم عمليةً تتطلب تسجيل دخول.
  • وجهةٌ تسمح للمستخدم بتسجيل خروجه من المنظومة لإزالة الجلسة من قاعدة البيانات، وقد يكون للوجهة المسار التالي "DELETE /api/logout".

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

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

ترجمة -وبتصرف- للفصل migrations, many-to-many relationships من سلسلة 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.


×
×
  • أضف...