Mypy / Pep-484 Support for ORM Mappings

SQLAlchemy 2.0で導入された mapped_column() 構文ではなく、 Column オブジェクトを直接参照するSQLAlchemy declarative マッピングを使用する場合の PEP 484 型付け注釈とMyPy_type checkingツールのサポート。

Deprecated since version 2.0:

SQLAlchemy Mypyプラグインは非推奨であり、早ければSQLAlchemy 2.1リリースで削除される予定です。ユーザーには、できるだけ早く移行するようお願いします。mypyプラグインもmypyバージョン1. 10.1までしか動作しません。バージョン1. 11.0以降は正しく動作しない可能性があります。

このプラグインは、mypyの継続的に変化するリリースにわたって維持することはできず、今後の安定性は保証できません。

Modern SQLAlchemyがs fully pep-484 compliant mapping syntaxes を提供するようになりました。移行の詳細については、リンクされたセクションを参照してください。

Installation

SQ LC alchemy 2.0のみ : スタブはインストールしないでください。SQLAlchemy-stubs_やsqlalchemy2-stubs_のようなパッケージは完全にアンインストールしてください。

Mypy_package自体は依存関係です。

Mypyはpipを使って”mypy”extrasフックを使ってインストールできます。:

pip install sqlalchemy[mypy]

プラグイン自体は Configuring mypy to use Plugins で説明されているように設定されています。モジュール名は sqlalchemy.ext.mypy.plugin で、例えば setup.cfg の中にあります:

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

What the Plugin Does

Mypyプラグインの主な目的は、SQLAlchemy declarative mappings の静的な定義をインターセプトして変更し、 Mapper オブジェクトによって instrumented された後の構造に一致させることです。これにより、クラス構造自体と、クラスを使用するコードの両方がMypyツールに対して意味を持つようになります。これは、宣言型マッピングが現在どのように機能しているかに基づいて、そうでなければそうではありません。このプラグインは、実行時にクラスを動的に変更する dataclasses のようなライブラリに必要な同様のプラグインとは異なりません。

これが起こる主な領域をカバーするために、 User クラスを使用した以下のORMマッピングを考えてみましょう。:

from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import declarative_base

# "Base" is a class that is created dynamically from the
# declarative_base() function
Base = declarative_base()

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)

# "some_user" is an instance of the User class, which
# accepts "id" and "name" kwargs based on the mapping
some_user = User(id=5, name="user")

# it has an attribute called .name that's a string
print(f"Username: {some_user.name}")

# a select() construct makes use of SQL expressions derived from the
# User class itself
select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))

上記では、Mypyエクステンションが実行できるステップは次のとおりです。:

  • 宣言的な インライン スタイルで定義されたORMマップ属性の型推論。上の例では、 User クラスの id 属性と name 属性です。これには、 User のインスタンスが idint を、 namestr を使用することも含まれます。また、 User.idUser.name のクラスレベル属性がアクセスされた場合、これらは前述の select() 文のように、 InstrumentedAttribute 属性記述子クラスから派生したSQL式の動作と互換性があります。

Mypyプラグインが上記のファイルを処理すると、Mypyツールに渡された静的クラス定義とPythonコードは次のようになります。:

from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import Mapped
from sqlalchemy.orm.decl_api import DeclarativeMeta

class Base(metaclass=DeclarativeMeta):
    __abstract__ = True

class User(Base):
    __tablename__ = "user"

    id: Mapped[Optional[int]] = Mapped._special_method(
        Column(Integer, primary_key=True)
    )
    name: Mapped[Optional[str]] = Mapped._special_method(Column(String))

    def __init__(self, id: Optional[int] = ..., name: Optional[str] = ...) -> None: ...

some_user = User(id=5, name="user")

print(f"Username: {some_user.name}")

select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))

上記で実行された主な手順は次のとおりです。:

  • Base クラスは、動的なクラスではなく、明示的に DeclarativeMeta クラスで定義されるようになりました。

  • id 属性と name 属性は、 Mapped クラスで定義されます。これは、クラスレベルとインスタンスレベルで異なる動作を示すPython記述子を表します。 Mapped クラスは、すべてのORMマップ属性に使用される InstrumentedAttribute クラスの基本クラスになりました。

Mapped は、任意のPython型に対するジェネリッククラスとして定義されています。つまり、 Mapped の特定の出現は、上記の Mapped[Optional[int]]Mapped[Optional[str]] のように、特定のPython型に関連付けられます。

  • 宣言的にマップされた属性の割り当ての右側は 削除 されます。これは、 Mapper クラスが通常行う操作に似ています。つまり、これらの属性を InstrumentedAttribute の特定のインスタンスに置き換えます。元の式は関数呼び出しに移動され、式の左側と競合することなく型チェックを行うことができます。Mypyの目的では、属性の動作を理解するためには、左側の型指定アノテーションで十分です。

  • 正しいキーワードとデータ型を含む User.__init__() メソッドの型スタブが追加されました。

Usage

次のサブセクションでは、これまでpep-484への準拠が検討されてきた個々のユースケースについて説明します。

Introspection of Columns based on TypeEngine

明示的なデータ型を含むマップされた列がインライン属性としてマップされると、マップされた型が自動的に調べられます。:

class MyClass(Base):
    # ...

    id = Column(Integer, primary_key=True)
    name = Column("employee_name", String(50), nullable=False)
    other_name = Column(String(50))

上記では、究極のクラスレベルのデータ型である idnameother_name は、それぞれ Mapped[Optional[int]]Mapped[Optional[str]]Mapped[Optional[str]] として紹介されています。これらの型はデフォルトでは 常に Optional とみなされます。これは、プライマリキーやNULLを許容しない列であっても同様です。その理由は、データベースの列 idname はNULLにできませんが、Pythonの属性 idname は明示的なコンストラクタがなければ None にできるからです。`

>>> m1 = MyClass()
>>> m1.id
None

上記の列の型は 明示的に 記述することができ、より明確な自己文書化と、どの型がオプションであるかを制御できるという2つの利点を提供します:

class MyClass(Base):
    # ...

    id: int = Column(Integer, primary_key=True)
    name: str = Column("employee_name", String(50), nullable=False)
    other_name: Optional[str] = Column(String(50))

Mypyプラグインは、上記の intstrOptional[str] を受け入れ、それらを囲む Mapped[] 型を含むように変換します。 Mapped[] 構文も明示的に使用できます。:

from sqlalchemy.orm import Mapped

class MyClass(Base):
    # ...

    id: Mapped[int] = Column(Integer, primary_key=True)
    name: Mapped[str] = Column("employee_name", String(50), nullable=False)
    other_name: Mapped[Optional[str]] = Column(String(50))

型が非オプションの場合、それは単に MyClass のインスタンスからアクセスされる属性が非Noneであるとみなされることを意味します:

mc = MyClass(...)

# will pass mypy --strict
name: str = mc.name

オプション属性の場合、Mypyは型にNoneが含まれているか、そうでなければ Optional でなければならないと考えています。:

mc = MyClass(...)

# will pass mypy --strict
other_name: Optional[str] = mc.name

マップされた属性が Optional としてタイプされているかどうかに関わらず、 __init__() メソッドの生成では、 すべてのキーワードがオプションであるとみなされます 。これもまた、SQLAlchemy ORMがコンストラクタを作成するときに実際に行うことと一致します。Pythonの dataclasses のような検証システムの動作と混同しないでください。このシステムは、オプション属性と必須属性の観点からアノテーションと一致するコンストラクタを生成します。

Columns that Don’t have an Explicit Type

ForeignKey 修飾子を含む列は、SQLAlchemy宣言マッピングでデータ型を指定する必要はありません。このタイプの属性の場合、Mypyプラグインは明示的なタイプを送信する必要があることをユーザに通知します:

# .. other imports
from sqlalchemy.sql.schema import ForeignKey

Base = declarative_base()

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id = Column(ForeignKey("user.id"))

プラグインは次のようにメッセージを配信します。:

$ mypy test3.py --strict
test3.py:20: error: [SQLAlchemy Mypy plugin] Can't infer type from
ORM mapped expression assigned to attribute 'user_id'; please specify a
Python type or Mapped[<python type>] on the left hand side.
Found 1 error in 1 file (checked 1 source file)

解決するには、明示的な型アノテーションを``Address.user_id`` 列に適用します。:

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

Mapping Columns with Imperative Table

imperative table style では、 Column の定義は、マップされた属性自体とは別の Table 構成体の内部で与えられます。Mypyプラグインはこの Table を考慮しませんが、代わりに、属性をマップされた属性として識別するために Mapped クラスを**使用しなければならない**という完全な注釈で、属性を明示的に記述できることをサポートしています。:

class MyClass(Base):
    __table__ = Table(
        "mytable",
        Base.metadata,
        Column(Integer, primary_key=True),
        Column("employee_name", String(50), nullable=False),
        Column(String(50)),
    )

    id: Mapped[int]
    name: Mapped[str]
    other_name: Mapped[Optional[str]]

上記の Mapped アノテーションはマップされた列とみなされ、デフォルトのコンストラクタに含まれます。また、クラスレベルとインスタンスレベルの両方で、 MyClass の正しい型定義を提供します。

Mapping Relationships

関係のマッピング

このプラグインでは、型推論を使用して関係の型を検出するサポートに制限があります。型を検出できない場合は、有益なエラーメッセージが表示されます。また、すべての場合において、適切な型が明示的に提供されます。 Mapped クラスを使用するか、インライン宣言で省略することもできます。また、プラグインは、関係がコレクションまたはスカラーを参照しているかどうかを判断する必要があります。そのためには、 relationship.uselist および/または relationship.collection_class パラメータの明示的な値に依存します。これらのパラメータが存在しない場合、および relationship() のターゲット型がクラスではなく文字列または呼び出し可能な場合は、明示的な型が必要です:

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user = relationship(User)

上記のマッピングでは、次のエラーが発生します。:

test3.py:22: error: [SQLAlchemy Mypy plugin] Can't infer scalar or
collection for ORM mapped expression assigned to attribute 'user'
if both 'uselist' and 'collection_class' arguments are absent from the
relationship(); please specify a type annotation on the left hand side.
Found 1 error in 1 file (checked 1 source file)

このエラーは、 relationship(User, uselist=False) を使用するか、型(この場合はスカラーの User オブジェクト)を指定することで解決できます。:

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user: User = relationship(User)

コレクションに対しても同様のパターンが適用されます。ここで、 uselist=True または relationship.collection_class がない場合、 List のようなコレクションアノテーションを使用することができます。また、pep-484でサポートされているように、アノテーションでクラスの文字列名を使用することも十分に適切です。クラスは、必要に応じて TYPE_CHECKING block にインポートされます。:

from typing import TYPE_CHECKING, List

from .mymodel import Base

if TYPE_CHECKING:
    # if the target of the relationship is in another module
    # that cannot normally be imported at runtime
    from .myaddressmodel import Address

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)
    addresses: List["Address"] = relationship("Address")

列の場合と同じく、 Mapped クラスを明示的に適用することもできます:

class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String)

    addresses: Mapped[List["Address"]] = relationship("Address", back_populates="user")

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user: Mapped[User] = relationship(User, back_populates="addresses")

Using @declared_attr and Declarative Mixins

declared_attr クラスを使用すると、宣言的にマップされた属性をクラスレベルの関数で宣言することができ、特に declarative mixins を使用する場合に便利です。これらの関数では、関数の戻り値の型は、Mapped[] 構文を使用するか、関数によって返されるオブジェクトの正確な種類を示すことによって注釈を付ける必要があります。さらに、他の方法でマップされていない(つまり、 declarative_base() クラスから拡張されていない、または registry.mapped() などのメソッドでマップされていない)”ミックスイン”クラスは、 declarative_mixin() デコレータで装飾する必要があります。デコレータは、特定のクラスが宣言的ミックスインとして機能しようとしていることをMypyプラグインにヒントを与えます:

from sqlalchemy.orm import declarative_mixin, declared_attr

@declarative_mixin
class HasUpdatedAt:
    @declared_attr
    def updated_at(cls) -> Column[DateTime]:  # uses Column
        return Column(DateTime)

@declarative_mixin
class HasCompany:
    @declared_attr
    def company_id(cls) -> Mapped[int]:  # uses Mapped
        return mapped_column(ForeignKey("company.id"))

    @declared_attr
    def company(cls) -> Mapped["Company"]:
        return relationship("Company")

class Employee(HasUpdatedAt, HasCompany, Base):
    __tablename__ = "employee"

    id = Column(Integer, primary_key=True)
    name = Column(String)

HasCompany.company のようなメソッドの実際の戻り値の型と、アノテーションの内容が一致していないことに注意してください。Mypyプラグインは、この複雑さを避けるために、すべての @declared_attr 関数を単純なアノテーション付き属性に変換します。:

# what Mypy sees
class HasCompany:
    company_id: Mapped[int]
    company: Mapped["Company"]

Combining with Dataclasses or Other Type-Sensitive Attribute Systems

Applying ORM Mappings to an existing dataclass (legacy dataclass use) のPythonデータクラス統合の例には問題があります。Pythonデータクラスは、クラスを構築するために使用する明示的な型を必要とし、各代入文で与えられる値は重要です。つまり、次のようなクラスは、データクラスに受け入れられるためには、そのままの状態で記述する必要があります:

mapper_registry: registry = registry()

@mapper_registry.mapped
@dataclass
class User:
    __table__ = Table(
        "user",
        mapper_registry.metadata,
        Column("id", Integer, primary_key=True),
        Column("name", String(50)),
        Column("fullname", String(50)),
        Column("nickname", String(12)),
    )
    id: int = field(init=False)
    name: Optional[str] = None
    fullname: Optional[str] = None
    nickname: Optional[str] = None
    addresses: List[Address] = field(default_factory=list)

    __mapper_args__ = {  # type: ignore
        "properties": {"addresses": relationship("Address")}
    }

上記のクラスは、実際にはMypyの型チェックに問題なく合格します。唯一欠けているのは、次のようなSQL式で使用される User の属性の機能です。:

stmt = select(User.name).where(User.id.in_([1, 2, 3]))

これを回避するために、Mypyプラグインには追加の属性 _mypy_mapped_attrs を指定できる機能が追加されています。これは、クラスレベルのオブジェクトまたはその文字列名を囲むリストです。この属性は、変数 TYPE_CHECKING 内で条件付きにすることができます。:

@mapper_registry.mapped
@dataclass
class User:
    __table__ = Table(
        "user",
        mapper_registry.metadata,
        Column("id", Integer, primary_key=True),
        Column("name", String(50)),
        Column("fullname", String(50)),
        Column("nickname", String(12)),
    )
    id: int = field(init=False)
    name: Optional[str] = None
    fullname: Optional[str]
    nickname: Optional[str]
    addresses: List[Address] = field(default_factory=list)

    if TYPE_CHECKING:
        _mypy_mapped_attrs = [id, name, "fullname", "nickname", addresses]

    __mapper_args__ = {  # type: ignore
        "properties": {"addresses": relationship("Address")}
    }

With the above recipe, the attributes listed in _mypy_mapped_attrs will be applied with the Mapped typing information so that the User class will behave as a SQLAlchemy mapped class when used in a class-bound context.

上記のレシピでは、 Mapped 型情報とともに、 _mypy_mapped_attrs にリストされた属性が適用されます。これにより、クラスにバインドされたコンテキストで使用された場合、 User クラスはSQLAlchemyマップクラスとして動作します。