לדלג לתוכן

9.7 פריזמה ובסיס נתונים פתרון

פתרון - פריזמה ובסיס נתונים - Prisma and Database

פתרון תרגיל 1 - הקמת Prisma

npx create-next-app@latest task-manager
cd task-manager
npm install prisma @prisma/client
npx prisma init --datasource-provider sqlite
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        Int      @id @default(autoincrement())
  name      String
  email     String   @unique
  createdAt DateTime @default(now())
  tasks     Task[]
}

model Task {
  id          Int      @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean  @default(false)
  priority    String   @default("medium")
  createdAt   DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id])
  userId      Int
}
npx prisma migrate dev --name init
// prisma/seed.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

async function main() {
  await prisma.task.deleteMany();
  await prisma.user.deleteMany();

  const user1 = await prisma.user.create({
    data: { name: "ישראל ישראלי", email: "israel@example.com" },
  });

  const user2 = await prisma.user.create({
    data: { name: "דנה כהן", email: "dana@example.com" },
  });

  await prisma.task.createMany({
    data: [
      { title: "ללמוד Prisma", description: "לעבור על התיעוד", priority: "high", userId: user1.id },
      { title: "לבנות API", priority: "high", userId: user1.id },
      { title: "לכתוב בדיקות", priority: "medium", userId: user1.id },
      { title: "לעצב דשבורד", description: "עיצוב עם Tailwind", priority: "low", userId: user2.id },
      { title: "לפרוס לייצור", priority: "medium", completed: true, userId: user2.id },
    ],
  });

  console.log("Seed completed");
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect());
npx prisma db seed

פתרון תרגיל 2 - דף משימות עם Server Component

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

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

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}
// app/tasks/page.tsx
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import TaskFilter from "./TaskFilter";

interface PageProps {
  searchParams: { status?: string };
}

export default async function TasksPage({ searchParams }: PageProps) {
  const status = searchParams.status;

  const where = status === "completed"
    ? { completed: true }
    : status === "active"
    ? { completed: false }
    : {};

  const tasks = await prisma.task.findMany({
    where,
    include: {
      user: { select: { name: true } },
    },
    orderBy: { createdAt: "desc" },
  });

  const priorityColors: Record<string, string> = {
    high: "bg-red-100 text-red-700",
    medium: "bg-yellow-100 text-yellow-700",
    low: "bg-green-100 text-green-700",
  };

  const priorityLabels: Record<string, string> = {
    high: "גבוהה",
    medium: "בינונית",
    low: "נמוכה",
  };

  return (
    <div className="max-w-3xl mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">משימות</h1>
        <Link
          href="/tasks/new"
          className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        >
          משימה חדשה
        </Link>
      </div>

      <TaskFilter currentStatus={status} />

      <ul className="space-y-3 mt-4">
        {tasks.map((task) => (
          <li key={task.id} className="border p-4 rounded flex items-center gap-4">
            <div className={`w-3 h-3 rounded-full ${task.completed ? "bg-green-500" : "bg-gray-300"}`} />
            <div className="flex-1">
              <Link href={`/tasks/${task.id}`} className="font-semibold hover:text-blue-600">
                {task.title}
              </Link>
              <div className="text-sm text-gray-500 mt-1">
                {task.user.name} |{" "}
                {new Date(task.createdAt).toLocaleDateString("he-IL")}
              </div>
            </div>
            <span className={`px-2 py-1 rounded text-sm ${priorityColors[task.priority]}`}>
              {priorityLabels[task.priority]}
            </span>
          </li>
        ))}
      </ul>

      <p className="mt-4 text-sm text-gray-500">{tasks.length} משימות</p>
    </div>
  );
}
// app/tasks/TaskFilter.tsx
"use client";

import { useRouter } from "next/navigation";

export default function TaskFilter({ currentStatus }: { currentStatus?: string }) {
  const router = useRouter();

  const filters = [
    { value: undefined, label: "הכל" },
    { value: "active", label: "פעיל" },
    { value: "completed", label: "הושלם" },
  ];

  return (
    <div className="flex gap-2">
      {filters.map((filter) => (
        <button
          key={filter.label}
          onClick={() =>
            router.push(
              filter.value ? `/tasks?status=${filter.value}` : "/tasks"
            )
          }
          className={`px-3 py-1 rounded ${
            currentStatus === filter.value
              ? "bg-blue-500 text-white"
              : "bg-gray-100 hover:bg-gray-200"
          }`}
        >
          {filter.label}
        </button>
      ))}
    </div>
  );
}

פתרון תרגיל 3 - CRUD מלא עם Server Actions

// app/actions/tasks.ts
"use server";

import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createTask(formData: FormData) {
  const title = formData.get("title") as string;
  const description = formData.get("description") as string;
  const priority = formData.get("priority") as string;

  if (!title || title.trim().length < 2) {
    return { error: "הכותרת חייבת להכיל לפחות 2 תווים" };
  }

  await prisma.task.create({
    data: {
      title: title.trim(),
      description: description?.trim() || null,
      priority: priority || "medium",
      userId: 1,
    },
  });

  revalidatePath("/tasks");
  redirect("/tasks");
}

export async function updateTask(id: number, formData: FormData) {
  const title = formData.get("title") as string;
  const description = formData.get("description") as string;
  const priority = formData.get("priority") as string;
  const completed = formData.get("completed") === "on";

  await prisma.task.update({
    where: { id },
    data: {
      title: title.trim(),
      description: description?.trim() || null,
      priority,
      completed,
    },
  });

  revalidatePath("/tasks");
  revalidatePath(`/tasks/${id}`);
  redirect(`/tasks/${id}`);
}

export async function toggleTask(id: number) {
  const task = await prisma.task.findUnique({ where: { id } });
  if (!task) return;

  await prisma.task.update({
    where: { id },
    data: { completed: !task.completed },
  });

  revalidatePath("/tasks");
}

export async function deleteTask(id: number) {
  await prisma.task.delete({ where: { id } });
  revalidatePath("/tasks");
  redirect("/tasks");
}
// app/tasks/new/page.tsx
import { createTask } from "@/app/actions/tasks";

export default function NewTaskPage() {
  return (
    <div className="max-w-lg mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">משימה חדשה</h1>
      <form action={createTask} className="space-y-4">
        <div>
          <label className="block font-semibold mb-1">כותרת</label>
          <input name="title" required className="w-full border rounded px-3 py-2" />
        </div>
        <div>
          <label className="block font-semibold mb-1">תיאור</label>
          <textarea name="description" rows={4} className="w-full border rounded px-3 py-2" />
        </div>
        <div>
          <label className="block font-semibold mb-1">עדיפות</label>
          <select name="priority" className="w-full border rounded px-3 py-2">
            <option value="low">נמוכה</option>
            <option value="medium" selected>בינונית</option>
            <option value="high">גבוהה</option>
          </select>
        </div>
        <button type="submit" className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600">
          צור משימה
        </button>
      </form>
    </div>
  );
}
// app/tasks/[id]/page.tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import DeleteButton from "./DeleteButton";
import ToggleButton from "./ToggleButton";

export default async function TaskPage({ params }: { params: { id: string } }) {
  const task = await prisma.task.findUnique({
    where: { id: parseInt(params.id) },
    include: { user: true },
  });

  if (!task) notFound();

  return (
    <div className="max-w-2xl mx-auto p-6">
      <Link href="/tasks" className="text-blue-500 hover:underline mb-4 block">
        חזרה לרשימה
      </Link>

      <h1 className="text-3xl font-bold mb-2">{task.title}</h1>
      <p className="text-gray-500 mb-4">
        מאת {task.user.name} | {new Date(task.createdAt).toLocaleDateString("he-IL")}
      </p>

      {task.description && <p className="text-gray-700 mb-4">{task.description}</p>}

      <div className="flex gap-3 mb-6">
        <span className="px-3 py-1 bg-gray-100 rounded">
          עדיפות: {task.priority === "high" ? "גבוהה" : task.priority === "medium" ? "בינונית" : "נמוכה"}
        </span>
        <span className={`px-3 py-1 rounded ${task.completed ? "bg-green-100" : "bg-yellow-100"}`}>
          {task.completed ? "הושלם" : "פעיל"}
        </span>
      </div>

      <div className="flex gap-3">
        <ToggleButton id={task.id} completed={task.completed} />
        <Link href={`/tasks/${task.id}/edit`} className="px-4 py-2 border rounded hover:bg-gray-50">
          ערוך
        </Link>
        <DeleteButton id={task.id} />
      </div>
    </div>
  );
}
// app/tasks/[id]/DeleteButton.tsx
"use client";

import { deleteTask } from "@/app/actions/tasks";

export default function DeleteButton({ id }: { id: number }) {
  const handleDelete = async () => {
    if (confirm("האם למחוק את המשימה?")) {
      await deleteTask(id);
    }
  };

  return (
    <button
      onClick={handleDelete}
      className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
    >
      מחק
    </button>
  );
}
// app/tasks/[id]/ToggleButton.tsx
"use client";

import { toggleTask } from "@/app/actions/tasks";

export default function ToggleButton({ id, completed }: { id: number; completed: boolean }) {
  return (
    <form action={() => toggleTask(id)}>
      <button type="submit" className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600">
        {completed ? "סמן כלא בוצע" : "סמן כבוצע"}
      </button>
    </form>
  );
}

פתרון תרגיל 4 - קשרים מורכבים

סכמה מעודכנת:

model Task {
  id          Int        @id @default(autoincrement())
  title       String
  description String?
  completed   Boolean    @default(false)
  priority    String     @default("medium")
  createdAt   DateTime   @default(now())
  user        User       @relation(fields: [userId], references: [id])
  userId      Int
  categories  Category[]
  comments    Comment[]
}

model Category {
  id    Int    @id @default(autoincrement())
  name  String @unique
  tasks Task[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  createdAt DateTime @default(now())
  task      Task     @relation(fields: [taskId], references: [id], onDelete: Cascade)
  taskId    Int
  user      User     @relation(fields: [userId], references: [id])
  userId    Int
}
npx prisma migrate dev --name add_categories_comments

פתרון תרגיל 5 - חיפוש ופילטור מתקדם

// app/tasks/search/page.tsx
import { prisma } from "@/lib/prisma";
import Link from "next/link";

interface PageProps {
  searchParams: {
    q?: string;
    priority?: string;
    userId?: string;
    sort?: string;
    page?: string;
  };
}

export default async function SearchPage({ searchParams }: PageProps) {
  const { q, priority, userId, sort, page } = searchParams;
  const currentPage = parseInt(page || "1");
  const perPage = 10;

  const where: any = {};

  if (q) {
    where.OR = [
      { title: { contains: q } },
      { description: { contains: q } },
    ];
  }

  if (priority) where.priority = priority;
  if (userId) where.userId = parseInt(userId);

  const orderBy: any = sort === "title"
    ? { title: "asc" }
    : sort === "priority"
    ? { priority: "asc" }
    : { createdAt: "desc" };

  const [tasks, total] = await Promise.all([
    prisma.task.findMany({
      where,
      include: { user: { select: { name: true } } },
      orderBy,
      take: perPage,
      skip: (currentPage - 1) * perPage,
    }),
    prisma.task.count({ where }),
  ]);

  const totalPages = Math.ceil(total / perPage);
  const users = await prisma.user.findMany({ select: { id: true, name: true } });

  return (
    <div className="max-w-3xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">חיפוש משימות</h1>

      <form className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
        <input
          name="q"
          defaultValue={q}
          placeholder="חיפוש..."
          className="border rounded px-3 py-2"
        />
        <select name="priority" defaultValue={priority} className="border rounded px-3 py-2">
          <option value="">כל העדיפויות</option>
          <option value="high">גבוהה</option>
          <option value="medium">בינונית</option>
          <option value="low">נמוכה</option>
        </select>
        <select name="userId" defaultValue={userId} className="border rounded px-3 py-2">
          <option value="">כל המשתמשים</option>
          {users.map((u) => (
            <option key={u.id} value={u.id}>{u.name}</option>
          ))}
        </select>
        <button type="submit" className="bg-blue-500 text-white rounded hover:bg-blue-600">
          חפש
        </button>
      </form>

      <p className="text-sm text-gray-500 mb-4">נמצאו {total} תוצאות</p>

      <ul className="space-y-2">
        {tasks.map((task) => (
          <li key={task.id} className="border p-3 rounded">
            <Link href={`/tasks/${task.id}`} className="font-semibold hover:text-blue-600">
              {task.title}
            </Link>
            <span className="text-sm text-gray-500 mr-2">({task.user.name})</span>
          </li>
        ))}
      </ul>

      {totalPages > 1 && (
        <div className="flex gap-2 mt-6 justify-center">
          {Array.from({ length: totalPages }, (_, i) => (
            <Link
              key={i}
              href={`/tasks/search?q=${q || ""}&priority=${priority || ""}&page=${i + 1}`}
              className={`px-3 py-1 rounded ${
                currentPage === i + 1 ? "bg-blue-500 text-white" : "bg-gray-100"
              }`}
            >
              {i + 1}
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

פתרון תרגיל 6 - דשבורד עם סטטיסטיקות

// app/dashboard/page.tsx
import { prisma } from "@/lib/prisma";

export default async function DashboardPage() {
  const [totalTasks, completedTasks, priorityStats, recentTasks, topUsers] =
    await Promise.all([
      prisma.task.count(),
      prisma.task.count({ where: { completed: true } }),
      prisma.task.groupBy({
        by: ["priority"],
        _count: { id: true },
      }),
      prisma.task.findMany({
        take: 5,
        orderBy: { createdAt: "desc" },
        include: { user: { select: { name: true } } },
      }),
      prisma.user.findMany({
        select: {
          name: true,
          _count: { select: { tasks: true } },
        },
        orderBy: { tasks: { _count: "desc" } },
        take: 1,
      }),
    ]);

  const completionRate = totalTasks > 0
    ? Math.round((completedTasks / totalTasks) * 100)
    : 0;

  const priorityLabels: Record<string, string> = {
    high: "גבוהה",
    medium: "בינונית",
    low: "נמוכה",
  };

  return (
    <div className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">דשבורד</h1>

      <div className="grid grid-cols-3 gap-4 mb-8">
        <div className="bg-blue-50 p-4 rounded text-center">
          <div className="text-3xl font-bold">{totalTasks}</div>
          <div>סה"כ משימות</div>
        </div>
        <div className="bg-green-50 p-4 rounded text-center">
          <div className="text-3xl font-bold">{completedTasks}</div>
          <div>הושלמו</div>
        </div>
        <div className="bg-yellow-50 p-4 rounded text-center">
          <div className="text-3xl font-bold">{completionRate}%</div>
          <div>אחוז השלמה</div>
        </div>
      </div>

      <div className="grid grid-cols-2 gap-6">
        <div className="border p-4 rounded">
          <h2 className="font-bold mb-3">חלוקה לפי עדיפות</h2>
          {priorityStats.map((stat) => (
            <div key={stat.priority} className="flex justify-between py-1">
              <span>{priorityLabels[stat.priority] || stat.priority}</span>
              <strong>{stat._count.id}</strong>
            </div>
          ))}
        </div>

        <div className="border p-4 rounded">
          <h2 className="font-bold mb-3">משתמש מוביל</h2>
          {topUsers[0] && (
            <p>
              {topUsers[0].name} - {topUsers[0]._count.tasks} משימות
            </p>
          )}
        </div>
      </div>

      <div className="border p-4 rounded mt-6">
        <h2 className="font-bold mb-3">5 משימות אחרונות</h2>
        <ul className="space-y-2">
          {recentTasks.map((task) => (
            <li key={task.id} className="flex justify-between">
              <span>{task.title}</span>
              <span className="text-sm text-gray-500">{task.user.name}</span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

תשובות לשאלות

  1. ההבדל בין include ל-select: include מוסיף קשרים לתוצאה (כל השדות + הקשר). select בוחר שדות ספציפיים (רק מה שביקשנו). אי אפשר להשתמש בשניהם יחד באותה רמה. select יעיל יותר כי מביא פחות נתונים.

  2. למה לשמור Prisma Client ב-global: ב-development, Next.js עושה hot reload שמריץ מחדש את המודולים. בלי global, כל reload יוצר חיבור חדש לבסיס הנתונים. זה יכול למלא את מאגר החיבורים ולגרום לשגיאות. ב-production אין hot reload אז אין בעיה.

  3. ההבדל בין migrate dev ל-db push: migrate dev יוצר קובץ מיגרציה שמתעד את השינוי, ומתאים לייצור. db push מסנכרן את הסכמה ישירות בלי קובץ מיגרציה, מתאים רק לפיתוח מהיר (prototyping). בייצור תמיד משתמשים ב-migrate.

  4. Cascade Delete: כש-record נמחק, כל ה-records שמקושרים אליו נמחקים גם. למשל, כשמוחקים משימה, כל התגובות שלה נמחקות. מגדירים עם onDelete: Cascade. שימושי כשהנתונים המקושרים לא הגיוניים בלי ה-parent.

  5. יתרון Prisma על SQL ישיר: Type-safety מלא - TypeScript יודע מה מוחזר מכל שאילתה. Auto-complete בעורך. מיגרציות אוטומטיות. מונע SQL injection. קריאות יותר טוב. עובד על מספר בסיסי נתונים שונים בלי לשנות קוד.