SQLAlchemy 2.0 #3 ORM モデル定義 — DeclarativeBase、Mapped、mapped_column

第 2 回では Table オブジェクトでテーブルを定義しました。ORM は同じ情報を Python のクラスとして宣言します。1 行がオブジェクト 1 つになり、カラムが属性になります。2.0 のモデル宣言は型ヒントが中心なので、1.x とは見た目がかなり違います。今回でその文法をしっかり押さえます。

基本形 — DeclarativeBase と Mapped #

models.py
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 の型(一般的なマッピング)備考
intINTEGER
strVARCHAR長さ制限は mapped_column(String(30))
floatFLOAT
boolBOOLEANSQLite では INTEGER 0/1
datetimeDATETIME / TIMESTAMPタイムゾーン付きは DateTime(timezone=True)
DecimalNUMERIC金額カラムの正解
bytesBLOB / BYTEA

そして最も重要なルールがひとつあります。Optional[str](または str | None)なら NULL 許容、そうでなければ NOT NULL です。NULL 許容かどうかが型ヒントから自動的に決まるので、モデルのコードと実際のスキーマと型チェックが常に一致します。上のモデルで nickname だけが NULL を許す理由です。mapped_column(nullable=...) で明示的に上書きもできますが、型ヒントと食い違う書き方は混乱しか残しません。

よく使うカラム設定 #

models.py
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)
  • defaultserver_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 ごとに命名ルールが違うので、最初に命名規約を入れておくのが標準的な作法です。

models.py
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 を行き来するコードに違和感がなくなります。

main.py
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 を代わりに作る仕組みを扱います。
X