إطار Next.js

دمج قاعدة البيانات مع Prisma

28 دقيقة الدرس 18 من 40

مقدمة إلى Prisma ORM

Prisma هو ORM (ربط كائن-علاقة) حديث يجعل الوصول إلى قاعدة البيانات سهلاً وآمناً من حيث النوع لتطبيقات Next.js. يوفر واجهة برمجة تطبيقات بديهية، وترحيلات تلقائية، ودعم ممتاز لـ TypeScript. يغطي هذا الدرس التكامل الكامل لقاعدة البيانات مع Prisma.

لماذا Prisma؟ توفر Prisma أماناً من حيث النوع، والإكمال التلقائي، والترحيلات التلقائية، وتعمل بسلاسة مع مكونات الخادم ومسارات API في Next.js. تدعم PostgreSQL و MySQL و SQLite و MongoDB والمزيد.

تثبيت Prisma

لنقم بإعداد Prisma في مشروع Next.js الخاص بك:

# تثبيت Prisma CLI كتبعية تطوير
npm install -D prisma

# تثبيت Prisma Client
npm install @prisma/client

# تهيئة Prisma
npx prisma init

هذا ينشئ مجلد prisma مع ملف schema.prisma ويضيف ملف .env لاتصال قاعدة البيانات الخاصة بك.

تكوين قاعدة البيانات

قم بتكوين اتصال قاعدة البيانات في ملف .env:

# .env
# PostgreSQL
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

# MySQL
DATABASE_URL="mysql://user:password@localhost:3306/mydb"

# SQLite (للتطوير)
DATABASE_URL="file:./dev.db"
نصيحة التطوير: استخدم SQLite للتطوير المحلي السريع، ولكن انتقل إلى PostgreSQL أو MySQL للإنتاج. تجعل Prisma تبديل قواعد البيانات سلساً.

إنشاء المخطط الخاص بك

حدد نماذج البيانات الخاصة بك في prisma/schema.prisma:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  hashedPassword String?
  image         String?
  role          Role      @default(USER)
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  posts         Post[]
  comments      Comment[]
  sessions      Session[]
  accounts      Account[]

  @@map("users")
}

model Post {
  id          String    @id @default(cuid())
  title       String
  slug        String    @unique
  content     String    @db.Text
  excerpt     String?
  published   Boolean   @default(false)
  views       Int       @default(0)
  authorId    String
  categoryId  String?
  createdAt   DateTime  @default(now())
  updatedAt   DateTime  @updatedAt

  author      User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  category    Category? @relation(fields: [categoryId], references: [id])
  comments    Comment[]
  tags        PostTag[]

  @@index([authorId])
  @@index([categoryId])
  @@index([slug])
  @@map("posts")
}

model Category {
  id          String   @id @default(cuid())
  name        String   @unique
  slug        String   @unique
  description String?
  createdAt   DateTime @default(now())

  posts       Post[]

  @@map("categories")
}

model Comment {
  id          String   @id @default(cuid())
  content     String   @db.Text
  postId      String
  authorId    String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  post        Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  author      User     @relation(fields: [authorId], references: [id], onDelete: Cascade)

  @@index([postId])
  @@index([authorId])
  @@map("comments")
}

model Tag {
  id          String    @id @default(cuid())
  name        String    @unique
  slug        String    @unique
  createdAt   DateTime  @default(now())

  posts       PostTag[]

  @@map("tags")
}

model PostTag {
  postId      String
  tagId       String

  post        Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag         Tag      @relation(fields: [tagId], references: [id], onDelete: Cascade)

  @@id([postId, tagId])
  @@map("post_tags")
}

enum Role {
  USER
  ADMIN
  MODERATOR
}

// نماذج Auth.js
model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  @@map("accounts")
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("sessions")
}
ميزات المخطط: يتضمن هذا المخطط علاقات (واحد إلى متعدد، متعدد إلى متعدد)، وفهارس للأداء، وحذف متسلسل، وقيم افتراضية، وتعدادات لأمان النوع.

تشغيل الترحيلات

بعد تعريف المخطط الخاص بك، أنشئ وشغل الترحيلات:

# إنشاء ترحيل
npx prisma migrate dev --name init

# هذا يفعل ثلاثة أشياء:
# 1. ينشئ ملفات ترحيل SQL في prisma/migrations
# 2. يطبق الترحيل على قاعدة البيانات
# 3. يولد Prisma Client

# تطبيق الترحيلات في الإنتاج
npx prisma migrate deploy

# إعادة تعيين قاعدة البيانات (التطوير فقط)
npx prisma migrate reset
تحذير: لا تستخدم أبداً migrate reset في الإنتاج لأنه يحذف جميع البيانات. استخدم migrate deploy لعمليات النشر في الإنتاج.

إعداد Prisma Client

أنشئ نسخة Prisma Client يمكن إعادة استخدامها في تطبيقك:

<!-- lib/prisma.ts -->
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
نصيحة الأداء: يمنع هذا النمط إنشاء نسخ Prisma Client متعددة أثناء إعادة التحميل الساخن للتطوير، مما قد يستنزف اتصالات قاعدة البيانات.

عمليات CRUD الأساسية

لنقم بتنفيذ عمليات الإنشاء والقراءة والتحديث والحذف:

عمليات الإنشاء

<!-- lib/actions/post-actions.ts -->
'use server'

import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'

export async function createPost(data: {
  title: string
  slug: string
  content: string
  excerpt?: string
  authorId: string
  categoryId?: string
  published?: boolean
}) {
  try {
    const post = await prisma.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        content: data.content,
        excerpt: data.excerpt,
        published: data.published ?? false,
        author: {
          connect: { id: data.authorId }
        },
        ...(data.categoryId && {
          category: {
            connect: { id: data.categoryId }
          }
        })
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true,
          }
        },
        category: true,
      }
    })

    revalidatePath('/blog')
    return { success: true, post }
  } catch (error) {
    console.error('خطأ في إنشاء المنشور:', error)
    return { success: false, error: 'فشل إنشاء المنشور' }
  }
}

// الإنشاء مع العلاقات (متعدد إلى متعدد)
export async function createPostWithTags(data: {
  title: string
  slug: string
  content: string
  authorId: string
  tagIds: string[]
}) {
  try {
    const post = await prisma.post.create({
      data: {
        title: data.title,
        slug: data.slug,
        content: data.content,
        author: {
          connect: { id: data.authorId }
        },
        tags: {
          create: data.tagIds.map(tagId => ({
            tag: {
              connect: { id: tagId }
            }
          }))
        }
      },
      include: {
        tags: {
          include: {
            tag: true
          }
        }
      }
    })

    revalidatePath('/blog')
    return { success: true, post }
  } catch (error) {
    return { success: false, error: 'فشل إنشاء المنشور' }
  }
}

عمليات القراءة

<!-- lib/queries/post-queries.ts -->
import { prisma } from '@/lib/prisma'
import { cache } from 'react'

// الحصول على منشور واحد (مخزن مؤقتاً)
export const getPostBySlug = cache(async (slug: string) => {
  return await prisma.post.findUnique({
    where: { slug },
    include: {
      author: {
        select: {
          id: true,
          name: true,
          email: true,
          image: true,
        }
      },
      category: true,
      comments: {
        include: {
          author: {
            select: {
              id: true,
              name: true,
              image: true,
            }
          }
        },
        orderBy: {
          createdAt: 'desc'
        }
      },
      tags: {
        include: {
          tag: true
        }
      }
    }
  })
})

// الحصول على جميع المنشورات مع الترقيم والتصفية
export async function getPosts({
  page = 1,
  limit = 10,
  categoryId,
  authorId,
  published = true,
  searchQuery,
}: {
  page?: number
  limit?: number
  categoryId?: string
  authorId?: string
  published?: boolean
  searchQuery?: string
}) {
  const skip = (page - 1) * limit

  const where = {
    ...(published !== undefined && { published }),
    ...(categoryId && { categoryId }),
    ...(authorId && { authorId }),
    ...(searchQuery && {
      OR: [
        { title: { contains: searchQuery, mode: 'insensitive' as const } },
        { content: { contains: searchQuery, mode: 'insensitive' as const } },
      ]
    })
  }

  const [posts, total] = await Promise.all([
    prisma.post.findMany({
      where,
      skip,
      take: limit,
      orderBy: {
        createdAt: 'desc'
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            image: true,
          }
        },
        category: true,
        _count: {
          select: {
            comments: true
          }
        }
      }
    }),
    prisma.post.count({ where })
  ])

  return {
    posts,
    pagination: {
      total,
      pages: Math.ceil(total / limit),
      currentPage: page,
      perPage: limit,
    }
  }
}

// استعلامات التجميع
export async function getPostStats(authorId: string) {
  const stats = await prisma.post.aggregate({
    where: { authorId },
    _count: {
      id: true
    },
    _sum: {
      views: true
    },
    _avg: {
      views: true
    }
  })

  return stats
}

عمليات التحديث

<!-- lib/actions/post-actions.ts -->
export async function updatePost(
  id: string,
  data: {
    title?: string
    slug?: string
    content?: string
    excerpt?: string
    published?: boolean
    categoryId?: string | null
  }
) {
  try {
    const post = await prisma.post.update({
      where: { id },
      data: {
        ...(data.title && { title: data.title }),
        ...(data.slug && { slug: data.slug }),
        ...(data.content && { content: data.content }),
        ...(data.excerpt !== undefined && { excerpt: data.excerpt }),
        ...(data.published !== undefined && { published: data.published }),
        ...(data.categoryId !== undefined && {
          category: data.categoryId
            ? { connect: { id: data.categoryId } }
            : { disconnect: true }
        })
      }
    })

    revalidatePath('/blog')
    revalidatePath(`/blog/${post.slug}`)
    return { success: true, post }
  } catch (error) {
    return { success: false, error: 'فشل تحديث المنشور' }
  }
}

// التحديث مع العلاقات
export async function updatePostTags(postId: string, tagIds: string[]) {
  try {
    await prisma.post.update({
      where: { id: postId },
      data: {
        tags: {
          deleteMany: {}, // إزالة جميع العلامات الموجودة
          create: tagIds.map(tagId => ({
            tag: {
              connect: { id: tagId }
            }
          }))
        }
      }
    })

    revalidatePath('/blog')
    return { success: true }
  } catch (error) {
    return { success: false, error: 'فشل تحديث العلامات' }
  }
}

// زيادة عدد المشاهدات
export async function incrementPostViews(slug: string) {
  await prisma.post.update({
    where: { slug },
    data: {
      views: {
        increment: 1
      }
    }
  })
}

عمليات الحذف

<!-- lib/actions/post-actions.ts -->
export async function deletePost(id: string) {
  try {
    await prisma.post.delete({
      where: { id }
    })

    revalidatePath('/blog')
    return { success: true }
  } catch (error) {
    return { success: false, error: 'فشل حذف المنشور' }
  }
}

// الحذف الناعم (باستخدام حقل deletedAt)
export async function softDeletePost(id: string) {
  try {
    await prisma.post.update({
      where: { id },
      data: {
        deletedAt: new Date()
      }
    })

    revalidatePath('/blog')
    return { success: true }
  } catch (error) {
    return { success: false, error: 'فشل حذف المنشور' }
  }
}

// الحذف الجماعي
export async function deletePostsByAuthor(authorId: string) {
  try {
    const result = await prisma.post.deleteMany({
      where: { authorId }
    })

    revalidatePath('/blog')
    return { success: true, count: result.count }
  } catch (error) {
    return { success: false, error: 'فشل حذف المنشورات' }
  }
}

المعاملات

استخدم المعاملات لضمان نجاح أو فشل العمليات المتعددة معاً:

export async function createUserWithProfile(data: {
  email: string
  name: string
  password: string
  bio?: string
}) {
  try {
    const hashedPassword = await bcrypt.hash(data.password, 12)

    const result = await prisma.$transaction(async (tx) => {
      // إنشاء المستخدم
      const user = await tx.user.create({
        data: {
          email: data.email,
          name: data.name,
          hashedPassword,
        }
      })

      // إنشاء الملف الشخصي
      const profile = await tx.profile.create({
        data: {
          userId: user.id,
          bio: data.bio || ''
        }
      })

      // إنشاء الإعدادات الافتراضية
      const settings = await tx.settings.create({
        data: {
          userId: user.id,
          theme: 'light',
          emailNotifications: true,
        }
      })

      return { user, profile, settings }
    })

    return { success: true, data: result }
  } catch (error) {
    return { success: false, error: 'فشل إنشاء المستخدم' }
  }
}
ضمان المعاملة: إذا فشلت أي عملية ضمن معاملة، يتم التراجع عن جميع التغييرات، مما يضمن اتساق البيانات.

الاستعلامات المتقدمة

// التصفية المعقدة مع العلاقات المتداخلة
export async function searchPosts(query: string) {
  return await prisma.post.findMany({
    where: {
      OR: [
        { title: { contains: query, mode: 'insensitive' } },
        { content: { contains: query, mode: 'insensitive' } },
        {
          author: {
            name: { contains: query, mode: 'insensitive' }
          }
        },
        {
          tags: {
            some: {
              tag: {
                name: { contains: query, mode: 'insensitive' }
              }
            }
          }
        }
      ],
      published: true,
    },
    include: {
      author: true,
      category: true,
      tags: {
        include: { tag: true }
      }
    },
    orderBy: [
      { views: 'desc' },
      { createdAt: 'desc' }
    ],
    take: 20
  })
}

// SQL خام للاستعلامات المعقدة
export async function getPopularPostsByViews() {
  return await prisma.$queryRaw`
    SELECT p.*, u.name as author_name, COUNT(c.id) as comment_count
    FROM posts p
    LEFT JOIN users u ON p.author_id = u.id
    LEFT JOIN comments c ON p.id = c.post_id
    WHERE p.published = true
    GROUP BY p.id, u.name
    ORDER BY p.views DESC, comment_count DESC
    LIMIT 10
  `
}
تمرين عملي:
  1. أنشئ مخططاً لمدونة مع مستخدمين ومنشورات وفئات وعلامات وتعليقات
  2. قم بتنفيذ عمليات CRUD الكاملة للمنشورات مع معالجة الأخطاء المناسبة
  3. أضف الترقيم والتصفية إلى قائمة المنشورات
  4. أنشئ معاملة تنشر منشوراً وترسل إشعارات
  5. قم بتنفيذ وظيفة بحث تستعلم عبر حقول وعلاقات متعددة
  6. أضف عد المشاهدات الذي يزداد بشكل ذري بدون شروط السباق

Prisma Studio

يوفر Prisma Studio متصفح قاعدة بيانات مرئي:

# تشغيل Prisma Studio
npx prisma studio

# يفتح http://localhost:5555
أداة التطوير: استخدم Prisma Studio أثناء التطوير لتصفح وتحرير سجلات قاعدة البيانات بشكل مرئي دون كتابة استعلامات.

أفضل الممارسات

  • استخدم غلاف cache() للاستعلامات التي يتم الوصول إليها بشكل متكرر في مكونات الخادم
  • استخدم دائماً revalidatePath() بعد الطفرات لتحديث البيانات المخزنة مؤقتاً
  • أضف فهارس إلى الحقول التي يتم الاستعلام عنها بشكل متكرر لتحسين الأداء
  • استخدم select لجلب الحقول المطلوبة فقط، مما يقلل نقل البيانات
  • قم بتنفيذ معالجة الأخطاء المناسبة وإرجاع رسائل سهلة الفهم للمستخدم
  • استخدم المعاملات للعمليات التي يجب أن تنجح أو تفشل معاً
  • لا تعرض أبداً Prisma Client في مكونات العميل - استخدم إجراءات الخادم

الخلاصة

توفر Prisma طريقة آمنة من حيث النوع وبديهية للعمل مع قواعد البيانات في Next.js. مع الترحيلات التلقائية، والدعم الممتاز لـ TypeScript، والتكامل السلس مع مكونات الخادم وإجراءات الخادم، فإن Prisma هي الحل المثالي لقاعدة البيانات لتطبيقات Next.js الحديثة.