الجزء 14: Next.js

قواعد البيانات والترحيلات والعلاقات

النشر إلى Vercel

في أجزاء سابقة من Full Stack Open، نشرنا الواجهة الخلفية على Render أو Fly.io. ومع Next.js، يكون هدف النشر الأكثر طبيعية هو Vercel، الشركة التي أنشأت Next.js وتواصل صيانته. فمنصّة Vercel مبنية خصيصاً حول Next.js، لذا تعمل ميزات مثل مكوّنات الخادم (Server Components) وإجراءات الخادم (Server Actions) والعرض الثابت وإعادة التحقق مباشرةً دون أي إعداد إضافي.

لنشر التطبيق، ادفع أولاً شيفرة تطبيق الملاحظات إلى مستودع GitHub. ثم:

  • اذهب إلى vercel.com وسجّل حساباً جديداً (أو سجّل الدخول) باستخدام حسابك على GitHub
  • اضغط Add New... ثم Project
  • اختر مستودع GitHub الذي يحتوي على تطبيق Next.js (لاحظ أنه قد يستغرق بعض الوقت حتى يظهر مستودعك)
  • يكتشف Vercel تلقائياً أنه مشروع Next.js ويهيّئ إعدادات البناء. ما عليك سوى الضغط على Deploy

هذا كل شيء. بعد بناء قصير، يمنحك Vercel رابطاً عاماً (شيء مثل https://notes-app-yourname.vercel.app) يكون تطبيقك متاحاً عليه:

صورة توضيحية

في كل مرة تدفع فيها commits جديدة إلى الفرع الرئيسي، يعيد Vercel بناء التطبيق ونشره تلقائياً. وإذا دفعت إلى فرع مختلف أو فتحت طلب سحب، ينشئ Vercel نشراً تجريبياً (preview deployment) برابط فريد خاص به، حتى تتمكن من اختبار التغييرات قبل دمجها.

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

والسبب أن مكوّنات الخادم وإجراءات الخادم تعمل على Vercel كـ دوال بلا خادم (serverless functions). وقد يتولّى معالجة كل طلب نسخة مختلفة من الدالة، وهذه النسخ لا تتشارك الذاكرة. ويمكن تشغيلها أو إيقافها في أي وقت. وطريقتنا الحالية في تخزين الملاحظات داخل مصفوفة JavaScript في الذاكرة معطوبة جوهرياً في هذه البيئة. فعندما تضيف إجراءات الخادم ملاحظة إلى المصفوفة، يوجد هذا التغيير في ذاكرة نسخة واحدة بعينها فقط. وقد يتولّى معالجة الطلب التالي نسخة مختلفة تماماً تملك نسخة جديدة من المصفوفة الأصلية المكتوبة في الشيفرة. وحتى لو تصادف أن تولّت النسخة نفسها معالجة الطلبين، فسيُوقف تشغيلها في النهاية بعد فترة خمول، وتُفقد كل البيانات الموجودة في الذاكرة.

أثناء التطوير باستخدام npm run dev، يعمل كل شيء في عملية Node.js واحدة طويلة العمر، لذا تعمل المصفوفة الموجودة في الذاكرة بشكل مثالي. وهذه فجوة أخرى بين التطوير والإنتاج ينبغي أن تكون على دراية بها.

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

إنشاء قاعدة بيانات

في أجزاء سابقة من Full Stack Open، استخدمنا MongoDB قاعدةً للبيانات. وفي الجزء 13 انتقلنا إلى قاعدة بيانات علائقية هي PostgreSQL. وقواعد البيانات العلائقية هي اختيارنا الآن أيضاً.

يوفّر Vercel قاعدة بيانات PostgreSQL مُدارة تُسمّى Vercel Postgres، وتعمل بقوة Neon. إنها قاعدة بيانات PostgreSQL بلا خادم تتكامل بسلاسة مع عمليات النشر على Vercel، ما يجعلها مناسبة تماماً لتطبيق Next.js لدينا.

لإنشاء قاعدة بيانات، انتقل إلى لوحة تحكم Vercel واتبع هذه الخطوات:

  • افتح المشروع الذي نشرته سابقاً
  • انتقل إلى تبويب Storage
  • اضغط Create Database
  • اختر Postgres (Neon) واضغط Continue
  • اختر منطقة قريبة من مكان النشر، ثم اضغط Create
  • أعطِ قاعدة البيانات اسماً (مثل notes-db)، واضغط Create

بعد الإنشاء، يضيف Vercel تلقائياً متغيرات البيئة الخاصة بالاتصال إلى مشروعك. يمكنك رؤيتها ضمن Settings > Environment Variables. وأهمها DATABASE_URL الذي يحتوي على نص الاتصال الكامل بقاعدة بيانات PostgreSQL لديك.

لاستخدام قاعدة البيانات نفسها محلياً أثناء التطوير، انسخ نص الاتصال من لوحة تحكم Vercel وأنشئ ملف .env.local في جذر مشروعك:

DATABASE_URL="postgresql://user:password@host:5432/dbname?sslmode=require"


استبدل القيمة بنص الاتصال الفعلي من لوحة تحكم Vercel. يحمّل Next.js ملف .env.local تلقائياً أثناء التطوير، لذا لا حاجة إلى أي إعداد إضافي.

مهم: أضف .env.local إلى ملف .gitignore حتى لا تُحفظ بيانات اعتماد قاعدة البيانات أبداً في نظام التحكم بالإصدارات:

echo ".env.local" >> .gitignore

الوصول إلى قاعدة البيانات باستخدام Drizzle ORM

في Full Stack Open: قواعد البيانات العلائقية استخدمنا Sequelize مكتبةً للـ ORM (الربط الكائني-العلائقي) للوصول إلى قاعدة بيانات PostgreSQL. وسنستخدم هذه المرة Drizzle ORM، الذي أصبح من أكثر الخيارات شيوعاً في منظومة Next.js.

لماذا Drizzle بدلاً من Sequelize؟ صُمّم Drizzle بحيث يكون TypeScript مواطناً من الدرجة الأولى. فمخطط قاعدة البيانات يُعرَّف بـTypeScript، ويولّد Drizzle نتائج استعلامات منوّعة بالكامل تلقائياً. وهذا يعني أنه إذا قال مخططك إن للملاحظة content (نص) و important (قيمة منطقية)، فستكون كل نتيجة استعلام منوّعة وفقاً لذلك، ويلتقط المترجم الأخطاء قبل أن تشغّل الشيفرة أصلاً. كما ينتج Drizzle استعلامات SQL قريبة جداً مما كنت ستكتبه يدوياً، ما يسهّل فهم ما يحدث في الخلفية. وأخيراً، يوفّر Drizzle دعماً ممتازاً للبيئات بلا خادم مثل Vercel، حيث يجب إدارة الاتصالات بعناية.

لنهيّئ Drizzle لمشروعنا. أولاً، ثبّت الحزم المطلوبة:

npm install drizzle-orm @neondatabase/serverless
npm install -D drizzle-kit

الحزمة drizzle-orm هي الـ ORM نفسه. والحزمة @neondatabase/serverless هي مشغّل PostgreSQL المُحسَّن للبيئات بلا خادم مثل Vercel، حيث تعمل قاعدة البيانات على Neon. والحزمة drizzle-kit أداة سطر أوامر لإدارة ترحيلات قاعدة البيانات.

بعد ذلك، نعرّف مخطط قاعدة البيانات. ننشئ ملفاً باسم db/schema.ts بالشكل التالي:

import { pgTable, serial, text, boolean } from "drizzle-orm/pg-core"

export const notes = pgTable("notes", {
  id: serial("id").primaryKey(),
  content: text("content").notNull(),
  important: boolean("important").notNull().default(false),
})

يعرّف هذا جدولاً يُسمّى notes بثلاثة أعمدة:

  • id من النوع serial أي أنه عدد صحيح يزداد تلقائياً، وهو مُعلَّم باعتباره المفتاح الرئيسي للجدول
  • content من النوع text الذي يقابل نوع TEXT في SQL، وهو مُعلَّم بـ not null أي أنه يجب أن يكون لكل ملاحظة محتوى
  • important من النوع boolean الذي يخزّن true أو false، وهو أيضاً not null وله قيمة افتراضية هي false

لاحظ كيف أن المخطط ليس سوى TypeScript. لا توجد صيغة منفصلة لتتعلمها!

بعد ذلك ننشئ اتصال قاعدة البيانات في الملف db/index.ts:

import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "./schema"

export const db = drizzle(process.env.DATABASE_URL!, { schema })

دالة drizzle من drizzle-orm/neon-http تنشئ اتصالاً بقاعدة البيانات مناسباً للبيئات بلا خادم باستخدام نص الاتصال من متغير البيئة لدينا. ومشغّل neon-http مُحسَّن للبيئات بلا خادم: فهو يتواصل مع قاعدة بيانات Neon عبر HTTP، لذا لا يوجد اتصال دائم يجب إدارته. والنتيجة المُصدَّرة db هي مُنشئ استعلامات منوّع يمكننا استخدامه في كل أنحاء تطبيقنا.

وأخيراً، نحتاج إلى ملف إعدادات Drizzle لأدوات الترحيل، ننشئه في الملف drizzle.config.ts في جذر المشروع:

import { defineConfig } from "drizzle-kit"

import * as dotenv from "dotenv"
dotenv.config({ path: ".env.local" })

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
})

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

نحتاج أيضاً إلى تثبيت حزمتين إضافيتين:

npm install --save-dev dotenv postgres

نحتاج إلى حزمة dotenv لأن أوامر Drizzle Kit CLI تعمل كسكربتات Node.js عادية خارج بيئة تشغيل Next.js. فـNext.js يحمّل ملف .env.local تلقائياً لشيفرة تطبيقك، لكن Drizzle Kit لا يستفيد من ذلك. وباستيراد dotenv واستدعاء dotenv.config({ path: ".env.local" }) في أعلى drizzle.config.ts، نجعل المتغير DATABASE_URL متاحاً لـDrizzle Kit عند اتصاله بقاعدة البيانات لتشغيل الترحيلات أو فتح Drizzle Studio.

يستخدم تطبيقنا مشغّل @neondatabase/serverless الذي يتواصل مع قاعدة البيانات عبر HTTP. غير أن أوامر Drizzle Kit CLI تحتاج إلى اتصال أكثر تقليدية بقاعدة البيانات. وبدون تثبيت حزمة postgres ستطبع أوامر Drizzle Kit تحذيرات عن بعض الاعتماديات الناقصة.

الآن يمكننا توليد الترحيل وتطبيقه على قاعدة البيانات:

npx drizzle-kit generate
npx drizzle-kit migrate

يقرأ الأمر الأول المخطط ويولّد ملفات ترحيل SQL في مجلد drizzle. ويطبّق الأمر الثاني تلك الترحيلات على قاعدة البيانات، فينشئ جدول notes.

قد تتساءل ما هي الترحيلات. لقد غُطّي هذا الموضوع في Full Stack Open: قواعد البيانات العلائقية، وسنعود إليه قريباً، فلا تقلق بعد!

يمكننا التحقق من إنشاء الجدول بتنفيذ:

npx drizzle-kit studio

يفتح هذا Drizzle Studio، وهو متصفح مرئي لقاعدة البيانات على العنوان https://local.drizzle.studio حيث يمكننا رؤية جدول notes وأعمدته.

لنضف أيضاً بضع ملاحظات إلى قاعدة البيانات:

صورة توضيحية

الآن نحن مستعدون لتغيير التطبيق ليستخدم قاعدة البيانات. لنبدأ بقائمة الملاحظات.

نغيّر app/services/notes.ts على النحو التالي:

import { eq } from "drizzle-orm"
import { db } from "../../db"
import { notes } from "../../db/schema"

export const getNotes = async () => {
  return db.query.notes.findMany()
}

<em>// ...</em>

تستخدم الدالة getNotes واجهة الاستعلامات العلائقية في Drizzle عبر db.query. ويقرأ الاستدعاء db.query.notes.findMany() كل الصفوف من جدول notes. وهو يقابل استعلام SQL التالي:

SELECT id, content, important FROM notes;

لاحظ أن الدالة أصبحت async لأن استعلام قاعدة البيانات يعيد promise. ويستنتج Drizzle نوع الإرجاع تلقائياً من المخطط، لذا يعرف TypeScript أن النتيجة مصفوفة من الكائنات فيها id (عدد) و content (نص) و important (قيمة منطقية).

يجب تعديل app/notes/page.tsx ليأخذ في الحسبان أن الدالة أصبحت async:

import Link from "next/link"
import { getNotes } from "../services/notes"

const Notes = async ({
  searchParams,
}: {
  searchParams: Promise<{ important?: string }>
}) => {
  const { important } = await searchParams
  const showImportant = important === "true"
  const allNotes = await getNotes()
  const notes = showImportant
    ? allNotes.filter((note) => note.important)
    : allNotes

  return (
    <em>// ...</em>
  )
}

الآن تجلب صفحة الملاحظات بياناتها من قاعدة البيانات:

صورة توضيحية

لنغيّر الآن بقية الدوال في app/services/notes.ts لتستخدم قاعدة البيانات:

export const getNoteById = async (id: number) => {
  return db.query.notes.findFirst({
    where: eq(notes.id, id),
  })
}

تستخدم الدالة getNoteById الطريقة findFirst. وهي تعيد أول صف مطابق، أو undefined إذا لم يطابق أي صف. وتستخدم جملة where المساعد eq من drizzle-orm لإنشاء شرط مساواة. و SQL المقابل هو:

SELECT id, content, important FROM notes WHERE id = 1;

ويصبح إنشاء ملاحظة جديدة:

export const addNote = async (content: string, important: boolean) => {
  await db.insert(notes).values({ content, important })
}

تستخدم الدالة addNote مُنشئ الاستعلامات insert في Drizzle. ويدرج الاستدعاء db.insert(notes).values(...) صفاً جديداً في جدول notes. و SQL المقابل هو:

INSERT INTO notes (content, important) VALUES ('some content', true);

لا حاجة إلى تقديم id لأن العمود معرّف كـ serial، فتولّده قاعدة البيانات تلقائياً.

export const toggleImportance = async (id: number) => {
  const note = await getNoteById(id)
  if (note) {
    await db
      .update(notes)
      .set({ important: !note.important })
      .where(eq(notes.id, id))
  }
}

تجلب الدالة toggleImportance الملاحظة أولاً لقراءة قيمة important الحالية لها، ثم تستخدم مُنشئ الاستعلامات update في Drizzle لقلبها. ويحدّث الاستدعاء db.update(notes).set(...).where(...) الصفوف المطابقة. و SQL المقابل هو:

UPDATE notes SET important = NOT important WHERE id = 1;

بما أن كل الدوال في services/notes.ts أصبحت async، نحتاج إلى إجراء تغييرات مقابلة في بضعة ملفات. فـ app/notes/[id] يحتاج إلى await

const NotePage = async ({ params }: { params: Promise<{ id: string }> }) => {
  const { id } = await params
  const note = await getNoteById(Number(id))

  <em>// ...</em>
}

كما تحتاج إجراءات الخادم إلى المعالجة نفسها:

export const createNote = async (formData: FormData) => {
  const content = formData.get("content") as string
  const important = formData.get("important") === "on"
  await addNote(content, important)

  revalidatePath("/notes")
  redirect("/notes")
}

export const toggleNoteImportance = async (formData: FormData) => {
  const id = Number(formData.get("id"))
  await toggleImportance(id)
  revalidatePath(`/notes/${id}`)
  revalidatePath("/notes")
}

هناك أمر آخر يجب ملاحظته قبل أن نكمل. يجلب المكوّن Notes دائماً كل الملاحظات من قاعدة البيانات، وتُجرى التصفية المحتملة للملاحظات المهمة في JavaScript بعد الاستعلام:

const Notes = async ({
  searchParams,
}: {
  searchParams: Promise<{ important?: string }>
}) => {
  const { important } = await searchParams
  const showImportant = important === "true"

  const allNotes = await getNotes()
  const notes = showImportant
    ? allNotes.filter((note) => note.important)
    : allNotes


  return (
    <em>// ...</em>
  )
}

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

export const getNotes = async (importantOnly: boolean) => {
  if (importantOnly) {
    return db.query.notes.findMany({
      where: eq(notes.important, true),
    })
  }

  return db.query.notes.findMany()
}

عندما تكون importantOnly قيمتها true، يُنفَّذ الاستعلام مع جملة where(eq(notes.important, true)) التي تقابل SQL:

SELECT id, content, important FROM notes WHERE important = true;

وعندما تكون importantOnly قيمتها false، تعيد الدالة كل الملاحظات دون أي تصفية، كما كان الحال سابقاً.

يمرّر المكوّن Notes الآن راية showImportant مباشرةً إلى getNotes، وتُزال منطقية التصفية التي كانت تُجرى سابقاً في JavaScript:

const Notes = async ({
  searchParams,
}: {
  searchParams: Promise<{ important?: string }>
}) => {
  const { important } = await searchParams
  const showImportant = important === "true"
  const notes = await getNotes(showImportant)

  return (
    <em>// ....</em>
  )
}

هذا أنظف وأكثر كفاءة: فبدلاً من جلب كل الصفوف والتخلص من بعضها في JavaScript، ندع قاعدة البيانات تعيد الصفوف التي نحتاجها فقط.

تجد الشيفرة الحالية للتطبيق على GitHub في الفرع part5.

الترحيلات

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

قبل أن نجري هذه التغييرات، لنلقِ نظرة أقرب على ترحيلات قاعدة البيانات، وهو مفهوم مررنا عليه سريعاً عند إعداد قاعدة البيانات لأول مرة.

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

بدون الترحيلات، كنت ستحتاج إلى تنفيذ عبارات SQL يدوياً مثل CREATE TABLE أو ALTER TABLE في كل بيئة. وهذا عرضة للخطأ ويستحيل تتبعه في نظام التحكم بالإصدارات. أما مع الترحيلات، فتعيش تغييرات المخطط جنباً إلى جنب مع شيفرة تطبيقك في مستودع Git، ويمكن تطبيقها تلقائياً أثناء النشر.

عندما هيّأنا Drizzle لأول مرة، نفّذنا أمرين:

npx drizzle-kit generate
npx drizzle-kit migrate

الأمر الأول، drizzle-kit generate، يقارن مخطط TypeScript الحالي (في db/schema.ts) بالترحيلات المولّدة سابقاً وينتج ملف ترحيل SQL جديداً في مجلد drizzle. وإذا نظرنا إلى ذلك المجلد، سنجد شيئاً مثل:

drizzle/
├── 0000_neat_captain_america.sql
└── meta/
    ├── _journal.json
    └── 0000_snapshot.json

يحتوي الملف 0000_neat_captain_america.sql على SQL الفعلي الذي وُلّد من مخططنا:

CREATE TABLE "notes" (
	"id" serial PRIMARY KEY NOT NULL,
	"content" text NOT NULL,
	"important" boolean DEFAULT false NOT NULL
);

يحتوي مجلد meta على بيانات وصفية يستخدمها Drizzle Kit لتتبع الترحيلات التي وُلّدت وكيف كان شكل المخطط عند كل نقطة.

الأمر الثاني drizzle-kit migrate يتصل بقاعدة البيانات وينفّذ أي ملفات ترحيل لم تُطبَّق بعد. ويتتبع Drizzle الترحيلات التي نُفّذت بتخزين معرّفاتها في جدول خاص يُسمّى __drizzle_migrations في قاعدة البيانات. وبهذه الطريقة، يكون تنفيذ drizzle-kit migrate عدة مرات آمناً: فهو يطبّق الترحيلات الجديدة فقط.

سير العمل لتغيير المخطط هو دائماً نفسه:

  • عدّل مخطط TypeScript في app/db/schema.ts
  • نفّذ npx drizzle-kit generate لإنشاء ملف ترحيل جديد
  • راجع SQL المولّد للتأكد من صحة مظهره
  • نفّذ npx drizzle-kit migrate لتطبيق الترحيل على قاعدة بياناتك

الخطوة الثالثة مهمة. تحقق دائماً مما ولّده Drizzle قبل تنفيذه على قاعدة بياناتك. يبذل Drizzle قصارى جهده لاستنتاج SQL الصحيح، لكن خصوصاً مع التغييرات المدمّرة (حذف أعمدة، إعادة تسمية جداول)، ينبغي أن تتحقق من المخرجات.

7. النشر إلى Vercel

8. DrizzleORM وقاعدة بيانات

المستخدمون

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

يتغيّر schema.ts على النحو التالي. الجدول الجديد users له ثلاثة أعمدة:

export const users = pgTable("users", {
  id: serial("id").primaryKey(),
  username: text("username").notNull().unique(),
  name: text("name").notNull(),
})

لعمود username قيد إضافي هو unique، ما يعني أنه لا يمكن أن يكون لمستخدمين اثنين اسم المستخدم نفسه. وبمصطلحات SQL، سيولّد Drizzle قيد UNIQUE على هذا العمود، وسترفض قاعدة البيانات أي إدراج ينشئ تكراراً.

يكتسب جدول notes عموداً جديداً هو userId يربط كل ملاحظة بالمستخدم الذي أنشأها:

export const notes = pgTable("notes", {
  id: serial("id").primaryKey(),
  content: text("content").notNull(),
  important: boolean("important").notNull().default(false),

  userId: integer("user_id").references(() => users.id),

})

عمود userId من النوع integer ويستخدم الدالة references لإنشاء قيد مفتاح أجنبي يشير إلى عمود id في جدول users. ويخبر هذا قاعدة البيانات بأن كل قيمة في user_id يجب أن تقابل صفاً موجوداً في جدول users. وإذا حاول أحدهم إدراج ملاحظة بقيمة user_id غير موجودة في users، فسترفضها قاعدة البيانات. ويعني المفتاح الأجنبي أيضاً أنه لا يمكن حذف مستخدم لا تزال له ملاحظات تشير إليه.

لاحظ كيف أن العمود مُسمّى userId في TypeScript (camelCase) لكنه يقابل user_id في قاعدة البيانات (snake_case). وهذا اصطلاح شائع: تستخدم شيفرة TypeScript صيغة camelCase بينما يستخدم SQL صيغة snake_case، ويتولى Drizzle هذا الربط عبر وسيط النص الممرَّر إلى integer("user_id").

الآن نولّد الترحيل ونطبّقه:

npx drizzle-kit generate
npx drizzle-kit migrate

إذا فحصنا ملف الترحيل المولّد، سنرى شيئاً مثل:

CREATE TABLE "users" (
	"id" serial PRIMARY KEY NOT NULL,
	"username" text NOT NULL,
	"name" text NOT NULL,
	CONSTRAINT "users_username_unique" UNIQUE("username")
);

ALTER TABLE "notes" ADD COLUMN "user_id" integer;<em>--> statement-breakpoint</em>
ALTER TABLE "notes" ADD CONSTRAINT "notes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

ينشئ الترحيل جدول users ويضيف عمود user_id مع قيد المفتاح الأجنبي الخاص به إلى جدول notes الموجود. لاحظ أن Drizzle لا يعيد إنشاء جدول notes من الصفر. بل يقارن المخطط الجديد بلقطة الترحيل السابقة ويولّد فقط عبارات ALTER TABLE اللازمة لجعل قاعدة البيانات متوافقة.

لننشئ مستخدماً من Drizzle Studio، ونربطه بالملاحظات الموجودة أصلاً في قاعدة البيانات:

صورة توضيحية

الآن يمكننا إجراء تغيير صغير على مخططنا: نريد تعديله بحيث يصبح المفتاح الأجنبي user_id في جدول notes مطلوباً وألا يقبل القيمة الفارغة:

export const notes = pgTable("notes", {
  id: serial("id").primaryKey(),
  content: text("content").notNull(),
  important: boolean("important").notNull().default(false),
  userId: integer("user_id").notNull().references(() => users.id),
})

مرة أخرى، نولّد الترحيل:

npx drizzle-kit generate

عند فحص SQL المولّد في الترحيل، نرى عبارة واحدة:

ALTER TABLE "notes" ALTER COLUMN "user_id" SET NOT NULL;

هذا بالضبط ما توقعناه: العمود موجود بالفعل، لذا يحتاج Drizzle فقط إلى إضافة قيد NOT NULL. وبما أننا ربطنا بالفعل كل الملاحظات الموجودة بمستخدم في Drizzle Studio، فلا يوجد أي صف بقيمة user_id فارغة، وسينجح الترحيل. ولو كانت أي ملاحظات لا تزال بقيمة user_id فارغة، لرفضت قاعدة البيانات عبارة ALTER TABLE بخطأ.

يمكننا الآن تطبيق الترحيل:

npx drizzle-kit migrate

ملاحظات المستخدمين

لننفّذ الآن صفحتين جديدتين، الأولى تعرض قائمة مستخدمي التطبيق. وهي مباشرة إلى حد بعيد. يجب أن يكون المكوّن Users على المسار /users، ولإتباع اصطلاح موجّه التطبيقات في Next.js نضعه في الملف app/users/page.tsx:

import Link from "next/link"
import { getUsers } from "../services/users"

const Users = async () => {
  const users = await getUsers()

  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link href={`/users/${user.id}`}>{user.name}</Link>
          </li>
        ))}
      </ul>
    </div>
  )
}

اسم المستخدم رابط إلى صفحة المستخدم الفردية التي سننفّذها قريباً.

الدالة getUsers في الملف services/users.ts مباشرة وبسيطة:

import { db } from "../../db"
import { users } from "../../db/schema"

export const getUsers = async () => {
  return db.query.users.findMany()
}

يُضاف الرابط إلى صفحة المستخدمين أيضاً إلى app/layout.tsx. والنتيجة النهائية تبدو هكذا:

صورة توضيحية

وبعد ذلك صفحة المستخدم الفردي. نريد أن نعرض فيها أيضاً الملاحظات المرتبطة بذلك المستخدم.

باتّباع اصطلاحات موجّه التطبيقات في Next.js، يُنفَّذ المكوّن UserPage في الملف app/users/[id]/page.tsx

import Link from "next/link"
import { notFound } from "next/navigation"
import { getUserById } from "../../services/users"

const UserPage = async ({ params }: { params: Promise<{ id: string }> }) => {
  const { id } = await params
  const user = await getUserById(Number(id))

  if (!user) {
    notFound()
  }

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Username: {user.username}</p>
      <h3>Notes</h3>
      <ul>
        {user.notes.map((note) => (
          <li key={note.id}>
            <Link href={`/notes/${note.id}`}>{note.content}</Link>
            {note.important && <strong> (important)</strong>}
          </li>
        ))}
      </ul>
    </div>
  )
}

تبدو الدوال في services/users.ts كما يلي:

export const getUserById = async (id: number) => {
  return db.query.users.findFirst({
    where: eq(users.id, id),
  })
}

export const getNotesByUserId = async (userId: number) => {
  return db.query.notes.findMany({
    where: eq(notes.userId, userId),
  })
}

تبدو صفحة المستخدم هكذا:

صورة توضيحية

تجد الشيفرة الحالية للتطبيق على GitHub في الفرع part6.

استعلامات الربط

يبرز سؤال: تنفيذ UserPage الحالي يجري استعلامين منفصلين على قاعدة البيانات، أحدهما لجلب المستخدم والآخر لجلب ملاحظاته. فهل يمكننا إجراء استعلام واحد فقط يربط المستخدم بالملاحظات المقابلة؟

في SQL، يُفعل هذا عادةً باستخدام JOIN:

SELECT users.*, notes.* FROM users
LEFT JOIN notes ON notes.user_id = users.id
WHERE users.id = 1;

يقدّم Drizzle ميزة تُسمّى العلاقات (relations) تتيح لنا تعريف العلاقات بين الجداول على مستوى التطبيق. ولا تُخزَّن تعريفات العلاقات هذه في قاعدة البيانات، ولا تولّد أي قيود SQL أو ترحيلات. بل توجد فقط لتخبر Drizzle كيف تتصل الجداول ببعضها، حتى تستطيع واجهة الاستعلامات العلائقية في Drizzle ربط البيانات المرتبطة تلقائياً نيابةً عنا.

يتغيّر schema.ts إلى

import { pgTable, serial, text, boolean, integer } from "drizzle-orm/pg-core"
import { relations } from "drizzle-orm"

export const users = pgTable("users", {
    <em>// ...</em>
})

export const notes = pgTable("notes", {
  <em>// ...</em>
})


export const usersRelations = relations(users, ({ many }) => ({
  notes: many(notes),
}))

export const notesRelations = relations(notes, ({ one }) => ({
  user: one(users, {
    fields: [notes.userId],
    references: [users.id],
  }),
}))


تُستخدم دالة relations من drizzle-orm للإعلان عن كيفية ارتباط الجداول ببعضها. والوسيط الأول هو الجدول، والثاني دالة رد نداء تستقبل دوال مساعدة (many و one) وتعيد كائناً يصف الجداول المرتبطة.

يقول تعريف usersRelations إن المستخدم يمكن أن تكون له ملاحظات كثيرة (many).

ويقول تعريف notesRelations إن الملاحظة تنتمي إلى مستخدم واحد (one). وتخبر خاصيتا fields و references Drizzle بالأعمدة التي تشكّل الرابط: notes.userId في جدول الملاحظات يشير إلى users.id في جدول المستخدمين.

لاحظ أن خاصيتي fields و references مطلوبتان دائماً في الجهة التي تحمل عمود المفتاح الأجنبي، وهي في حالتنا جهة notes، لأن user_id يقع في جدول الملاحظات.

من المهم أن تفهم أن تعريفات العلاقات هذه منفصلة عن المفتاح الأجنبي references الذي عرّفناه سابقاً في استدعاء pgTable. فقيد المفتاح الأجنبي تفرضه قاعدة البيانات، بينما تُستخدم تعريفات relations فقط في واجهة الاستعلامات في Drizzle لمعرفة كيفية ربط البيانات. تحتاج إلى كليهما: المفتاح الأجنبي لسلامة البيانات، والعلاقات للاستعلام المريح.

الآن يتغيّر services/users.ts إلى

export const getUserWithNotes = async (id: number) => {
  return db.query.users.findFirst({
    where: eq(users.id, id),
    with: { notes: true },
  })
}

الإضافة الجوهرية إلى ما رأيناه سابقاً هي with: { notes: true } التي تخبر Drizzle بأن يجلب أيضاً كل الملاحظات المرتبطة بذلك المستخدم في العملية نفسها.

في الخلفية، يترجم Drizzle هذا إلى استعلام فعّال يربط جدولي users وnotes. والنتيجة كائن مستخدم واحد مرفقة به مصفوفة notes، شيء مثل:

{
  id: 1,
  username: "mluukkai",
  name: "Matti Luukkainen",
  notes: [
    { id: 1, content: "next.js utilizes React Server Components", important: true, userId: 1 },
    { id: 2, content: "next.js is built on top of React", important: true, userId: 1 },
  ]
}

يحل هذا محل الدالتين المنفصلتين getUserById و getNotesByUserId بدالة واحدة تجلب كل شيء دفعة واحدة.

هذه هي الدالة الوحيدة اللازمة في UserPage

import Link from "next/link"
import { notFound } from "next/navigation"
import { getUserWithNotes } from "../../services/users"

const UserPage = async ({ params }: { params: Promise<{ id: string }> }) => {
  const { id } = await params
  const user = await getUserWithNotes(Number(id))

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Username: {user.username}</p>
      <h3>Notes</h3>
      <ul>
        {user.notes.map((note) => (
          <li key={note.id}>
            <Link href={`/notes/${note.id}`}>{note.content}</Link>
            {note.important && <strong> (important)</strong>}
          </li>
        ))}
      </ul>
    </div>
  )
}

أصبح المكوّن أبسط الآن: فبدلاً من استدعاء دالتين ودمج النتيجتين يدوياً، يستدعي getUserWithNotes مرة واحدة ويحصل على كائن مستخدم يحتوي أصلاً على مصفوفة الملاحظات. و user.notes.map(...) يمر على الملاحظات المضمّنة مباشرةً، دون حاجة إلى استعلام منفصل.

توجد الآن مشكلة صغيرة في إنشاء ملاحظات جديدة. فمخططنا يتطلب أن يكون لكل ملاحظة user_id يشير إلى منشئها:

export const notes = pgTable("notes", {
  id: serial("id").primaryKey(),
  content: text("content").notNull(),
  important: boolean("important").notNull().default(false),
  userId: integer("user_id")
    .notNull()
    .references(() => users.id),
})

بما أن التطبيق لا يدعم تسجيل دخول المستخدمين بعد، لا يمكننا معرفة من ينشئ الملاحظة. وكحل مؤقت، لنربط كل ملاحظة جديدة بمستخدم عشوائي من قاعدة البيانات. نغيّر دالة addNote في services/notes.ts:

export const addNote = async (content: string, important: boolean) => {
  const user = await db.query.users.findFirst({
    orderBy: sql`RANDOM()`,
  })

  await db.insert(notes).values({ content, important, userId: user.id })
}

تستخدم الدالة findFirst مع orderBy: sqlRANDOM() لاختيار مستخدم عشوائي من قاعدة البيانات. ويتيح لنا وسم القالب sql من drizzle-orm كتابة أجزاء SQL خام عندما لا يملك Drizzle دالة مساعدة مدمجة لشيء ما. ثم تُدرج الملاحظة بمعرّف ذلك المستخدم id كرقم userId. وسنستبدل هذا الحل المؤقت بمصادقة مستخدمين مناسبة قريباً.

تجد الشيفرة الحالية للتطبيق على GitHub في الفرع part7.

مسجّل الاستعلامات

عندما يتصرف شيء ما بشكل غير متوقع، يفيد رؤية SQL الفعلي الذي يرسله Drizzle إلى قاعدة البيانات. يمكنك تفعيل التسجيل بتمرير خيار logger عند إنشاء الاتصال في db/index.ts:

import { drizzle } from "drizzle-orm/neon-http"
import * as schema from "./schema"

export const db = drizzle(process.env.DATABASE_URL!, {
  schema,
  logger: true,
})


مع logger: true، يطبع Drizzle كل عبارة SQL ومعاملاتها إلى الطرفية أثناء معالجة تطبيقك للطلبات. مثلاً، جلب الملاحظات ينتج مخرجات مثل:

Query: select "id", "content", "important", "user_id" from "notes" where "notes"."important" = $1 -- params: [true]


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

تحذير بشأن الترحيلات

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

وعندما تسوء الأمور، سترى عادةً أخطاء مثل "relation already exists" أو "column does not exist" عند تنفيذ drizzle-kit migrate. ويحدث هذا لأن متتبع الترحيلات في Drizzle يظن أن قاعدة البيانات في حالة معينة، بينما قاعدة البيانات الفعلية في حالة أخرى.

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

أولاً، أزل تتبع ترحيلات Drizzle من قاعدة البيانات وأسقط كل الجداول التي أنشأتها:

DROP TABLE IF EXISTS drizzle.__drizzle_migrations CASCADE;
DROP TABLE IF EXISTS notes CASCADE;
DROP TABLE IF EXISTS users CASCADE;

يمكنك تنفيذ عبارات SQL هذه في Drizzle Studio أو مباشرةً في طرفية Neon على لوحة تحكم Vercel.

ثانياً، أزل سجل الترحيلات المحلي حتى ينسى Drizzle Kit كل الترحيلات المولّدة سابقاً:

npx drizzle-kit drop

يتيح لك الأمر drizzle-kit drop اختيار إدخالات الترحيل التي تريد إزالتها من السجل المحلي. وبعد إسقاطها، يمكنك إعادة توليد كل شيء وتطبيقه من جديد بشكل نظيف:

npx drizzle-kit generate
npx drizzle-kit migrate

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

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

9. المستخدمون

10. صفحة المستخدم