חיבור למסד נתונים

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

לפני שמתחילים: צריך שרת MySQL רץ. הדרך הקלה: Docker, לפי השיעור התקנה ועבודה בטרמינל בקורס SQL.

הטבלה

CREATE DATABASE IF NOT EXISTS todo_app CHARACTER SET utf8mb4;
USE todo_app;

CREATE TABLE todos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    done BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

החבילה: mysql2

npm install mysql2
# .env
DATABASE_URL=mysql://root:secret@localhost:3306/todo_app
// src/db.js
import mysql from 'mysql2/promise';

// Pool: כמה חיבורים פתוחים שמשותפים לכל הבקשות, במקום חיבור חדש לכל בקשה
export const pool = mysql.createPool({
    uri: process.env.DATABASE_URL,
    connectionLimit: 10,
});

ה-service, הפעם מול המסד

// src/todos/todos.service.js
import { pool } from '../db.js';

export async function listTodos({ done } = {}) {
    if (done === undefined) {
        const [rows] = await pool.query('SELECT * FROM todos ORDER BY created_at DESC');
        return rows;
    }
    const [rows] = await pool.query('SELECT * FROM todos WHERE done = ? ORDER BY created_at DESC', [done]);
    return rows;
}

export async function getTodo(id) {
    const [rows] = await pool.query('SELECT * FROM todos WHERE id = ?', [id]);
    return rows[0] ?? null;
}

export async function createTodo(title) {
    const [result] = await pool.query('INSERT INTO todos (title) VALUES (?)', [title]);
    return getTodo(result.insertId);
}

export async function updateTodo(id, { title, done }) {
    const [result] = await pool.query(
        'UPDATE todos SET title = COALESCE(?, title), done = COALESCE(?, done) WHERE id = ?',
        [title ?? null, done ?? null, id]
    );
    return result.affectedRows ? getTodo(id) : null;
}

export async function deleteTodo(id) {
    const [result] = await pool.query('DELETE FROM todos WHERE id = ?', [id]);
    return result.affectedRows > 0;
}

בנתיבים משתנה רק דבר אחד: כל קריאה ל-service מקבלת await. שגיאה של המסד מגיעה לבד למטפל השגיאות, כי ב-Express 5 שגיאות async נתפסות.

todosRouter.get('/:id', async (req, res) => {
    const todo = await service.getTodo(req.params.id);
    if (!todo) throw new NotFoundError('המשימה');
    res.json(todo);
});

SQL Injection: הכלל החשוב בשיעור

// ❌ לעולם לא: הקלט של המשתמש נכנס ישר לשאילתה
pool.query(`SELECT * FROM users WHERE email = '${req.body.email}'`);
// מספיק שמישהו ישלח:  ' OR '1'='1  כדי לקבל את כל המשתמשים

// ✅ תמיד: סימני שאלה, והערכים בנפרד
pool.query('SELECT * FROM users WHERE email = ?', [req.body.email]);

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

טרנזקציה: כמה פעולות, הכול או כלום

export async function transfer(fromId, toId, amount) {
    const connection = await pool.getConnection();
    try {
        await connection.beginTransaction();
        await connection.query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [amount, fromId]);
        await connection.query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [amount, toId]);
        await connection.commit();
    } catch (error) {
        await connection.rollback();
        throw error;
    } finally {
        connection.release();  // מחזירים את החיבור ל-Pool, גם בהצלחה וגם בכישלון
    }
}

על המושג עצמו בהרחבה בשיעור טרנזקציות בקורס SQL.

ORM: כשלא רוצים לכתוב SQL ידני

בפרויקטים גדולים משתמשים הרבה פעמים ב-ORM או Query Builder, שמייצרים את ה-SQL מקוד JavaScript ומוסיפים בדיקת טיפוסים:

  • Prisma - מגדירים סכמה בקובץ, ומקבלים לקוח עם השלמה אוטומטית.
  • Drizzle - קרוב מאוד ל-SQL, קל ומהיר, מצוין עם TypeScript.

כדאי להכיר SQL "נקי" קודם, כמו בשיעור הזה: כך מבינים מה ה-ORM עושה, ויודעים לתקן כשמשהו איטי.

ומה עם PostgreSQL או MongoDB?

מסדחבילההערה
PostgreSQLpgאותו רעיון, עם $1, $2 במקום ?
SQLitebetter-sqlite3קובץ אחד, בלי שרת. מצוין לפרויקטים קטנים ולבדיקות
MongoDBmongodb / Mongooseמסד מסמכים (לא טבלאות), בלי SQL

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. למה משתמשים ב-? בשאילתה ולא משרשרים את הקלט?

    1. כדי למנוע SQL Injection: הערכים נשלחים בנפרד ולא כקוד
    2. סגנון
    3. MySQL דורש
    4. זה מהיר יותר
    הצגת התשובה

    תשובה א. שרשור קלט של משתמש לשאילתה הוא אחת הפרצות הנפוצות בעולם.

  2. מה היתרון של Pool חיבורים?

    1. הוא מגבה את המסד
    2. אין
    3. הוא מצפין נתונים
    4. חיבורים פתוחים משותפים לכל הבקשות, במקום לפתוח חיבור חדש בכל בקשה
    הצגת התשובה

    תשובה ד. פתיחת חיבור היא פעולה יקרה.

  3. למה חשוב connection.release() ב-finally?

    1. כדי לסגור את השרת
    2. כדי להחזיר את החיבור ל-Pool גם כשהייתה שגיאה
    3. כדי לשמור נתונים
    4. לא חשוב
    הצגת התשובה

    תשובה ב. חיבורים שלא מוחזרים נגמרים, והשרת נתקע.