🐻

SQLAlchemyで型チェックをがんばる

2022/07/24に公開約6,200字

SQLAlchemyは長らくお世話になってるけど、型チェックに対する理解が微妙だったのでまとめてみました。

よくある誤解

静的型チェックと実行時型チェック

Pythonの型チェックに詳しくない人、特に静的型付け言語から来た人によくある誤解なんですが、SQLAlchemyを含めほとんどのライブラリは静的な型チェックしか提供しません。

例えば以下のようにPEP484で型を指定したコードは、mypyがコードを検査して型エラーを教えてくれます。

@dataclass
class Foo:
    i: int

Foo('10') # <== Argument 1 to "Foo" has incompatible type "str"; expected "int"

しかし、型宣言が間違っていたり、以下のように実行時に間違った型が来たとしてもmypyとpythonインタプリタは何も教えてくれません。

Foo(json.loads('"10"')) # mypyのエラーは出ない

静的型チェックと実行時型チェックが欲しい場合は現状そういうライブラリ(e.g. pyserde, beartype, pydantic)を選定するしかありません。

例えば、pyserdeではJSONからでデシリアライズする際に実行時型チェックが可能です。

@serde
@dataclass
class Foo
    i: int

# serde.compat.SerdeError: Foo.i is not instance of int
from_json(Foo, '{"i": "10"}', type_check=Strict)

本記事ではSQLALchemyで静的型チェックをがんばる方法を中心に解説し、最後に実行時型チェックを行う方法も提案してみます。

セットアップ

インストール

  • SQLAlchemy 1.4

sqlalchemyインストール時にmypyextrasを指定しましょう。すると、sqlalchemy2-stubsというパッケージが一緒にインストールされます。sqlalchemy2-stubsはSQLAlchemyのコードの型情報を提供するパッケージです。

pip install sqlalchemy[mypy]
  • SQLAlchemy 2.X
pip install git+https://github.com/sqlalchemy/sqlalchemy.git

SQLAlchemy 2系ではPython 3.6以上をターゲットにしているので、sqlalchemy2-stubsは要らなくなるようです。

mypyの設定

mypyのSQLALchemyプラグインを有効にします。なぜプラグインが必要かというと、mypyって型チェック時に実際にpythonコードを実行しているわけではないので、SQLAlchemyのように実行時に型をゴニョゴニョしてるライブラリはうまく型チェックできないんですよね。だからmypyプラグインを提供して、SQLAlchemyのゴニョゴニョしてる部分をちゃんと型チェックしてあげられるようにしているようです。pydanticも同じようにmypy pluginを提供しています。

[mypy]
plugins = sqlalchemy.ext.mypy.plugin

ちなみにSQLAchemy作者のMike Bayer氏によると、mypyプラグインをメンテするのはめっちゃ大変だそうです。

Mypy plugins are extremely difficult to develop, maintain and test, as a Mypy plugin must be deeply integrated with Mypy’s internal datastructures and processes, which itself are not stable within the Mypy project itself. The SQLAlchemy Mypy plugin has lots of limitations when used with code that deviates from very basic patterns which are reported regularly.

https://docs.sqlalchemy.org/en/14/orm/extensions/mypy.html

モデルにちゃんと型を指定する

全てのカラムはデフォルトでOptionalになる

以下のモデルで、NOT NULLのnamedetailやPrimaryKeyなidは直感的にはNon Optionalになってほしいところですが、SQLAlchemyではデフォルトでは全てOptionalになります

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    detail = Column(JSON, nullable=False)
    money = Column(Numeric, nullable=True)
    updated_at = Column(DateTime, nullable=False)

user = User(id=None)   # <= mypyではエラー検知できない

この仕様は微妙だなとは思いますが、autoincrementの場合はPrimaryKeyでもOptionalになってほしかったりもするのでしょうがないのかもしれません。

SQLAlchemyの型には幅がある

上の例だとmoneyNumericですが、mypyが認識する型はOptional[Union[float, Decimal]]になるようです。なぜfloatDecimalの二つ候補があるかというとフィールド定義時に以下のようにasdecimalがTrue/FalseでSQLAlchemyが返す型が変わるからです。

    # Decimalになる
    money = Column(Numeric, nullable=True)
    # floatになる
    money = Column(Numeric(asdecimal=False), nullable=True)

クラス定義に期待する型アノテーションを付ける

全てのフィールドがOptionalUnionだと何ともプログラミングしづらいですよね。 SQLAlchemyはモデルクラス定義時にPEP484スタイルで型アノテーションを追加できるので、自分が期待する型を指定しましょう。

class User(Base):
    __tablename__ = "users"

    id: int = Column(Integer, primary_key=True)
    name: str = Column(String, nullable=False)
    detail: dict[str, Any] = Column(JSON, nullable=False)
    money: Optional[Decimal] = Column(Numeric, nullable=True)
    updated_at: datetime = Column(DateTime, nullable=False)

こうすれば以下のコードははmypyで型エラーを検知できるようになります。

user = User(id=None)   # <= Argument "id" to "User" has incompatible type "None"; expected "int"

クエリでちゃんと型を指定する

1.4.39時点ではQueryのAPIで上手く型を認識してくれなさそうなので、自分で指定するしかなさそうです

u: User = s.query(User).one()

戻り値で返す以外の場合は以下のように指定できます。

u: User
for u in s.query(User):
    ...

SQLAlchemyのコードで型を明示してくれれば楽なんですけどね。。SQLAlchemy v2ではこの辺がだいぶ向上されそうなので期待。

やっぱり実行時型チェックもほしい

やはり実行時型チェックもあると便利なので、最も良さそうなやり方を紹介します。

SQLAlchemyのモデルはdataclassでも定義できます。この方法を使う利点としてはdataclassの機能を使えることと、dataclassに対応した様々なライブラリを使えることにあります。

@mapper_registry.mapped
@beartype
@dataclass
class User:
    __table__ = Table(
        "users",
        mapper_registry.metadata,
        Column("id", Integer, primary_key=True),
        Column("name", String, nullable=False),
        Column("detail", JSON, nullable=False),
        Column("money", Numeric, nullable=True),
        Column("updated_at", DateTime, nullable=False)
    )
    id: Optional[int]
    name: str
    detail: dict[str, Any]
    money: Optional[Decimal]
    updated_at: datetime

実行時型チェックを提供するbeartypeというライブラリを使ってみます。

beartypeはdataclassに対応しており、@beartypeデコレータを追加するだけで実行時型チェックしてくれるようになります。

以下のコードを実行するとmypyによる静的型チェックだけでなく、実行時にもエラーにすることができるようになりました!

u = User(id=None, name="foo", detail={}, money=1000, updated_at=datetime.now())
  • mypyエラー
error: Argument "money" to "User" has incompatible type "int"; expected "Optional[Decimal]"
  • 実行時エラー
beartype.roar.BeartypeCallHintParamViolation: @beartyped __create_fn__.__init__() parameter money=1000 violates type hint typing.Optional[decimal.Decimal], as 1000 not <protocol "builtins.NoneType"> or <protocol "decimal.Decimal">.

SQLAlchemy 2.0に期待

SQLAlchemy 2.0では型周りがだいぶ強化されてかなり期待できそうです。2.0がリリースされた頃に色々試してみたいと思います。

Discussion

ログインするとコメントできます