SQLAlchemy 2.0 #3 ORM モデル定義 — DeclarativeBase、Mapped、mapped_column
第 2 回では Table オブジェクトでテーブルを定義しました。ORM は同じ情報を Python のクラスとして宣言します。1 行がオブジェクト 1 つになり、カラムが属性になります。2.0 のモデル宣言は型ヒントが中心なので、1.x とは見た目がかなり違います。今回でその文法をしっかり押さえます。
基本形 — DeclarativeBase と Mapped #
from datetime import datetime
from typing import Optional
from sqlalchemy import String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "user_account"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(30))
email: Mapped[str] = mapped_column(String(100), unique=True)
nickname: Mapped[Optional[str]] = mapped_column(String(30))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
def __repr__(self) -> str:
return f"User(id={self.id!r}, name={self.name!r})"構造を分解するとこうなります。
Base: プロジェクトの全モデルが継承する共通の親です。DeclarativeBaseを継承したクラスをひとつ作ると、そのサブクラスが登録されるMetaDataが自動で用意されます。第 2 回のmetadata.create_all(engine)は、ORM ではBase.metadata.create_all(engine)になります。Mapped[型]: この属性が DB のカラムにマッピングされるという宣言であり、型ヒントでもあります。IDE と mypy がこの情報をそのまま理解します。mapped_column(): カラムの詳細設定(主キー、長さ、ユニーク、デフォルト値など)を担当します。設定するものがなければ省略できます。name: Mapped[str]だけでも NOT NULL の文字列カラムになります。
役割分担が核心です。型と NULL 許容は Mapped[...] が、それ以外の詳細は mapped_column() が決めます。
型マッピングのルール — Python の型が DB の型になります #
Mapped[...] の中の Python の型は、DB の型に自動変換されます。
| Python の型 | DB の型(一般的なマッピング) | 備考 |
|---|---|---|
int | INTEGER | |
str | VARCHAR | 長さ制限は mapped_column(String(30)) で |
float | FLOAT | |
bool | BOOLEAN | SQLite では INTEGER 0/1 |
datetime | DATETIME / TIMESTAMP | タイムゾーン付きは DateTime(timezone=True) |
Decimal | NUMERIC | 金額カラムの正解 |
bytes | BLOB / BYTEA |
そして最も重要なルールがひとつあります。Optional[str](または str | None)なら NULL 許容、そうでなければ NOT NULL です。NULL 許容かどうかが型ヒントから自動的に決まるので、モデルのコードと実際のスキーマと型チェックが常に一致します。上のモデルで nickname だけが NULL を許す理由です。mapped_column(nullable=...) で明示的に上書きもできますが、型ヒントと食い違う書き方は混乱しか残しません。
よく使うカラム設定 #
from sqlalchemy import Text, text
class Post(Base):
__tablename__ = "post"
id: Mapped[int] = mapped_column(primary_key=True)
# インデックス: 検索条件によく使うカラムに
author_name: Mapped[str] = mapped_column(String(30), index=True)
# Python 側のデフォルト値: オブジェクト生成時に適用
view_count: Mapped[int] = mapped_column(default=0)
# サーバー側のデフォルト値: DDL の DEFAULT 句になる
status: Mapped[str] = mapped_column(String(20), server_default=text("'draft'"))
# 長さ制限のないテキスト
body: Mapped[str] = mapped_column(Text)defaultとserver_default:default=0は Python が INSERT 文に値を埋め込むもので、server_defaultは CREATE TABLE の DEFAULT 句になって DB が埋めます。SQLAlchemy を経由しないアクセス(手動 SQL、別サービス)まで考えるならserver_defaultが安全で、ORM だけが書く値ならdefaultで十分です。created_at系はserver_default=func.now()が定石です。index=True: そのカラムに単一インデックスを作ります。複合インデックスは__table_args__にIndex("ix_post_author_status", "author_name", "status")のように宣言します。インデックスが検索性能になぜ効くのかはデータベースインデックスの仕事で扱いました。unique=True: ユニーク制約を作ります。メールアドレスやユーザー名のように重複がそのままバグになるカラムは、アプリケーション側のチェックに任せず DB の制約をかけます。
命名規約 — 制約名を最初から統一します #
制約(ユニーク、外部キーなど)に名前を付けないと、DB が勝手な名前を付けます。問題は、あとで Alembic で制約を削除・変更するときにその名前が必要になることです。DB ごとに命名ルールが違うので、最初に命名規約を入れておくのが標準的な作法です。
from sqlalchemy import MetaData
convention = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=convention)こうしておくと、user_account.email のユニーク制約はどの DB でも uq_user_account_email という予測可能な名前になります。プロジェクト初日に入れるべき設定です。テーブルができたあとに導入すると、既存の制約名とずれてマイグレーションが面倒になります。
モデルとテーブルは同じものの 2 つの表現です #
宣言したモデルの裏には、第 2 回で見た Table オブジェクトがそのまま入っています。User.__table__ で取り出せますし、Base.metadata にはすべてのモデルのテーブルが登録されています。ORM モデルは結局 Table 定義 + クラスマッピングの束だと覚えておくと、Core と ORM を行き来するコードに違和感がなくなります。
print(User.__table__) # Table('user_account', MetaData(), ...)
Base.metadata.create_all(engine) # 登録された全テーブルを作成まとめ #
- モデルは
DeclarativeBaseを継承したBaseを作り、そのサブクラスとして宣言します。Base.metadataがすべてのテーブル定義を持ちます。 Mapped[型]がカラムマッピングと型ヒントを兼ね、詳細設定はmapped_column()が受け持ちます。Optionalなら NULL 許容、そうでなければ NOT NULL です。- デフォルト値は ORM 専用なら
default、DB スキーマレベルならserver_defaultを使います。重複禁止はunique=Trueで DB の制約をかけます。 - 命名規約はプロジェクト初日に設定します。あとでマイグレーションの制約名に苦しまない唯一の方法です。
- 次回はこのモデルで実際に CRUD を行うセッションです。ORM が変更を追跡して SQL を代わりに作る仕組みを扱います。