SQLAlchemyで型チェックをがんばる
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インストール時にmypy
extrasを指定しましょう。すると、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.
モデルにちゃんと型を指定する
全てのカラムはデフォルトでOptionalになる
以下のモデルで、NOT NULLのname
とdetail
や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の型には幅がある
上の例だとmoney
はNumeric
ですが、mypyが認識する型はOptional[Union[float, Decimal]]
になるようです。なぜfloat
とDecimal
の二つ候補があるかというとフィールド定義時に以下のようにasdecimal
がTrue/FalseでSQLAlchemyが返す型が変わるからです。
# Decimalになる
money = Column(Numeric, nullable=True)
# floatになる
money = Column(Numeric(asdecimal=False), nullable=True)
クラス定義に期待する型アノテーションを付ける
全てのフィールドがOptional
でUnion
だと何ともプログラミングしづらいですよね。 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