פרויקט דוגמה: next עם בסיס נתונים drizzle

23/11/2024

הרבה זמן אני רוצה להקליט דוגמת וידאו שמסבירה איך לבנות פרויקט ב next.js בשילוב עם בסיס נתונים. מאחר ואני לא בטוח מתי אגיע לזה חשבתי להתחיל עם מדריך טקסט שיראה את כל החלקים החשובים. יום אחד אולי גם יהיה וידאו ואז אוסיף לינק לפוסט הזה.

1. מבנה המערכת

האמת ש Server Actions ו React Server Components אחראיים לרוב מה שקורה בפרויקט הזה, ובסיס הנתונים הוא רק החלק הקטן. את הקוד המלא אתם יכולים למצוא בקישור: https://github.com/ynonp/demo-next-sql-drizzle

אלה החלקים המרכזיים בקוד:

  1. ספריית src/db מחזיקה את כל הקוד שקשור לעבודה עם בסיס הנתונים.

  2. בזכות השימוש ב Server Actions ו RSC אין צורך להגדיר API. הגישה לשרת היא בסך הכל הפעלת פונקציה. בפרויקט הדוגמה יש גישה אחת מתוך קוד צד שרת בקובץ page.tsx וגישה אחת מתוך קוד צד לקוח בקובץ client/link.tsx.

  3. בתיקייה הראשית של הפרויקט הגדרתי שני סקריפטים להכנסת מידע לדוגמה לבסיס הנתונים ומחיקתו. בנוסף בתיקיית הפרויקט הראשית הקובץ drizzle.config.ts אחראי על הגדרת החיבור לבסיס הנתונים.

עכשיו בואו נעבור על שלושת החלקים.

2. בסיס הנתונים

בסיס הנתונים נשמר בדוגמה בתור קובץ SQLite ואנחנו רואים את פרטי ההתחברות בקובץ הקונפיגורציה של דריזל:

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
  out: './drizzle',
  schema: './src/db/schema.ts',
  dialect: 'sqlite',
  dbCredentials: {
    url: process.env.DB_FILE_NAME!,
  },
});

הפרויקט משתמש ב drizzle-kit כך שהגדרות בסיס הנתונים מגיעות מקובץ הסכימה. בפרויקט הדוגמה שכתבתי יש משתמשים, המשתמשים מפרסמים לינקים ויכולים לעשות "לייק" ללינקים שהם או אחרים פרסמו. זאת הסכימה:

import { int, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const usersTable = sqliteTable("users", {
  id: int().primaryKey({ autoIncrement: true }),
  name: text().notNull(),
});

export const linksTable = sqliteTable("links", {
  id: int().primaryKey({ autoIncrement: true }),
  user_id: int().references(() => usersTable.id, {onDelete: 'cascade'}).notNull(),
  href: text().notNull(),
});

export const likesTable = sqliteTable('likes', {
  id: int().primaryKey({autoIncrement: true}),
  user_id: int().references(() => usersTable.id, {onDelete: 'cascade'}).notNull(),
  link_id: int().references(() => linksTable.id, {onDelete: 'cascade'}).notNull(),
});

בנוסף אני מאוד אוהב להגדיר פונקציות לעבודה עם בסיס הנתונים בקבצים קטנים בתיקיית db. אני מסדר את הפונקציות לפי נושאים ולכל נושא יש קובץ, לדוגמה הקובץ users.ts אחראי על פונקציות שקשורות למשתמשים. זה תוכנו:

import {linksTable, usersTable, likesTable as likes} from './schema';
import {db} from './index';
import { eq } from 'drizzle-orm';

export async function login(name: string) {
  // Check if the user already exists
  const existingUser = await db
    .select()
    .from(usersTable)
    .where(eq(usersTable.name, name))
    .limit(1);

  if (existingUser.length > 0) {
    console.log('User logged in:', existingUser[0]);
    return { success: true, user: existingUser[0] };
  } else {
    return { success: false, user: null };
  }
}

בקבצים האלה תישמר כל הלוגיקה של המערכת. בגלל ש Drizzle לא קשיח כמו TypeORM או Sequelize, אפשר לארגן את הקבצים בכל צורה שמתאימה לכם. אני אוהב להתחיל פרויקט עם קובץ לוגיקה אחד ולאט לאט לפצל אותו לקבצים יותר קטנים כשהוא מתחיל להרגיש עמוס.

אגב קובץ הלוגיקה השני מהתיקייה נקרא links ושם כתבתי את השאילתות למשיכת פרטי הלינקים:

'use server';

import {
  linksTable as links,
  usersTable as users,
  likesTable as likes} from './schema';
import {db} from './index';
import { eq, sql, and } from 'drizzle-orm';

/**
 * Returns a list of all links joined with their authors
 * and number of likes
 */
export async function queryHomepageLinks() {
  return db.select({
    linkId: links.id,
    href: links.href,
    authorName: users.name,
    likesCount: sql<number>`COUNT(${likes.id})`, // Aggregates likes
  })
  .from(links)
  .leftJoin(users, eq(links.user_id, users.id))
  .leftJoin(likes, eq(links.id, likes.link_id))
  .groupBy(links.id, users.name); 
}

export async function likeLink(user_id: number, link_id: number) {
  console.log(`user id = ${user_id}, link_id = ${link_id}`);
  // Check if the like already exists
  const existingLike = await db
    .select()
    .from(likes)
    .where(and(eq(likes.user_id, user_id), eq(likes.link_id, link_id)))
    .limit(1);

  if (existingLike.length > 0) {
    console.log('User has already liked this link.');
    return { success: false, message: 'You have already liked this link.' };
  } 

  // Add the like
  const newLike = await db
    .insert(likes)
    .values({ user_id, link_id })
    .returning();

  console.log('Like added successfully:', newLike);
  return { success: true, message: 'Like added successfully!', like: newLike };

}

עכשיו דריזל יכול להיות מעייף ועמוס, אבל לקירבה ל SQL יש גם יתרונות. קודם כל ברור מאוד מה קורה בכל פונקציה, ומאוד קל ל AI לקבל סכימה ולכתוב בשבילכם שאילתות דריזל. בניגוד לכלי ORM, פה אם יש טעות בשאילתה או בעיית ביצועים רואים אותה מיד.

3. העברת המידע לדפדפן

החלק השני בתוכנית הוא העברת המידע לדפדפן, ופה next.js עושה את הקסם שלו. הקובץ page.tsx הוא Server Component ולכן הוא יכול להפעיל כל פונקציה בצד שרת, כולל פונקציות גישה לבסיס הנתונים. זה הקוד:

import { queryHomepageLinks } from "@/db/links";
import { login } from "@/db/users";
import Link from './client/Link';

export default async function Home() {
  const homepageLinks = await queryHomepageLinks();
  const currentUser = (await login('Dave')).user!;

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
      <ul className="space-y-4 text-left text-gray-500 dark:text-gray-400">
        {homepageLinks.map(link => (
          <Link user={currentUser} key={link.linkId} link={link} />
        ))}
      </ul>
      </main>
    </div>
  );
}

בשביל הפשטות כל מערכת ההתחברות פה מאוד מנוונת. כל מי שנכנס למערכת מזדהה בתור "Dave" ובלי סיסמה. יותר מעניין זה המערך homepageLinks, שמגיע מהפונקציה queryHomepageLinks שהוגדרה בקובץ הגישה לבסיס הנתונים. במערך הזה אנחנו כבר משתמשים בכתיבת הקומפוננטה.

עכשיו פה המקום להזכיר שעד לפני כמה שנים (ועדיין במערכות רבות שאני מכיר) שיטת העבודה עם מידע שמגיע מבסיס הנתונים היתה כתיבת שאילתה כפולה - קודם לוקחים את ה HTML, CSS, JS מהשרת, ואז יש קריאת Ajax שמושכת את המידע הראשוני (מערך הלינקים בדוגמה שלנו). העבודה עם RSC חוסכת את הכפילות ויותר מזה, מאחר שהקומפוננטה מתרנדרת רק בצד השרת והקוד שלה אפילו לא נשלח לדפדפן.

מה שכן נשלח לדפדפן זה הקוד של קומפוננטת Link שמוגדרת בקובץ client/link.tsx:

'use client'
import { likeLink } from "@/db/links";
import { useState } from "react";

export default function Link({link, user}: {
  link: {
    linkId: number,
    href: string,
    likesCount: number,  
  },
  user: { id: number, name: string },
}) {  
  const [likesCount, setLikesCount] = useState(link.likesCount);

  async function handleClick() {
    const result = await likeLink(user.id, link.linkId);
    if (result.success) {
      setLikesCount(c => c + 1);
    } else {
      alert(result.message);
    }
  }

  return <li className="flex items-center space-x-3 rtl:space-x-reverse">
    <span className="mx-2 inline-block w-4">{likesCount}</span>          
    <button onClick={handleClick} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded">+1</button>
    <span className="font-bold">{link.href}</span>                    
  </li>
};

הקומפוננטה מקבלת את הלינק מקומפוננטת צד השרת ומציגה אותו, אבל יש לה עוד שני טריקים:

  1. משתמש יכול ללחוץ על פלוס כדי לעשות "לייק" ללינק. בשביל זה הקומפוננטה מגדירה משתנה state שמתחיל עם הערך שהגיע מהשרת אבל יוכל להשתנות בעקבות לחיצה על כפתורים.

  2. בעת לחיצה על הכפתור הפונקציה פונה לשרת ומפעילה את הפונקציה likeLink שמוגדרת שם. זה נראה כמו הפעלה רגילה של פונקציה אבל בעצם מדובר על Server Action - כלומר קריאת Ajax שגורמת להפעלת פונקציה בצד שרת והחזרת התוצאה. הפונקציה ממשיכה לבדיקת התוצאה ולפי זה מחליטה אם העדכון הצליח ואם כן היא מעלה את מספר הלייקים של הלינק. באפליקציה גדולה יותר היינו יכולים לשלב רידאקס ואז היינו יכולים בצד שרת ליצור את אוביקט ה Action המתאים והפונקציה בצד הדפדפן היתה עושה לו dispatch.

  3. אתם שמים לב שהעברתי את ה user בתור prop. לצערי אין ל RSC תמיכה ב Context. יש כמה טריקים שאפשר לעשות כדי לעקוף את זה ולהגדיר Context בצד לקוח אבל ארחיב על זה בפוסט אחר. זה לא היה מספיק חשוב לכאן. ממילא במערכת אמיתית תשתמשו במנגנון ניהול משתמשים חכם יותר.

4. סקריפטים לניהול

בעבודה עם דריזל קיט הפקודה:

$ npx drizzle-kit generate

מסתכלת על הסכימה ועל בסיס הנתונים ומייצרת קובץ SQL שמעדכן את בסיס הנתונים כדי להתאים לסכימה. הקובץ נשמר בתיקיית drizzle ואפשר לראות שבפרויקט הדוגמה יש לי קובץ אחד כזה עם התוכן הבא:

CREATE TABLE `likes` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `user_id` integer NOT NULL,
    `link_id` integer NOT NULL,
    FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
    FOREIGN KEY (`link_id`) REFERENCES `links`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `links` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `user_id` integer NOT NULL,
    `href` text NOT NULL,
    FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
    `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
    `name` text NOT NULL
);

שימו לב להערה --> statement-breakpoint - היא חייבת להופיע בין פקודות שונות בקובץ כדי שירוצו כל הפקודות. היא נוצרת אוטומטית מפקודת generate אבל אם אתם עושים עדכון ידני אל תשכחו לכתוב אותה.

בנוסף ליצירת הסכימה אני אוהב להחזיק סקריפטים להכנסת מידע לבסיס הנתונים או מחיקתו. בפרויקט הדוגמה יצרתי סקריפט אחד ששומר מידע לפיתוח:

import { db } from './db';
import { usersTable, linksTable, likesTable } from './db/schema';

async function seedDatabase() {
  // Insert users
  const userIds = await db.insert(usersTable).values([
    { name: 'Alice' },
    { name: 'Bob' },
    { name: 'Charlie' },
    { name: 'Dave'},
  ]).returning({ id: usersTable.id });

  // Insert links
  const linkIds = await db.insert(linksTable).values([
    { user_id: userIds[0].id, href: 'https://www.duckduckgo.com' },
    { user_id: userIds[1].id, href: 'https://www.tocode.co.il' },
    { user_id: userIds[2].id, href: 'https://nextjs.org/blog/next-15' },
  ]).returning({ id: linksTable.id });

  // Insert likes
  await db.insert(likesTable).values([
    { user_id: userIds[0].id, link_id: linkIds[1].id },
    { user_id: userIds[1].id, link_id: linkIds[2].id },
    { user_id: userIds[2].id, link_id: linkIds[1].id },
    { user_id: userIds[1].id, link_id: linkIds[0].id },
    { user_id: userIds[0].id, link_id: linkIds[1].id },
  ]);

  console.log('Database seeded successfully!');
}

seedDatabase().catch(console.error);

וסקריפט אחר שמוחק את המידע:

import { db } from './db';
import { usersTable, linksTable, likesTable } from './db/schema';

async function truncateDatabase() {
  await db.delete(usersTable)
  await db.delete(linksTable);
  await db.delete(likesTable);
}

truncateDatabase().catch(console.error);

לדריזל אין עדיין דרך מובנית להריץ סקריפטים כאלה אז הוספתי את הפקודות ל package.json:

  "scripts": {
    "dev": "next dev",
    "seed-dev": "tsx src/seed-dev.ts",
    "truncate-dev": "tsx src/truncate-dev-db.ts",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },

5. סיכום

החלקים המרכזיים בפרויקט next עם בסיס נתונים הם בסך הכל:

  1. יצירת קבצי לוגיקה לגישה לבסיס הנתונים, בדרך כלל ליד הגדרת הסכימה. אני אוהב את דריזל ואת kysely, עבדתי עם Sequelize ב JavaScript (לפני טייפסקריפט) וגם הייתי מרוצה. את TypeORM לא אהבתי. אבל בסוף זה רק עניין של טעם כולם טובים.

  2. יצירת קומפוננטות צד שרת שמושכות את המידע והופכות אותו ל JSX.

  3. יצירת קומפוננטות צד לקוח שיפעילו עם Server Actions את קבצי הלוגיקה.

האתגר המרכזי שעדיין נשאר באינטגרציה הוא לקחת את התוצאה של ה Server Action ולעדכן את המידע שעל המסך. בדוגמה שלי הייתי צריך להגדיר את likesCount בתור סטייט בשביל שאוכל לעדכן אותו. גישה אחרת שראיתי זה להפעיל refresh ולתת לשרת לרנדר מחדש את ה Server Components עם המידע המעודכן.