ORMとは?なぜ必要なのか

ORMという言葉を初めて聞くと、少し難しそうに見えます。けれど、やっていることは意外とシンプルです。ORMはObject-Relational Mappingの略で、SQLを直接書かずに、Pythonのコードだけでデータベースを操作できる仕組みです。Djangoにはこの仕組みが最初から用意されています。

20歳より上の従業員を探してみます。SQLで書くと次のようになります。

SELECT * FROM employees WHERE age > 20;

同じ検索をDjango ORMで書いてみます。

Employee.objects.filter(age__gt=20)

Pythonのコードとして読めるので、どんな条件で探しているのかを追いやすくなります。条件をあとから足すのも簡単です。ただし、SQLがなくなったわけではありません。見えないところではORMがSQLを組み立てています。

開発環境

$ python --version
Python 3.13.0
$ python -m django --version
6.1
$ sqlite3 -version
3.51.0

プロジェクト作成

適当なディレクトリに移動したら下記コマンドでプロジェクトファイルを作成します。

$ django-admin startproject config .

続いてアプリを作成。

$ python manage.py startapp main

下記のような構成になります。

Attendance/
├── manage.py
├── config/
│ ├── settings.py
│ └── urls.py
└── main/
├── migrations/
├── admin.py
├── apps.py
└── models.py

モデルを書くファイルはmain/models.pyです。アプリを作ったばかりなら、先にconfig/settings.pyを開き、INSTALLED_APPSへmainを追加します。

config/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'main',
]

1人の従業員が複数日の勤怠記録を持てる、小さな管理ツールを作ります。まずは従業員と勤怠記録を別々に定義し、あとから2つをつなぎます。

モデルを定義してみる

モデルは普通のPythonクラスに見えますが、models.Modelを継承しているところがポイントです。このクラスには、Djangoがデータベースとやり取りするための機能が入っています。継承すると、objects.create()やsave()を使えるようになります。

main/models.py
from django.db import models
class Employee(models.Model):
name = models.CharField('氏名', max_length=100)
employee_number = models.CharField('社員番号', max_length=20, unique=True)
age = models.IntegerField('年齢')
def __str__(self):
return self.name
class Attendance(models.Model):
note = models.TextField('備考', blank=True)
work_minutes = models.IntegerField('勤務時間(分)', default=0)
clock_in = models.DateTimeField('出勤日時')
clock_out = models.DateTimeField('退勤日時', null=True, blank=True)
def __str__(self):
return str(self.clock_in)

CharFieldなどの最初の引数に書いた「氏名」や「社員番号」はverbose_nameです。Django管理画面やフォームで、人が読みやすい項目名として使われます。

フィールド型はたくさんあります。最初から全部覚える必要はありません。まずは、ここで使う4つを押さえておきましょう。

フィールド 用途
CharField 名前やタイトルなど、長さに上限のある文字列。max_lengthが必要です。
TextField 備考などの長い文章。
IntegerField 年齢や勤務時間などの整数。
DateTimeField 出勤・退勤などの日付と時刻。

モデルのクラスはテーブルの設計図です。クラスに書いたフィールドが、テーブルの列になります。コードにはidが見当たりませんが、主キーを自分で定義しなければDjangoが自動で追加します。

Pythonのモデル データベースのテーブル
class Employee(models.Model) main_employeeテーブル
name = CharField(...) name列
age = IntegerField() age列
Djangoが自動追加 id列(主キー)

マイグレーションでテーブルを作る

モデルを書いたのに、テーブルが見つからない。Djangoを触り始めた頃は、ここで戸惑いがちです。models.pyにクラスを書いただけでは、データベースにテーブルは作られません。makemigrationsで変更内容を記録し、migrateでデータベースへ反映します。

1. 変更内容からマイグレーションファイルを作る

$ python manage.py makemigrations
Migrations for 'main':
main/migrations/0001_initial.py
+ Create model Attendance
+ Create model Employee

このコマンドを実行すると、モデルと直前の状態をDjangoが見比べ、差分のマイグレーションファイルが生成されます。初回はmain/migrations/0001_initial.pyが生成されます。

2. マイグレーションをデータベースへ適用する

$ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, main, sessions
Running migrations:
Applying main.0001_initial... OK

OKが出たら、テーブルの作成は完了です。この先モデルを変更したときも、makemigrations、migrateの順に実行します。

テーブルが作られないときは、mainがINSTALLED_APPSに入っているか確認します。コマンドを実行した場所も見ておきましょう。manage.pyがあるディレクトリで実行します。どこまで適用されたか調べたいときは、python manage.py showmigrationsで確認できます。

Django shellでCRUDを試す

ここからは、Django shellでデータを触ってみましょう。Django shellは、models.pyに書いたモデルをimportし、対話形式で操作できます。短いコードを1行ずつ実行しながら、データの作成や検索を試せます。 manage.pyがあるディレクトリで起動します。

$ python manage.py shell

>>>が付いた行を入力してください。その次の行が表示例です。作成、取得、更新、削除の順に進めます。この4つの操作を、英語の頭文字からCRUDと呼びます。

データを作成する:create()

>>> from main.models import Employee, Attendance
>>> employee = Employee.objects.create(
... name='佐藤',
... employee_number='E001',
... age=28,
... )
>>> employee.id
1

create()を使うと、オブジェクトの作成とデータベースへの保存が一度に済みます。返ってきたオブジェクトをemployeeへ入れておけば、そのまま次の操作に使えます。

データを取得する:all()、get()、filter()

>>> Employee.objects.all()
<QuerySet [<Employee: 佐藤>]>
>>> Employee.objects.get(employee_number='E001')
<Employee: 佐藤>
>>> Employee.objects.filter(age__gte=20)
<QuerySet [<Employee: 佐藤>]>

all()は全件を取得します。条件を付けて探すならfilter()です。どちらも結果はQuerySetで返ってきます。get()は少し性格が違い、1件だけを直接返します。主キーなど、結果が1件に決まる条件で使います。

データを更新する:save()

>>> employee = Employee.objects.get(employee_number='E001')
>>> employee.age = 29
>>> employee.save()
>>> Employee.objects.get(employee_number='E001').age
29

更新するときは、取得したオブジェクトの値を書き換えてsave()を呼びます。ここでは年齢を28から29へ変更しました。

データを削除する:delete()

>>> employee.delete()
(1, {'main.Employee': 1})
>>> Employee.objects.all()
<QuerySet []>

delete()を呼ぶと、そのデータが削除されます。戻り値に表示されているのは、削除した件数とモデルごとの内訳です。この先でも従業員を使うので、削除できたことを確認したら作り直しておきます。

>>> employee = Employee.objects.create(
... name='佐藤', employee_number='E001', age=29
... )

QuerySetと遅延評価を理解する

filter()を書いた瞬間にSQLが実行されると思うかもしれません。実際には、QuerySetを作っただけではデータベースへ問い合わせされません。検索結果が必要になったところでSQLが実行されます。この動きを遅延評価と呼びます。

employees = Employee.objects.filter(age__gte=20) # 通常、まだ問い合わせない
employees = employees.filter(name__contains='佐') # 条件を追加できる
for employee in employees: # ここで結果が必要になる
print(employee.name)

すぐに問い合わせないので、条件を少しずつ足してからまとめて実行できます。ただし、いつSQLが動くのかを知らないと、同じ問い合わせを何度も実行してしまうことがあります。ループで使う、list(queryset)でリストにする、件数を取得するといったタイミングで評価されます。Django shellでQuerySetの中身を表示したときも、結果を見せるために問い合わせが発生します。

QuerySetからSQLを確認する

導入で見たORMのコードが、どんなSQLに変換されるのかも見てみましょう。QuerySetのquery属性をDjango shellで表示します。

>>> employees = Employee.objects.filter(age__gt=20)
>>> print(employees.query)
SELECT ... FROM "main_employee" WHERE "main_employee"."age" > 20

テーブル名や引用符の付き方は、使っているデータベースによって変わります。query属性で確認できるのは、QuerySetから組み立てられたSQLです。検索結果そのものは取得しないため、QuerySetの遅延評価は保たれています。

age__gteのように、フィールド名の後ろへ二重アンダースコアで条件を付ける書き方をフィールドルックアップと呼びます。よく使うものを見ておきましょう。

ルックアップ 意味 例
__gte 以上 age__gte=20
__gt より大きい age__gt=20
__contains 文字列を含む name__contains='佐'
__startswith 指定文字列で始まる name__startswith='佐'

ForeignKeyでモデル同士を関連づける

ここまでは従業員と勤怠記録が別々に存在していました。勤怠管理ツールとして使うなら、どの記録が誰のものなのか分からなければ困ります。1人の従業員が複数の勤怠記録を持つ関係を1対多と呼びます。Djangoでは、複数になる勤怠記録側へForeignKeyを定義します。

erDiagram
  EMPLOYEE ||--o{ ATTENDANCE : "勤怠を記録する"

  EMPLOYEE {
    bigint id PK
    string name
    string employee_number
    int age
  }

  ATTENDANCE {
    bigint id PK
    bigint employee_id FK
    text note
    int work_minutes
    datetime clock_in
    datetime clock_out
  }

図の||は従業員が必ず1人いること、o{は勤怠記録が0件以上あることを示しています。勤怠記録テーブルのemployee_idが、従業員テーブルのidを指します。

コードも見てみます。Attendanceモデルにemployeeを追加してください。

main/models.py
class Attendance(models.Model):
employee = models.ForeignKey(
Employee,
on_delete=models.CASCADE,
verbose_name='従業員',
)
note = models.TextField('備考', blank=True)
work_minutes = models.IntegerField('勤務時間(分)', default=0)
clock_in = models.DateTimeField('出勤日時')
clock_out = models.DateTimeField('退勤日時', null=True, blank=True)
def __str__(self):
return f'{self.employee.name}の勤怠'

ForeignKeyでは最初の引数に関連先のモデルを書くため、項目名はverbose_name='従業員'と指定します。

on_delete=models.CASCADEを指定すると、従業員を削除したときに、その従業員の勤怠記録も一緒に削除されます。

モデルを変更したので、マイグレーションも忘れずに実行します。その前に、先ほど作り直した従業員のidをDjango shellで確認しておきます。

>>> employee.id
2
>>> exit()

ここでは2と表示されましたが、値は環境によって変わります。自分の画面に表示された値を控えてください。

makemigrationsを実行すると、次の質問が表示されます。

$ python manage.py makemigrations
It is impossible to add a non-nullable field 'employee' to attendance 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:

employeeはnullを許可しない設定にしました。 すでにある勤怠記録にも従業員が必要になるため、どの従業員を入れるか確認されます。 makemigrationsはテーブル内のデータ件数を調べないので、今回のように勤怠記録が0件でも確認されます。

それぞれ選択肢は下記のような内容になってます。

  1. 今回のマイグレーションだけで使うデフォルト値を入力します。
  2. makemigrationsを中止し、モデルの定義を見直します。

今回は1を入力してEnterキーを押します。

Select an option: 1

1を選ぶと、デフォルト値の入力画面へ進みます。

Please enter the default value as valid Python.
The datetime and django.utils.timezone modules are available, so it is possible to provide e.g. timezone.now as a value.
Type 'exit' to exit this prompt
>>>

今回はまだ勤怠記録を作成していないため、デフォルト値が設定される既存データはありません。>>>の後ろへ、先ほど確認したemployee.idを整数で入力します。途中に表示されるtimezone.nowは日時フィールド向けの例なので、今回は使いません。

>>> 2

上の2は従業員IDの表示例です。自分の画面で確認した値を使ってください。

マイグレーションファイルが作られたら、データベースへ適用します。

$ python manage.py migrate

マイグレーションが終わったらshellを開き直し、従業員とつながった勤怠記録を作ります。

>>> from django.utils import timezone
>>> from main.models import Employee, Attendance
>>> employee = Employee.objects.get(employee_number='E001')
>>> attendance = Attendance.objects.create(
... employee=employee,
... clock_in=timezone.now(),
... note='通常勤務',
... )

勤怠記録から従業員をたどるのはattendance.employeeで取得できます。 反対に、従業員からその人の勤怠記録を探すときはattendance_setを使います。これはDjangoが用意するデフォルトの逆参照名です。

>>> attendance.employee
<Employee: 佐藤>
>>> employee.attendance_set.all()
<QuerySet [<Attendance: 佐藤の勤怠>]>

勤怠記録から従業員へはemployee、従業員から勤怠記録へはattendance_set。行きと帰りで名前が違うことを覚えておくと、関連データをたどりやすくなります。

よくあるエラーと対処法

DoesNotExist

get()を使ったものの、条件に合うデータが1件もなかった。このときに発生するのがDoesNotExistです。データが見つからなかったことを知らせています。見つからない場合もあるなら、tryとexceptで受け止めます。

try:
attendance = Attendance.objects.get(id=999)
except Attendance.DoesNotExist:
attendance = None

Web画面でデータが見つからなかったときに404を返すなら、Djangoのget_object_or_404()も使えます。

MultipleObjectsReturned

今度は反対に、get()の条件へ2件以上が一致したときのエラーです。get()は1件だけを取るメソッドなので、複数見つかるとMultipleObjectsReturnedが発生します。主キーなど、結果が1件に決まる条件を使ってください。複数になるかもしれない検索にはfilter()を使います。

# 同じ名前の従業員が複数いる可能性がある
employees = Employee.objects.filter(name='佐藤')

マイグレーションが衝突した

複数人で開発していると、同じアプリに別々のマイグレーションが作られることがあります。どちらも同じファイルの続きとして作られると、履歴が2つに分かれて衝突します。まずはエラー文と適用状態を確認します。チームの変更を取り込んだら、マージ用のファイルを作ります。

$ python manage.py showmigrations main
$ python manage.py makemigrations --merge
$ python manage.py migrate

マージ用のマイグレーションができても、すぐに適用せず内容と依存関係を確認してください。共有済みのファイルや、すでに適用したファイルを安易に削除すると、メンバーごとに状態がずれてしまいます。うまく解消できないときはデータベースをバックアップし、どこまで適用したのかをチームで確認します。

まとめ

モデルを設計図として書き、マイグレーションでテーブルを作りました。そのテーブルへデータを作成し、取得、更新、削除するところまで動かせています。QuerySetがSQLを実行するタイミングと、ForeignKeyで従業員と勤怠記録をつなぐ方法も見てきました。

流れはモデルを定義する → マイグレーションを作成・適用する → ORMでCRUDを行う → モデル同士を関連づけるです。エラーが起きたときは、この流れのどこまで終わっているかを確かめると原因を探しやすくなります。