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
}
// 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());
פתרון תרגיל 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
}
פתרון תרגיל 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>
);
}
תשובות לשאלות¶
-
ההבדל בין include ל-select:
includeמוסיף קשרים לתוצאה (כל השדות + הקשר).selectבוחר שדות ספציפיים (רק מה שביקשנו). אי אפשר להשתמש בשניהם יחד באותה רמה. select יעיל יותר כי מביא פחות נתונים. -
למה לשמור Prisma Client ב-global: ב-development, Next.js עושה hot reload שמריץ מחדש את המודולים. בלי global, כל reload יוצר חיבור חדש לבסיס הנתונים. זה יכול למלא את מאגר החיבורים ולגרום לשגיאות. ב-production אין hot reload אז אין בעיה.
-
ההבדל בין migrate dev ל-db push:
migrate devיוצר קובץ מיגרציה שמתעד את השינוי, ומתאים לייצור.db pushמסנכרן את הסכמה ישירות בלי קובץ מיגרציה, מתאים רק לפיתוח מהיר (prototyping). בייצור תמיד משתמשים ב-migrate. -
Cascade Delete: כש-record נמחק, כל ה-records שמקושרים אליו נמחקים גם. למשל, כשמוחקים משימה, כל התגובות שלה נמחקות. מגדירים עם
onDelete: Cascade. שימושי כשהנתונים המקושרים לא הגיוניים בלי ה-parent. -
יתרון Prisma על SQL ישיר: Type-safety מלא - TypeScript יודע מה מוחזר מכל שאילתה. Auto-complete בעורך. מיגרציות אוטומטיות. מונע SQL injection. קריאות יותר טוב. עובד על מספר בסיסי נתונים שונים בלי לשנות קוד.