iT邦幫忙

2023 iThome 鐵人賽

DAY 22
0

前言

昨天我們已經模擬了幾個新增欄位的情境,今天我們要來模擬如何在欄位中加入不能指定一次性預設值的欄位吧!

今天我們要模擬的情境是,假設今天需要加入一個「分類」的表,並且將他跟任務關聯再一起(一對多關係,一個分類會有多個任務),讓我們一起做看看吧!

新增分類表

首先我們來建立一個分類的 Model 吧!讓我們編輯 server/app/todo/models.py 檔案

from django.db import models


class Tag(models.Model):
    name = models.CharField(max_length=255, unique=True)
    description = models.TextField(blank=True)

    def __str__(self):
        return self.name


+class Category(models.Model):
+    name = models.CharField(max_length=255, unique=True)
+
+    def __str__(self):
+        return self.name


class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

這邊我們建立了一個 Category Model 裡面的欄位跟之前的 Tag 一樣,都是有一個文字型態欄位 name 並設定最大長度 255 且不能重複。

接著讓我們建立遷移檔並套用

python manage.py makemigrations
python manage.py migrate

現在可以看看資料庫中已經出現分類這張表了

將分類關聯到任務

接下來我們要將 Category 關聯到 Task,讓我們編輯 server/app/todo/models.py 檔案

# ...... 以上省略 ......

class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
+   category = models.ForeignKey(Category, on_delete=models.PROTECT)

    def __str__(self):
        return self.title

這邊我們在 Task Model 中加入一個欄位 category 是一個 ForeignKey(外鍵),他表達了 Task 關聯到 Category 這張表。而我們在這邊設定了 on_delete 設定為 models.PROTECT(保護)他代表的是當 category 資料被刪除時應該要怎麼處理 task 資料。

除了保護以外還有幾個選項可以選,這邊我們會介紹四個最常用的(這邊會用分類與任務的關係來說明):

  • CASCADE: 當分類被刪除時連帶刪除此非類的任務
  • PROTECT: 當分類被刪除時如果有任務屬於這個任務,則阻止分類被刪除
  • SET_NULL: 當分類被刪除時,將任務的分類欄位設為 null(該欄位要設定為 null=True
  • SET_DEFAULT: 當分類被刪除時,將任務的分類欄位設為預設值(該欄位要有設定 default=預設值

除了這四個以外,還有奇功其他的選項,只是相對少用,大家有興趣可以參考文件

接著我們試著建立遷移檔看看

python manage.py makemigrations

應該會看到下方的輸出

It is impossible to add a non-nullable field 'category' to task without specifying a default. This is because the database needs something to populate existing rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit and manually define a default value in models.py.
Select an option:

代表我們現在 Django 無法針對舊資料進行調整,我們需要告訴他該在舊資料的 category 欄位放入什麼值,但現在不能給訂一個一次性的預設值,因為我們有可能沒有已存在的分類可以使用,我們必須確認分類已經存在這樣才能放心地使用它。所以這邊我們先輸入 2 再按 enter 先退出這次的遷移檔建立。

客製化遷移檔

現在讓我們將問題拆解一下,在要能建立必填的 category 欄位,但又有舊資料需要處理的情況。

  1. 我們先在 task 中建立 category 欄位,先暫時設定為可以 null
  2. 接著我們執行一段 Python 將舊有的資料先從 null 跟我們想要的分類關聯起來
  3. 再將 category 調整成不可以為 null

這樣三個步驟都執行完後的結果就是我們想要的了,讓我們開始吧!

步驟一

先編輯 server/app/todo/models.py 檔案

# ...... 以上省略 ......

class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
-   category = models.ForeignKey(Category, on_delete=models.PROTECT)
+   category = models.ForeignKey(Category, on_delete=models.PROTECT, null=True)

    def __str__(self):
        return self.title

將 category 設定成可以為 null,接著建立步驟一個遷移檔

python manage.py makemigrations

步驟二

接著我們要執行一段 Python 將現有任務的 category 從 null 更新成我們想要的分類,但我們等到遷移檔執行到步驟一再手動執行我們的 Python 明顯是不方便的,所以最理想的方式是有一個遷移檔可以幫我們執行那段 Python 程式,所以讓我們建立一個空的遷移檔吧!

python manage.py makemigrations --empty --name set_default_category_to_tasks todo

這個指令是要在 todo 這個 app 建立一個空的(empty)遷移檔,並將名稱(name)設定為 set_default_category_to_tasks,當指令下完後大家應該會看到 0011_set_default_category_to_tasks.py 檔案出現在 server/app/todo/migrations

讓我們一起看看檔案內容

# Generated by Django 4.2.5 on 2023-10-06 17:28

from django.db import migrations


class Migration(migrations.Migration):
    dependencies = [
        ("todo", "0010_task_category"),
    ]

    operations = []

會看到目前是完全空的,讓我們填入我們想要的程式吧!讓我們編輯 server/app/todo/migrations/0010_set_default_category_to_tasks.py 檔案

# Generated by Django 4.2.5 on 2023-10-06 17:28

from django.db import migrations


+def set_default_category_to_task(apps, schema_editor):
+   task_model = apps.get_model("todo", "Task")
+   if task_model.objects.count() == 0:
+       return
+
+   category_model = apps.get_model("todo", "Category")
+   default_category, _ = category_model.objects.get_or_create(name="default")
+   task_model.objects.update(category=default_category)


class Migration(migrations.Migration):
+   atomic = False
+
    dependencies = [
        ("todo", "0009_category"),
    ]

-   operations = []
+   operations = [
+       migrations.RunPython(
+           set_default_category_to_task,
+           reverse_code=migrations.RunPython.noop,
+           atomic=True,
+       ),
+   ]

這邊我們做的事情是我們在 operations 中設定一個 RunPython,他會執行 set_default_category_to_task 這個函式,且我們設定 reverse_code 為 migrations.RunPython.noop 意思是當遷移檔降版時什麼都不處理(不果不設定 reverse_code 的話,降版會錯誤)就直降版,如果有降版需要執行的動作可以放一個 function 這邊什麼都不需要處理所以我們放 noop

接著我們看一下在 class Migration(migrations.Migration): 這行下方我們設定 atomic=False 因為不這樣設定會無法執行資料庫操作(ORM 語法)但我們在 RunPython 中有設定 atomic=True 代表在執行 set_default_category_to_task 時會被包在資料庫的交易(代表發生錯誤的話會退回執行前)當中。

我們現在繼續看 set_default_category_to_task 的內容他做的事情是透過 apps.get_model 取得當前版本的 Model 要特別注意這邊不能直接從 models.py 檔案 import 進來使用喔。

原因是這樣的,當你要下 python manage.py migrate 這個指令時,如果是一個新的資料庫,他會幫你從 0001 開始跑,那假設你的版本最高到 0020 但你在 0010 這邊想要使用 Model,假設這邊你直接 import 會得到的是 0020 版的 model 那可能就會多或少欄位,但你如果用 get_model 這個方法取得,那你就可以得到 0009 版的 model 就不會發生錯誤,這個部分是很多人在客製化 migrate 時會遇到的問題,這個要特別注意一下。

接著我們看一下是否有已經存在的任務,如果有就取得如果不存在則新增(get_or_create)一個名稱為 default 的分類,並將所有任務的分類更新成這個 default 的,這樣目前資料庫中就不存在分類為 null 的任務了。

另外這邊說明一下 get_or_create 會回傳兩個值,第一個是那筆資料,第二個是一個布林值代表的是這次的資料是否是新建立的,如果是新建立的則為 True,那因為我們用不到所以我宣告一個變數 _ 來放他(這是 Python 的習慣)代表用不到。

步驟三

接下來步驟三我們要把 category 那個欄位改回不可是 null,讓我們編輯 server/app/todo/models.py 檔案

class Task(models.Model):
    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    is_finish = models.BooleanField(default=False)
    tags = models.ManyToManyField(Tag)
    end_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
-   category = models.ForeignKey(Category, on_delete=models.PROTECT, null=True)
+   category = models.ForeignKey(Category, on_delete=models.PROTECT)

    def __str__(self):
        return self.title

接著我們產生遷移檔

python manage.py makemigrations

現在我們應該會看到這個輸出結果

It is impossible to change a nullable field 'category' on task to non-nullable without providing a default. This is because the database needs something to populate existing rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Ignore for now. Existing rows that contain NULL values will have to be handled manually, for example with a RunPython or RunSQL operation.
 3) Quit and manually define a default value in models.py.
Select an option:

那這邊 1 與 3 選項之前都出現過了,這邊多出一個 2 選項簡而言之就是說我們手動處理了已存在的資料(前面的 RunPython)所以 Django 不用擔心,那這是我們想要的所以這邊輸入 2 後按下 enter 就能產生遷移檔了

最後一步

現在我們都設定好了我們就將遷移檔套用到資料庫中吧!

python manage.py migrate

總結

今天我們學會了要怎麼在現有資料庫中加入必填的欄位,且透過客製化遷移檔來指定預設值,對剛開始學的人來說客製化遷移檔可能會有些複雜,但這個技巧有很多人並不熟悉,所以我還是想要寫一下關於這個技巧的教學,但因為屬於比較進階一點點的技巧,所以如果暫時看不懂,可以先跟著操作後續使用多次後就會更了解了!

結束前別忘了檢查一下今天的程式碼有沒有問題,並排版好喔。

ruff check --fix .
black .
pyright .

今天的內容就到這邊了,讓我們期待明天的內容吧。

P.S. 今天的檔案更新可以參考我的 Git Commit 大家可以搭配服用


上一篇
Day21 - 在已存在的 Model 中加欄位
下一篇
Day23 - 實作 Category 相關功能
系列文
Django REST 大冒險:探索精彩紛呈的 API 開發世界30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言