コンテンツにスキップ

型を活用した Pydantic の共有バリデーションの設計

Python のデータを型や条件で検証するライブラリ Pydantic で、バリデーションを共通化する設計を考える。送信側と受信側で同じ条件を使う場合に、フィールドの制約をどう再利用するかをコード例で示す。

バリデーションを共有するモチベーション

システムの設計上、バリデーションを同じ条件で実行したいことがある。例えば、クライアントがデータを送信する前と、サーバーが受け取ったデータを処理する前に、同じ条件で検証したい場合だ。検証条件を共通化すれば、送信側と受信側で条件が食い違うのを防げる。

バリデーションとフィールドの共有

Pydantic のモデルを定義する際にフィールドに対してバリデーションを追加できる。例えば、次のようにFieldクラスを使用してバリデーションを追加できる。

1
2
3
4
5
from pydantic import BaseModel, Field

class User(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    age: int = Field(..., ge=0, le=150)

バリデーションやフィールドの設定を共通化するには、次のようにUserを継承したクラスを作成できる。

1
2
3
4
5
6
7
from pydantic import BaseModel, Field

class AdminUser(User):
    role: str = Field(..., min_length=1, max_length=100)

class NormalUser(User):
    pass

上記のようにクラス継承すると、フィールドやバリデーションを共通化できる。しかし、クラス継承には次のような問題点がいくつか生じる。

  • 継承元のクラスを小分けにすると、継承先のフィールドを細かく設定できる。一方で、1 行のフィールドだけを記述したクラスが乱立する
  • 深い継承は可読性を損なう
  • mixin で複数のクラスを継承する際に、同じフィールドに対してどのクラスのバリデーションを優先するかが不明瞭

バリデーションの共通化

クラス継承の問題点を解決しつつ、バリデーションを共通化する設計を考えてみる。次のように型アノテーションを使用して、バリデーションを共通化できる。

from typing import Annotated

from pydantic import (
    AfterValidator,
    PlainSerializer,
    TypeAdapter,
    WithJsonSchema,
)


Name = Annotated[
    str,
    AfterValidator(lambda x: x.strip()),
    PlainSerializer(lambda x: x.upper(), return_type=str),
    WithJsonSchema({'type': 'string'}, mode='serialization'),
]

Age = Annotated[
    int,
    AfterValidator(lambda x: max(0, min(x, 150))),
    PlainSerializer(lambda x: f'{x:03}', return_type=str),
    WithJsonSchema({'type': 'number'}, mode='serialization'),
]

class User(BaseModel):
    name: Name
    age: Age

class Person(BaseModel):
    age: Age
    address: str

class Game(BaseModel):
    host_name: Name
    guest_name: Name
    team: str

上記のように型アノテーションを使用してバリデーションを共通化する方法には、2 つの利点がある。第一に、クラス継承の課題を解決しつつ、バリデーションを再利用しやすい。第二に、個別のフィールドに対してバリデーションを共通化するので、データの整合性を保てるうえ、個々のクラスで同じ定義を重複して書く必要もなくなる。

参考リンク