היכרות עם Drizzle

04/03/2025
SQL

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

דריזל היא ספריית העבודה עם בסיסי נתונים האהובה עליי בתקופה האחרונה. היא מספקת ביצועים מצוינים, כוללת המון פינוקים למפתחים וכמובן Type Safety ותיעוד מעולה. נכון, מספר הגירסה 0.40 לא משרה תחושת ביטחון אבל בואו נזכור שהספריה בפיתוח כבר שלוש שנים והיא אחת הפופולריות בתעשייה אז אולי אפשר לבלוע את הצפרדע הזאת.

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

פוסט זה קיים גם בגירסת וידאו בקישור:
https://www.youtube.com/watch?v=FBs5c_LHYqs

1. מה אנחנו בונים

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

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

import { integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import { sql } from 'drizzle-orm';

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

// Define the vocabulary cards table
export const vocabularyCardsTable = sqliteTable('vocabulary_cards', {
  id: integer().primaryKey({ autoIncrement: true }),
  userId: integer().notNull().references(() => usersTable.id),
  front: text().notNull(),
  back: text().notNull(),    
  nextReviewDate: text().notNull(),    
  createdAt: text().notNull().default(sql`(current_timestamp)`),
  updatedAt: text().notNull().default(sql`(current_timestamp)`),
});

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

-- Create the users table
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL
);

-- Create the vocabulary_cards table
CREATE TABLE vocabulary_cards (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  userId INTEGER NOT NULL,
  front TEXT NOT NULL,
  back TEXT NOT NULL,
  nextReviewDate TEXT NOT NULL,
  createdAt TEXT NOT NULL DEFAULT (current_timestamp),
  updatedAt TEXT NOT NULL DEFAULT (current_timestamp),
  FOREIGN KEY (userId) REFERENCES users(id)
);

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

2. התקנה וחיבור לבסיס נתונים SQLite

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

$ npx create-next-app@latest . -e https://github.com/ynonp/next-15-starter

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

https://orm.drizzle.team/docs/get-started

ואלה עיקרי הדברים עבור SQLite. תחילה מתקינים את הספריות:

npm i drizzle-orm @libsql/client dotenv
npm i -D drizzle-kit tsx

לאחר מכן יוצרים קובץ .env שמגדיר את הנתיב לבסיס הנתונים (זכרו ש SQLite הוא בסך הכל קובץ יחיד), למשל אני משתמש ב:

DB_FILE_NAME=file:local.db

לאחר מכן אני יוצר תיקייה בשם db בתוך תיקיית src בפרויקט, ובתוכה אני יוצר קובץ בשם schema.ts עם הסכימה שלי:

import { integer, sqliteTable, text} from "drizzle-orm/sqlite-core";
import { sql } from 'drizzle-orm';

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

// Define the vocabulary cards table
export const vocabularyCardsTable = sqliteTable('vocabulary_cards', {
  id: integer().primaryKey({ autoIncrement: true }),
  userId: integer().notNull().references(() => usersTable.id),
  front: text().notNull(),
  back: text().notNull(),
  nextReviewDate: text().notNull(),
  createdAt: text().notNull().default(sql`(current_timestamp)`),
  updatedAt: text().notNull().default(sql`(current_timestamp)`),
});

ולסיום בתוך אותה תיקיית db אני יוצר קובץ בשם drizzle.ts שמחבר את הסכימה לדרייבר של SQLite ומייצא אוביקט חיבור לבסיס הנתונים דרכו אוכל להפעיל שאילתות מכל היישום:

import 'dotenv/config';
import { drizzle } from 'drizzle-orm/libsql';
import * as schema from './schema';

export const db = drizzle(process.env.DB_FILE_NAME!, {schema: schema});

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

3. קצת דריזל קיט

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

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!,
  },
});

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

$ npx drizzle-kit push

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

לאחר הפעלת הפקודה אני יכול לראות שנוצר לי בסיס הנתונים בקובץ local.db. אלה הטבלאות שם:

sqlite3 local.db
SQLite version 3.43.2 2023-10-10 13:08:14
Enter ".help" for usage hints.
sqlite> .tables
users             vocabulary_cards

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

$ npx drizzle-kit studio

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

4. יצירת נתונים

בסיס הנתונים שלנו מתחיל ריק אז בואו נמלא אותו. אני יוצר קובץ בשם src/db/seed.ts וכותב בו את הפקודות:

import {db} from './drizzle';
import { usersTable } from './schema';

async function main() {
  ['user1', 'user2', 'user3', 'user4'].forEach(async name => {
    await db.insert(usersTable).values({ name })
  })
}

main();

השורה המעניינת היא זו שבתוך גוף הלולאה:

await db.insert(usersTable).values({ name })

פקודת insert מוסיפה שורות לטבלה ואנחנו יכולים לראות את הדמיון ל INSERT של SQL. האוביקט שאני מעביר ל values מתאר את השורה ובמקרה שלנו אני מעביר ערך רק לעמודה name כי ה id מסומן בסכימה כ autoIncrement.

נפעיל את הקובץ עם הפקודה:

$ npx tsx --env-file=.env src/db/seed.ts

וראו זה פלא - 4 שורות התווספו לטבלה. אם יש לכם עדיין את הדריזל קיט סטודיו פתוח תוכלו לראות את השורות החדשות.

5. שמירת מילים חדשות

פרקטיקה טובה בעבודה עם בסיסי נתונים היא ליצור קובץ או קבצים עם פונקציות עזר שדרכן ניגש לבסיס הנתונים לפי כללים פשוטים של האפליקציה. בדוגמה שלנו אפשר לדמיין שמשתמשים ירצו לשמור מילים לאוצר המילים שלהם ולכן ארצה פונקציה ששומרת מילה. אולי בהמשך ארצה לבדוק שמשתמש לא שומר את אותה מילה כמה פעמים או לא לאפשר בכלל שמירה של מילים מסוימות, בכל מקרה אני מתחיל ביצירת קובץ של פונקציות גישה לבסיס הנתונים בשם src/actions/vocabulary.ts, ובו אני כותב את הפונקציה הראשונה ששומרת מילה ופירוש בבסיס הנתונים:

export async function saveCard(userId: number, front: string, back: string) {
  return db.insert(vocabularyCardsTable).values({
    userId: userId,
    front,
    back,
    nextReviewDate: sql`current_timestamp`
  })
}

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

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

את הטיפוסים האוטומטיים אפשר לייצא מקובץ הסכימה באמצעות הוספת השורות הבאות לקובץ schema.ts:

export type SelectUser = typeof usersTable.$inferSelect;
export type InsertUser = typeof usersTable.$inferInsert;

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

export async function saveCard(user: SelectUser, front: string, back: string) {
  return db.insert(vocabularyCardsTable).values({
    userId: user.id,
    front,
    back,
    nextReviewDate: sql`current_timestamp`
  })
}

6. שליפות ב Drizzle

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

db
  .select()
  .from(users)
  .where(eq(users.name, 'user1'))
  .limit(1);

המבנה השני נקרא query ואני מגיע אליו דרך אוביקט בשם db.query. מבנה זה הוא יותר High Level וכולל קיצורי דרך לפעולות נפוצות של השאילתה כמו חיבור בין טבלאות או משיכת רק תוצאה אחת. אותה שאילתה עם query תהיה:

db
  .query
  .users
  .findFirst({where: eq(users.name, 'user1')})

נשתמש בכתיב השני כדי לכתוב שתי פונקציות עזר למשיכה של משתמשים לפי שם משתמש או מזהה:

export async function findUserById(userId: number): Promise<SelectUser> {
  const user = await db.query.usersTable.findFirst({where: eq(usersTable.id, userId)});
  if (!user) {
    throw new Error(`User not found: ${userId}`);
  }
  return user;
}

export async function findUserByName(userName: string): Promise<SelectUser> {
  const user = await db.query.usersTable.findFirst({where: eq(usersTable.name, userName)});
  if (!user) {
    throw new Error(`User not found: ${userName}`);
  }
  return user;
}

וממשיך להשתמש בפונקציה כדי למלא כרטיסים למשתמשים בקובץ seed.ts:

import {db} from '@/db/drizzle';
import { SelectUser, vocabularyCardsTable, usersTable } from '@/db/schema';
import { sql, eq } from 'drizzle-orm';

export async function saveCard(user: SelectUser, front: string, back: string) {
  return db.insert(vocabularyCardsTable).values({
    userId: user.id,
    front,
    back,
    nextReviewDate: sql`current_timestamp`
  })
}

export async function findUserById(userId: number): Promise<SelectUser> {
  const user = await db.query.usersTable.findFirst({where: eq(usersTable.id, userId)});
  if (!user) {
    throw new Error(`User not found: ${userId}`);
  }
  return user;
}

export async function findUserByName(userName: string): Promise<SelectUser> {
  const user = await db.query.usersTable.findFirst({where: eq(usersTable.name, userName)});
  if (!user) {
    throw new Error(`User not found: ${userName}`);
  }
  return user;
}

אני מוחק את בסיס הנתונים ומפעיל את קובץ ה seed מחדש הפעם עם הנתונים של שתי הטבלאות:

$ rm local.db
$ npx drizzle-kit push
$ npx tsx --env-file=.env src/db/seed.ts

7. הצגת כל המילים והפירושים של משתמש

נמשיך לעוד דוגמה לשאילתה ודרכה נדבר על קשר בין טבלאות בדריזל - בדוגמה הבאה אני רוצה למצוא משתמשים עם המילים שלהם, כלומר לבצע JOIN בין הטבלאות. שימוש ב select ב drizzle יעבוד והשאילתה הבאה תחזיר לי את כל המילים של המשתמש user1:

db
  .select({
    userId: users.id,
    front: vocabularyCards.front,
    back: vocabularyCards.back,
  })
  .from(users)
  .where(eq(users.name, 'user1'))
  .innerJoin(vocabularyCards, eq(users.id, vocabularyCards.userId))

וקל לראות שאם נוריד את ה where נוכל לקבל את כל המשתמשים וכל המילים שלהם. ויש עוד דרך. דריזל מאפשר לי להגדיר בסכימה יחס בין שתי הטבלאות, כלומר להגדיר ששדה user_id בטבלת המילים מתאים לשדה id בטבלת המשתמשים.

אבל גם ל Join יש קיצור דרך בעזרת db.query. בשביל להשתמש בו עלינו להרחיב את הסכימה ולעדכן את דריזל על הקשר בין שתי הטבלאות. אני מוסיף את שתי הקריאות הבאות לקובץ הסכימה:

export const usersRelations = relations(usersTable, ({ many }) => ({
  words: many(vocabularyCardsTable)
}));

export const vocabularyCardsRelations = relations(vocabularyCardsTable, ({ one }) => ({
  user: one(usersTable, {
    fields: [vocabularyCardsTable.userId],
    references: [usersTable.id]
  })
}))

התחביר הוא שוב דריזלי אבל יחסית קל לקריאה - לאוסף המשתמשים אני יוצר "קשר" שנקרא words והוא מתחבר לטבלת אוצר המילים, בטבלת אוצר המילים אני יוצר את הצד השני של הקשר, ומגדיר שלכל מילה יש "משתמש" שמתאים את השדה userId בטבלת המילים לשדה id בטבלת המשתמשים.

אחרי העדכון אני יכול לכתוב את הקוד:

const usersWithWords = await db.query.usersTable.findFirst({
  with: {
    words: true
  }
})
console.log(usersWithWords);

ולקבל את התוצאה:

{
  id: 1,
  name: 'user1',
  words: [
    {
      id: 1,
      userId: 1,
      front: 'cat',
      back: 'חתול',
      nextReviewDate: '2025-03-03 18:04:28',
      createdAt: '2025-03-03 18:04:28',
      updatedAt: '2025-03-03 18:04:28'
    },
    {
      id: 2,
      userId: 1,
      front: 'dog',
      back: 'כלב',
      nextReviewDate: '2025-03-03 18:04:28',
      createdAt: '2025-03-03 18:04:28',
      updatedAt: '2025-03-03 18:04:28'
    },
    {
      id: 3,
      userId: 1,
      front: 'why',
      back: 'למה',
      nextReviewDate: '2025-03-03 18:04:28',
      createdAt: '2025-03-03 18:04:28',
      updatedAt: '2025-03-03 18:04:28'
    }
  ]
}

8. הצגת משתמשים שאין להם מילים

שאילתה אחרונה לסיפור שלנו היום היא שליפת כל המשתמשים שלא יצרו עדיין מילים. דרך קלה לעשות את זה ב SQL היא להשתמש ב JOIN שמאלי בין שתי הטבלאות ואז לחפש את השורות בהן vocabulary_words.id הוא NULL. ובדריזל? בדיוק אותו דבר:

const usersWithoutCards = await db
  .select()
  .from(usersTable)
  .leftJoin(vocabularyCardsTable, eq(usersTable.id, vocabularyCardsTable.userId))
  .where(isNull(vocabularyCardsTable.id))
  .then(rows => rows.map(row => row.users));

console.log(usersWithoutCards);

9. סיכום

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

קוד הדוגמה המלא שהופיע בפוסט זמין בגיטהאב בקישור:

https://github.com/ynonp/drizzle-intro

אלה מכם שאוהבים השוואות מוזמנים לקרוא גם פוסט שכתבתי על knex בקישור הזה:

https://www.tocode.co.il/blog/2021-02-knex-14-examples

ופוסט על Sequelize כאן:

https://www.tocode.co.il/blog/2019-07-sequelize