# CRUD מלא מתוך Python: מודול db.py שלם ובדיקת כל ארבע הפעולות<no value>

## מה נלמד?

בסוף היום הזה, כמו שהסילבוס מגדיר, יש לנו **CRUD מלא מתוך Python**. השיעור מרכיב את כל החלקים מימים 2–4 ל**מודול אחד**, `db.py`, ובודק אותו מקצה לקצה.

| CRUD | פונקציה | SQL | מחזירה |
|---|---|---|---|
| (הכנה) | `init_db()` | `CREATE TABLE IF NOT EXISTS` | — |
| **C**reate | `create_student(name, age, email)` | `INSERT` | dict (או `ValueError` על אימייל כפול) |
| **R**ead (כולם) | `get_all_students()` | `SELECT` | list של dicts (אולי ריקה) |
| **R**ead (אחד) | `get_student(student_id)` | `SELECT ... WHERE id = %s` | dict או `None` |
| **U**pdate | `update_student(student_id, name, age, email)` | `UPDATE ... WHERE id = %s` | dict, `None`, או `ValueError` |
| **D**elete | `delete_student(student_id)` | `DELETE ... WHERE id = %s` | `True` / `False` |

## למה צריך את זה?

מחר מחברים את המודול הזה לשרת. כל Endpoint יהיה **שורות ספורות**, שקוראות לפונקציה אחת מכאן ומתרגמות את התוצאה ל-HTTP. כדי שזה יעבוד בלי הפתעות, המודול צריך להיות **שלם, עקבי ובדוק**, עם "חוזה" ברור: מה כל פונקציה מחזירה בכל מצב.

## הסבר מהבסיס

### "חוזה" של פונקציה

**חוזה** (Contract) = ההבטחה של הפונקציה למי שקורא לה: מה היא מקבלת, מה היא מחזירה, ואילו שגיאות היא עלולה לזרוק. הטבלה למעלה **היא** החוזה של `db.py`. השרת יסתמך עליה:

```text
get_student(...)    returned None     →  404
create_student(...) raised ValueError  →  409
delete_student(...) returned False    →  404
```

### עקרונות שמשותפים לכל הפונקציות

1. **חיבור לכל פעולה** — `get_connection()`, ו-`close()` ב-`finally`.
2. **פרמטרים בלבד** — `%s`, לעולם לא שרשור.
3. **commit** אחרי כל שינוי.
4. **dict החוצה** — `cursor(dictionary=True)`.
5. **"לא נמצא" = `None` / `False`**, ולא Exception.
6. **הפרת אילוץ = `ValueError`** עם הודעה ברורה.
7. **אין HTTP, אין `print`** — המודול לא יודע מי משתמש בו (שרת, סקריפט, תפריט).

## תחביר

המבנה של המודול:

```text
db.py
├── get_connection()            ← one place that opens connections (host, user, password, database)
├── init_db()                   ← create the table if needed
├── get_all_students()          ← R (list)
├── get_student(student_id)     ← R (one)
├── create_student(...)         ← C
├── update_student(...)         ← U
└── delete_student(student_id)  ← D
```

## דוגמת קוד ראשונה

**`db.py` המלא** (בפרויקט `week-4/school-db`):

```python
import mysql.connector


def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def init_db():
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            """
            CREATE TABLE IF NOT EXISTS students (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                age INT NOT NULL,
                email VARCHAR(100) NOT NULL UNIQUE
            )
            """
        )
    finally:
        connection.close()


def get_all_students():
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute("SELECT id, name, age, email FROM students ORDER BY id")
        return cursor.fetchall()
    finally:
        connection.close()


def get_student(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute(
            "SELECT id, name, age, email FROM students WHERE id = %s", (student_id,))
        return cursor.fetchone()
    finally:
        connection.close()


def create_student(name, age, email):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            "INSERT INTO students (name, age, email) VALUES (%s, %s, %s)",
            (name, age, email))
        connection.commit()
        new_id = cursor.lastrowid
    except mysql.connector.IntegrityError:
        raise ValueError("a student with email " + email + " already exists")
    finally:
        connection.close()
    return get_student(new_id)


def update_student(student_id, name, age, email):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            "UPDATE students SET name = %s, age = %s, email = %s WHERE id = %s",
            (name, age, email, student_id))
        connection.commit()
    except mysql.connector.IntegrityError:
        raise ValueError("a student with email " + email + " already exists")
    finally:
        connection.close()
    return get_student(student_id)


def delete_student(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute("DELETE FROM students WHERE id = %s", (student_id,))
        connection.commit()
        return cursor.rowcount > 0
    finally:
        connection.close()
```

## פירוק הקוד שורה אחר שורה

| פונקציה | מה חדש בה / מה לשים לב |
|---|---|
| `get_connection` | המקום **היחיד** שפותח חיבור — וגם היחיד שמכיר את פרטי החיבור. |
| `init_db` | `IF NOT EXISTS` ‏←‏ בטוח לקרוא לה בכל הפעלה של השרת. |
| `get_all_students` | `ORDER BY id` לסדר יציב. רשימה ריקה היא תשובה תקינה. |
| `get_student` | `None` כשלא נמצא. כל השאר משתמשים בה כדי להחזיר "את מה שבמסד". |
| `create_student` | `mysql.connector.IntegrityError` → `ValueError`. `new_id` נשמר **בתוך** ה-try, ו-`get_student` נקראת **אחרי** הסגירה. |
| `update_student` | שלושה מצבים: dict / `None` / `ValueError` (אימייל תפוס). את "לא נמצא" קובעת `get_student` אחרי העדכון — **לא** `rowcount`, כי ב-MySQL עדכון לאותם ערכים מחזיר `rowcount` 0. |
| `delete_student` | `True` / `False`. לא מחזירה את מה שנמחק (ב-API: 204, בלי גוף). |

**שימו לב לחזרה על `"SELECT id, name, age, email ..."`:** בפונקציות של נושא מסדי נתונים ו-MySQL בתרגול העצמאי ראינו קבוע `COLUMNS` שמונע את החזרה. כאן השארנו את השאילתות מלאות, כדי שכל פונקציה תהיה **קריאה לבד**, ללמידה. שתי הגישות לגיטימיות.

## דוגמה נוספת

**תסריט בדיקה לכל ארבע הפעולות** — "תרגול כל ארבע הפעולות על Entity אחת" מהסילבוס. `check_crud.py`:

```python
import db

connection = db.get_connection()
connection.cursor().execute("DROP TABLE IF EXISTS students")
connection.close()
db.init_db()

print("--- CREATE ---")
dana = db.create_student("Dana", 24, "dana@mail.com")
avi = db.create_student("Avi", 31, "avi@mail.com")
print(dana)
print(avi)
try:
    db.create_student("Dana Copy", 20, "dana@mail.com")
except ValueError as error:
    print("Duplicate rejected:", error)

print("--- READ ---")
print("All:", db.get_all_students())
print("Id 2:", db.get_student(2))
print("Id 99:", db.get_student(99))

print("--- UPDATE ---")
print("Update id 2:", db.update_student(2, "Avi Levi", 32, "avi@mail.com"))
print("Update id 2 again:", db.update_student(2, "Avi Levi", 32, "avi@mail.com"))
print("Update id 99:", db.update_student(99, "Ghost", 30, "ghost@mail.com"))
try:
    db.update_student(2, "Avi Levi", 32, "dana@mail.com")
except ValueError as error:
    print("Email clash rejected:", error)

print("--- DELETE ---")
print("Delete id 1:", db.delete_student(1))
print("Delete id 1 again:", db.delete_student(1))
print("All at the end:", db.get_all_students())
```

**פלט:**

```text
--- CREATE ---
{'id': 1, 'name': 'Dana', 'age': 24, 'email': 'dana@mail.com'}
{'id': 2, 'name': 'Avi', 'age': 31, 'email': 'avi@mail.com'}
Duplicate rejected: a student with email dana@mail.com already exists
--- READ ---
All: [{'id': 1, 'name': 'Dana', 'age': 24, 'email': 'dana@mail.com'}, {'id': 2, 'name': 'Avi', 'age': 31, 'email': 'avi@mail.com'}]
Id 2: {'id': 2, 'name': 'Avi', 'age': 31, 'email': 'avi@mail.com'}
Id 99: None
--- UPDATE ---
Update id 2: {'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}
Update id 2 again: {'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}
Update id 99: None
Email clash rejected: a student with email dana@mail.com already exists
--- DELETE ---
Delete id 1: True
Delete id 1 again: False
All at the end: [{'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}]
```

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

**ופתחו את טבלת `students` ב-Database Client:** נשאר רק Avi Levi, עם id 2.

## טעות נפוצה

**הטעות 1: חוזה לא עקבי.** `get_student` מחזירה `None` כשלא נמצא, אבל `update_student` זורקת `KeyError` ו-`delete_student` מחזירה `0`. השרת יצטרך לטפל בכל פונקציה אחרת. **מצב "לא נמצא" באותה צורה בכל המודול.**

**הטעות 2: `print` בתוך `db.py`.** `print("Student created!")` בתוך `create_student`. בשרת זה ילך לשום מקום, ובבדיקות זה "רעש". שכבת הנתונים **מחזירה** ערכים. מי שקורא לה מחליט מה להציג. (ללוג ‏←‏ `logger`.)

**הטעות 3: מחיקה בתסריט הבדיקה בלי לחשוב.** `DROP TABLE IF EXISTS students` בראש `check_crud.py` מוחק את **כל הנתונים**. מעולה לבדיקה, **אסון** אם מריצים בטעות מול מסד אמיתי. (בהמשך המסלול תלמדו להריץ בדיקות על מסד נפרד, למשל `school_test`.)

**הטעות 4: לבדוק רק את ה-Create וה-Read.** הבאגים הכי מעניינים מתחבאים ב-UPDATE (סדר פרמטרים, WHERE) וב-DELETE (rowcount). **כל ארבע הפעולות, עם מקרי קצה.**

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## תרגול

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

### תרגיל 1: החוזה של המודול

כתבו טבלת "חוזה" לחמש הפונקציות של `db.py`: מה כל אחת מקבלת, מה היא מחזירה, ומה קורה כשאין סטודנט כזה.

```text
get_all_students   get_student   create_student   update_student   delete_student
```

{{< solution >}}
| פונקציה | מקבלת | מחזירה | אם אין סטודנט |
|---|---|---|---|
| `get_all_students()` | — | רשימה של מילונים | רשימה ריקה |
| `get_student(id)` | id | מילון | `None` |
| `create_student(name, age, email)` | הנתונים | מילון (עם id) | — (אימייל כפול ‏←‏ `ValueError`) |
| `update_student(id, name, age, email)` | id + הנתונים | מילון מעודכן | `None` |
| `delete_student(id)` | id | `True` | `False` |

חוזה אחיד = השרת יודע בדיוק מה לצפות מכל פונקציה, ואיך לתרגם כל תוצאה לקוד HTTP.
{{< /solution >}}

### תרגיל 2: create_student עם תרגום שגיאה

כתבו את `get_connection`, `init_db`, `get_student` ו-`create_student`, שמתרגמת אימייל כפול ל-`ValueError`.

```python
init_db()
print(create_student("Dana", 24, "dana@mail.com"))
try:
    create_student("Copy", 20, "dana@mail.com")
except ValueError as error:
    print("Duplicate rejected:", error)
```

**פלט צפוי:**

```text
{'id': 1, 'name': 'Dana', 'age': 24, 'email': 'dana@mail.com'}
Duplicate rejected: a student with email dana@mail.com already exists
```

{{< solution >}}
```python
import mysql.connector


def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def init_db():
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS students (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                age INT NOT NULL,
                email VARCHAR(100) NOT NULL UNIQUE
            )
        """)
    finally:
        connection.close()


def get_student(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
        return cursor.fetchone()
    finally:
        connection.close()


def create_student(name, age, email):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            "INSERT INTO students (name, age, email) VALUES (%s, %s, %s)",
            (name, age, email))
        connection.commit()
        new_id = cursor.lastrowid
    except mysql.connector.IntegrityError:
        raise ValueError("a student with email " + email + " already exists")
    finally:
        connection.close()
    return get_student(new_id)


init_db()
print(create_student("Dana", 24, "dana@mail.com"))
try:
    create_student("Copy", 20, "dana@mail.com")
except ValueError as error:
    print("Duplicate rejected:", error)
```

```text
{'id': 1, 'name': 'Dana', 'age': 24, 'email': 'dana@mail.com'}
Duplicate rejected: a student with email dana@mail.com already exists
```

`create_student` לא שולפת בעצמה — היא קוראת ל-`get_student` עם ה-id החדש. ה-`finally` סוגר את החיבור גם כשנזרקת `ValueError`.
{{< /solution >}}

### תרגיל 3: המודול המלא

השלימו את `db.py` עם `get_all_students`, `update_student` ו-`delete_student` (לפי החוזה), ובדקו את כל הפעולות.

```python
init_db()
dana = create_student("Dana", 24, "dana@mail.com")
avi = create_student("Avi", 31, "avi@mail.com")
print(len(get_all_students()))
print(update_student(avi["id"], "Avi Levi", 32, "avi@mail.com"))
print(update_student(99, "X", 1, "x@mail.com"))
print(delete_student(dana["id"]), delete_student(dana["id"]))
print(get_student(dana["id"]))
print(get_all_students())
```

**פלט צפוי:**

```text
2
{'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}
None
True False
None
[{'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}]
```

{{< solution >}}
```python
import mysql.connector


def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def init_db():
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS students (
                id INT AUTO_INCREMENT PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                age INT NOT NULL,
                email VARCHAR(100) NOT NULL UNIQUE
            )
        """)
    finally:
        connection.close()


def get_all_students():
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute("SELECT * FROM students ORDER BY id")
        return cursor.fetchall()
    finally:
        connection.close()


def get_student(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute("SELECT * FROM students WHERE id = %s", (student_id,))
        return cursor.fetchone()
    finally:
        connection.close()


def create_student(name, age, email):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            "INSERT INTO students (name, age, email) VALUES (%s, %s, %s)",
            (name, age, email))
        connection.commit()
        new_id = cursor.lastrowid
    except mysql.connector.IntegrityError:
        raise ValueError("a student with email " + email + " already exists")
    finally:
        connection.close()
    return get_student(new_id)


def update_student(student_id, name, age, email):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute(
            "UPDATE students SET name = %s, age = %s, email = %s WHERE id = %s",
            (name, age, email, student_id))
        connection.commit()
    except mysql.connector.IntegrityError:
        raise ValueError("a student with email " + email + " already exists")
    finally:
        connection.close()
    return get_student(student_id)


def delete_student(student_id):
    connection = get_connection()
    try:
        cursor = connection.cursor()
        cursor.execute("DELETE FROM students WHERE id = %s", (student_id,))
        connection.commit()
        return cursor.rowcount > 0
    finally:
        connection.close()


init_db()
dana = create_student("Dana", 24, "dana@mail.com")
avi = create_student("Avi", 31, "avi@mail.com")
print(len(get_all_students()))
print(update_student(avi["id"], "Avi Levi", 32, "avi@mail.com"))
print(update_student(99, "X", 1, "x@mail.com"))
print(delete_student(dana["id"]), delete_student(dana["id"]))
print(get_student(dana["id"]))
print(get_all_students())
```

```text
2
{'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}
None
True False
None
[{'id': 2, 'name': 'Avi Levi', 'age': 32, 'email': 'avi@mail.com'}]
```

כל פונקציה עומדת בחוזה: מילון / `None` / `True` / `False` / `ValueError` — בלי שום דבר של `mysql.connector` שדולף החוצה.
{{< /solution >}}

### תרגיל 4: חיפוש לפי שם

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

```py
import mysql.connector

connection = mysql.connector.connect(
    host="localhost", user="root", password="kodkod", database="school")
cursor = connection.cursor()
cursor.execute("DROP TABLE IF EXISTS students")   # start clean, so ids start from 1
cursor.execute("CREATE TABLE students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))")
cursor.execute("INSERT INTO students (name) VALUES ('Dana'), ('Avi'), ('Daniel')")
connection.commit()
connection.close()
```

הוסיפו `search_students(text)` שמחזירה **רשימה של מילונים** עם כל הסטודנטים שהשם שלהם **מכיל** את הטקסט, בעזרת `LIKE` ו-`%`, ממוינים לפי `id`.

```python
print(search_students("an"))
```

**פלט צפוי:**

```text
[{'id': 1, 'name': 'Dana'}, {'id': 3, 'name': 'Daniel'}]
```

{{< solution >}}
```python
import mysql.connector


def get_connection():
    return mysql.connector.connect(
        host="localhost", user="root", password="kodkod", database="school")


def search_students(text):
    connection = get_connection()
    try:
        cursor = connection.cursor(dictionary=True)
        cursor.execute(
            "SELECT * FROM students WHERE name LIKE %s ORDER BY id",
            ("%" + text + "%",))
        return cursor.fetchall()
    finally:
        connection.close()


connection = get_connection()
cursor = connection.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS students "
               "(id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100))")
for name in ["Dana", "Avi", "Daniel"]:
    cursor.execute("INSERT INTO students (name) VALUES (%s)", (name,))
connection.commit()
connection.close()

print(search_students("an"))
```

```text
[{'id': 1, 'name': 'Dana'}, {'id': 3, 'name': 'Daniel'}]
```

- `LIKE` משווה לפי תבנית. `%` = "כל רצף של תווים".
- את ה-`%` מוסיפים ל**ערך** — לא לפקודה — כך שגם החיפוש נשאר בטוח מ-SQL Injection.
{{< /solution >}}

### תרגיל 5: תסריט בדיקה

למה כדאי לכתוב תסריט שבודק את כל חמש הפונקציות של `db.py`, ולהריץ אותו על **טבלה ריקה**?

{{< solution >}}
- **בדיקה אחת שמכסה הכול** — אחרי כל שינוי במודול מריצים את התסריט ורואים מיד אם משהו נשבר, במקום לבדוק ידנית כל פונקציה.
- **טבלה ריקה** — התוצאות **צפויות**: ה-id הראשון הוא 1, הרשימה מתחילה ריקה. על טבלה עם נתונים ישנים, אותה בדיקה הייתה מחזירה תוצאות שונות בכל הרצה.
- **בודקים גם כישלונות**: id שלא קיים, אימייל כפול, מחיקה כפולה — שם מסתתרים רוב הבאגים.

(מתחילים את התסריט ב-`DROP TABLE IF EXISTS students` ואז `init_db()` — ועדיף על מסד בדיקות נפרד, למשל `school_test`, כדי לא למחוק נתונים אמיתיים.)
{{< /solution >}}

<!-- exercises:end -->

## סיכום

- **`db.py` = CRUD מלא על Entity אחת**: `init_db`, `create`, `get_all`, `get`, `update`, `delete`.
- **חוזה עקבי**: list / dict / `None` / `True`-`False` / `ValueError`. "לא נמצא" תמיד באותה צורה.
- כל פונקציה: חיבור משלה, `%s`, commit, `cursor(dictionary=True)`, ו-`finally: close()`.
- **אין HTTP ואין print** בשכבת הנתונים. היא מחזירה ערכים, ומי שקורא מחליט.
- תסריט בדיקה שעובר על **כל ארבע הפעולות ועל מקרי הקצה**. מחר כל שורה בו תהפוך לבקשת HTTP.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## דוקומנטציה רשמית

- [Connector/Python — Coding Examples](https://dev.mysql.com/doc/connector-python/en/connector-python-examples.html) — כל הדוגמאות הרשמיות במקום אחד
- [Python Tutorial — Modules](https://docs.python.org/3/tutorial/modules.html) — מודול db.py

<!-- docs:end -->
