SQLAlchemy基本的なRelationshipパターン

 
0
このエントリーをはてなブックマークに追加
Daichi Takayama
Daichi Takayama (高山 大地)

以下の文章はこちらのページを翻訳したものです。

https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html

基本的なリレーションシップパターン

このセクションでは、Mapped アノテーションの使用に基づいて宣言型(Declarative)マッピングを使用し、基本的なリレーションシップパターンを紹介します。

以下のセクションの設定は次の通りです:

from __future__ import annotations
from typing import List

from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

宣言型(Declarative) vs. 命令型(Imperative)

SQLAlchemyが進化するにつれて、さまざまなORMの設定スタイルが出現してきました。このセクションの例や他のアノテーション付き宣言型マッピングでMapped を使う場合、アノテーションがない形式では、relationship()の最初の引数としてクラス名またはその文字列を指定する必要があります。以下の例は、この文書で使用されている形式、すなわち**PEP 484** アノテーションを使用した完全な宣言型の例を示しています。ここでの構造は、アノテーションからターゲットクラスとコレクションタイプを導出しています。これはSQLAlchemyの宣言型マッピングの最新の形式です:

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Child"]] = relationship(back_populates="parent")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
    parent: Mapped["Parent"] = relationship(back_populates="children")

対照的に、アノテーションを使用しない宣言型マッピングは、より「クラシックな」マッピング形式です。この場合、relationship()は、以下の例のように、直接渡されるすべてのパラメータを必要とします。

class Parent(Base):
    __tablename__ = "parent_table"

    id = mapped_column(Integer, primary_key=True)
    children = relationship("Child", back_populates="parent")

class Child(Base):
    __tablename__ = "child_table"

    id = mapped_column(Integer, primary_key=True)
    parent_id = mapped_column(ForeignKey("parent_table.id"))
    parent = relationship("Parent", back_populates="children")

最後に、命令型(Imperative)マッピングについて触れます。これは、宣言型が導入される前のSQLAlchemyのオリジナルのマッピング手法です。一部のユーザーには今でも好まれていますが、上記の設定は以下のように見えます:

registry.map_imperatively(
    Parent,
    parent_table,
    properties={"children": relationship("Child", back_populates="parent")},
)

registry.map_imperatively(
    Child,
    child_table,
    properties={"parent": relationship("Parent", back_populates="children")},
)

さらに、アノテーションを使用しないマッピングのデフォルトのコレクションスタイルはlistです。アノテーションなしでsetや他のコレクションを使用するには、relationship.collection_class パラメーターでそれを指定します。

class Parent(Base):
    __tablename__ = "parent_table"

    id = mapped_column(Integer, primary_key=True)
    children = relationship("Child", collection_class=set, ...)

relationship() のコレクション設定の詳細は、Customizing Collection Accessで確認できます。

アノテーションの有無や、命令型のスタイルとの違いについては、必要に応じて後ほど触れます。


1対多

1対多の関係は、子テーブルに親を参照する外部キーを配置します。その後、親の方でrelationship()が指定されます。これは子が表現するアイテムのコレクションを参照するものとしてです。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Child"]] = relationship()

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))

1対多の関係で双方向リレーションを確立するとき、つまり、逆側となるChildから見たときに多対1であるような関係を作りたいとき、追加のrelationship()を指定し、relationship.back_populates パラメーターを使って二つを繋げます。それぞれの属性名を他方の値として使用します。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Child"]] = relationship(back_populates="parent")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
    parent: Mapped["Parent"] = relationship(back_populates="children")

Childには多対1のセマンティクスを持つparent属性が追加されます。

1対多リレーションにおけるSetやList、その他のコレクションタイプの利用

アノテーション付きの宣言型マッピングでは、relationship()でのコレクションの種類は、Mapped に渡されるタイプから決まります。例えば、前のセクションのParent.childrenlistではなくsetとして扱いたい場合、Mapped[Set["Child"]]のように指定します。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[Set["Child"]] = relationship(back_populates="parent")

アノテーションを使用しない形式や命令型マッピングを使用する場合、コレクションとして使用するPythonクラスは、relationship.collection_class パラメーターを使用して渡すことができます。

関連情報:

Customizing Collection Access - コレクションの設定に関する詳細や、relationship()をdictionaryにマッピングするためのテクニックなどが含まれています。

1対多リレーションでの削除動作の設定

Parentが削除される際に、それに紐づくすべてのChildオブジェクトも削除されるケースはよくあります。この動作を設定するには、delete で説明されているdeleteカスケードオプションを使用します。さらに、オブジェクトがその親から分離されたときに自身を削除するというオプションもあります。この動作については、delete-orphan で解説されています。

関連情報:

delete

Using foreign key ON DELETE cascade with ORM relationships

delete-orphan


多対1

多対1リレーションでは、親テーブルに外部キーが置かれ、子を参照します。relationship()は親上で宣言され、新しいスカラーを保持する属性が作成されます。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    child_id: Mapped[int] = mapped_column(ForeignKey("child_table.id"))
    child: Mapped["Child"] = relationship()

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)

この例では、多対1リレーションが示されており、外部キーがNULLを持たないという前提で記述されています。NULLを許容する多対1リレーションについては、次のセクションで詳しく解説いたします。

双方向の動作を実現する場合は、以下のように2つ目の relationship() を追加し、双方向に relationship.back_populates パラメーターを適用し、お互いの属性名を使用して設定します。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    child_id: Mapped[int] = mapped_column(ForeignKey("child_table.id"))
    child: Mapped["Child"] = relationship(back_populates="parents")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parents: Mapped[List["Parent"]] = relationship(back_populates="child")

NULL許容の多対1

前述の例では、Parent.childの関係はNoneを許容する型として指定されていません。これは、Parent.child_id列自体がnull許容でないためであり、それはMapped[int]として型指定されているからです。もしNull許容の多対1にしたい場合、Parent.child_idParent.childの両方をOptional[]に設定する方法があります。その場合の設定は以下のようになります。

from typing import Optional

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    child_id: Mapped[Optional[int]] = mapped_column(ForeignKey("child_table.id"))
    child: Mapped[Optional["Child"]] = relationship(back_populates="parents")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parents: Mapped[List["Parent"]] = relationship(back_populates="child")

この設定により、Parent.childの列は、データベース上でNULL値を許容するようになります。SQLAlchemyのmapped_column()を使用して明示的なタイピングを行う場合、child_id: Mapped[Optional[int]]という設定は、Column.nullable属性をTrueに設定することと同等です。逆に、child_id: Mapped[int]と設定することは、Falseに設定することと同等です。この動作の詳細については、こちらで確認することができます。

ヒント:

Python 3.10以降を使用している場合、PEP 604 の構文を使用すると、オプションの型を示すのが簡単です。これにより | None を組み合わせ、PEP 563 による遅延アノテーション評価を使用できます。その結果、文字列で型を囲む必要がありません。具体的な例を示します。

from __future__ import annotations

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    child_id: Mapped[int | None] = mapped_column(ForeignKey("child_table.id"))
    child: Mapped[Child | None] = relationship(back_populates="parents")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parents: Mapped[List[Parent]] = relationship(back_populates="child")

1対1

1対1は、外部キーの観点からは基本的に1対多リレーションですが、特定の親行を参照する行が常に1つだけ存在することを示します。

アノテーションを使用してMapped と組み合わせてマッピングを行う場合、この "1対1" の規則は、リレーションの両側にコレクションでない型を適用することで実現されます。これにより、ORMに対してコレクションを使用しないように指示されます。以下の例のようになります。

class Parent(Base):
    __tablename__ = "parent_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    child: Mapped["Child"] = relationship(back_populates="parent")

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
    parent: Mapped["Parent"] = relationship(back_populates="child")

この設定では、Parentオブジェクトを取得するとき、Parent.child属性はコレクションではなく、単一のChildオブジェクトを参照します。新しいChildオブジェクトにその値を置き換えると、以前のChild行は新しい行に置き換えられます。特定のcascadeの動作が設定されていない限り、前のchild.parent_idカラムはデフォルトでNULLになります。

ヒント:

前述の通り、ORMは1対1パターンを慣習として扱い、ParentオブジェクトのParent.child属性をロードする際に、1つの行だけが返されると仮定します。1つ以上の行が返された場合、ORMは警告を発します。

しかし、上記のリレーションのChild.parent側は多対1リレーションとして残ります。それ自体では、1つ以上のChildの割り当てを検出しませんが、relationship.single_parentパラメーターが設定されている場合に便利です。

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
    parent: Mapped["Parent"] = relationship(back_populates="child", single_parent=True)

このパラメーターを設定しない場合、"1対多"側(ここでは慣習的に"1対1")も、1つのParentに複数のChildが関連付けられている場合、特に複数のオブジェクトが保留中でデータベースには永続化されていない場合など、確実には検出しません。

relationship.single_parentを使用するかどうかに関係なく、データベーススキーマにChild.parent_idカラムが一意であることを示すunique constraintを含めることが推奨されます。これにより、データベースレベルで特定のParent行について同時に1つのChild行のみが参照できるようになります(__table_args__タプル構文の背景については、Declarative Table Configurationを参照)。

from sqlalchemy import UniqueConstraint

class Child(Base):
    __tablename__ = "child_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"))
    parent: Mapped["Parent"] = relationship(back_populates="child")

    __table_args__ = (UniqueConstraint("parent_id"),)

バージョン2.0では、relationship() は指定されたMapped アノテーションから relationship.uselist パラメータの実際の値を導出することができます。

アノテーションを使用しないconfigurationでのuselist=Falseの設定

Mapped アノテーションを使用せずにrelationship() を使う場合、通常は「多」側となる部分にrelationship.uselistパラメーターをFalseに設定することで、1対1パターンを有効にできます。以下は、非アノテーションの宣言型(Declarative)設定の例です。

class Parent(Base):
    __tablename__ = "parent_table"

    id = mapped_column(Integer, primary_key=True)
    child = relationship("Child", uselist=False, back_populates="parent")

class Child(Base):
    __tablename__ = "child_table"

    id = mapped_column(Integer, primary_key=True)
    parent_id = mapped_column(ForeignKey("parent_table.id"))
    parent = relationship("Parent", back_populates="child")


多対多

多対多は、2つのクラス間にassociationテーブルを追加します。associationテーブルはほとんどの場合、CoreのTableオブジェクトや他のCoreの選択可能なオブジェクト、例えばJoin オブジェクトとして指定され、relationship()relationship.secondary引数で示されます。通常、これにより、ForeignKey指令がリモートテーブルを検索してリンクできるように、宣言(declarative)ベースクラスに関連付けられたMetaDataオブジェクトが使用されます。

from __future__ import annotations

from sqlalchemy import Column
from sqlalchemy import Table
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

# 注意: Coreテーブルの場合、sqlalchemy.Columnを使用し、
# sqlalchemy.orm.mapped_columnは使用しない。
association_table = Table(
    "association_table",
    Base.metadata,
    Column("left_id", ForeignKey("left_table.id")),
    Column("right_id", ForeignKey("right_table.id")),
)

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List[Child]] = relationship(secondary=association_table)

class Child(Base):
    __tablename__ = "right_table"

    id: Mapped[int] = mapped_column(primary_key=True)

ヒント

上記の「association table」には、リレーションの両側の2つのエンティティテーブルを参照する外部キー制約が確立されています。通常、association.left_idassociation.right_id の各データタイプは、参照されるテーブルのものから推測され、省略することができます。また、SQLAlchemyが要求するわけではありませんが、2つのエンティティテーブルを参照する列は、一意制約 またはより一般的には 主キー制約 のいずれかで確立されることが推奨されています。これにより、アプリケーション側の問題に関係なく、テーブル内に重複した行が永続化されることはありません。

`association_table = Table(
    "association_table",
    Base.metadata,
    Column("left_id", ForeignKey("left_table.id"), primary_key=True),
    Column("right_id", ForeignKey("right_table.id"), primary_key=True),
)`

双方向での多対多の設定

双方向リレーションでは両側にコレクションが含まれます。これを指定するには、relationship.back_populatesを使用し、各relationship()に共通のassociationテーブルを指定します。

from __future__ import annotations

from sqlalchemy import Column
from sqlalchemy import Table
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

association_table = Table(
    "association_table",
    Base.metadata,
    Column("left_id", ForeignKey("left_table.id"), primary_key=True),
    Column("right_id", ForeignKey("right_table.id"), primary_key=True),
)

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List[Child]] = relationship(
        secondary=association_table, back_populates="parents"
    )

class Child(Base):
    __tablename__ = "right_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    parents: Mapped[List[Parent]] = relationship(
        secondary=association_table, back_populates="children"
    )

"secondary"引数の遅延評価形式の利用

relationship()relationship.secondaryパラメーターは、テーブル名の文字列やラムダ呼び出し可能な関数を含む2つの異なる「遅延評価」形式を受け入れます。詳細については後ほど解説いたします。

多対多リレーションにおけるコレクションタイプの選択

多対多リレーションにおけるコレクションの設定は、「1対多」の解説で記載されている方法と同じです。アノテーション付きのマッピングを使用してMappedを行う場合、コレクションの型はジェネリッククラス内で使用されるコレクションの型(例:set)によって指定できます。

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[Set["Child"]] = relationship(secondary=association_table)

アノテーションのない形式、命令型のマッピングを含む場合、1対多の場合のように、コレクションとして使用するPythonクラスは、relationship.collection_class パラメーターを使用して渡すことができます。

参考:

Customizing Collection Access - コレクションの設定に関する詳細情報が含まれており、relationship()をdictionaryにマッピングするためのいくつかのテクニックも説明されています。

多対多テーブルからの行の削除

これは relationship()relationship.secondary引数に固有の動作として、指定された Table は、コレクションからオブジェクトが追加または削除されると、自動的にINSERTおよびDELETEステートメントの対象となります。このテーブルを手動で削除する必要はありません。コレクションからレコードを削除すると、その行がフラッシュ時に削除されます。

# "secondary"テーブルからの行は自動的に削除されます
myparent.children.remove(somechild)

しかし、Session.delete()で子オブジェクトを直接削除した場合、"secondary"テーブルの行の削除動作は以下の通りです:

  1. 逆の関係(例えばChild.parentsなど)が存在しない場合、SQLAlchemyはその子オブジェクトの削除と"secondary"テーブルの行の削除との関連を認識しません。この結果、"secondary"テーブルの削除は発生しません。
  2. 逆の関係が存在する場合(例:Child.parents)、SQLAlchemyは関連する"secondary"テーブルの行を削除します。この動作はリレーションが双方向であるかどうかに依存しません。
  3. 効率的な方法の一つとして、外部キーにON DELETE CASCADE指令を使用することができます。データベースがこの機能をサポートしている場合、子オブジェクトの参照行が削除されると、データベースは関連する"secondary"テーブルの行も自動で削除します。

再度注意してください。これらの動作は、relationship()で使用されるrelationship.secondaryオプションにのみ関連しています。明示的にマップされたassociationテーブルを扱っていて、関連するrelationship()secondaryオプションに存在しない場合は、代わりにカスケードルールを使用して、関連するエンティティが削除された際に自動的にエンティティを削除することができます。この機能についての情報は、Cascadesを参照してください。

関連リンク


Associationオブジェクト

Associationオブジェクトパターンは、多対多の変種です。親と子(または左と右)のテーブルに外部キー以外の追加の列が含まれている場合に使用されます。これらの列は理想として、それぞれのORMマップされたクラスにマップされます。マップされたクラスは、通常は多対多パターンを使用する場合にrelationship.secondaryとして指定されるであろうTableにマップされます。

Associationオブジェクトパターンでは、relationship.secondaryパラメータは使用されず、代わりにクラスが直接Associationテーブルにマップされます。その後、2つの個別のrelationship() 構築物が使用され、まず親側をマップされたAssociationクラスに1対多のリンクで接続し、次にマップされたAssociationクラスを子側の多対1のリンクで接続して、親からAssociation、そして子への単方向のAssociationオブジェクト関係を形成します。双方向リレーションの場合、マップされたAssociationクラスを親と子の両方の方向にリンクするために4つの構築物が使用されます。

以下の例は、新しいAssociationクラスを示しており、これはassociationという名前のTableにマップされています。このテーブルにはextra_dataという追加の列が含まれており、これはParentChild間の各関連付けと一緒に格納される文字列値です。テーブルをexplicitクラスにマップすることにより、親から子への基本的なアクセスが明示的に行われます。

from typing import Optional
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

class Association(Base):
    __tablename__ = "association_table"
    left_id: Mapped[int] = mapped_column(ForeignKey("left_table.id"), primary_key=True)
    right_id: Mapped[int] = mapped_column(
        ForeignKey("right_table.id"), primary_key=True
    )
    extra_data: Mapped[Optional[str]]
    child: Mapped["Child"] = relationship()

class Parent(Base):
    __tablename__ = "left_table"
    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Association"]] = relationship()

class Child(Base):
    __tablename__ = "right_table"
    id: Mapped[int] = mapped_column(primary_key=True)

双方向の例を示すために、既存のリレーションを補完するために2つのrelationship()構築を追加します。これらの新しい関係は、relationship.back_populatesを使用して既存のリレーションと連携しています。

from typing import Optional

from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

class Association(Base):
    __tablename__ = "association_table"
    left_id: Mapped[int] = mapped_column(ForeignKey("left_table.id"), primary_key=True)
    right_id: Mapped[int] = mapped_column(
        ForeignKey("right_table.id"), primary_key=True
    )
    extra_data: Mapped[Optional[str]]
    child: Mapped["Child"] = relationship(back_populates="parents")
    parent: Mapped["Parent"] = relationship(back_populates="children")

class Parent(Base):
    __tablename__ = "left_table"
    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Association"]] = relationship(back_populates="parent")

class Child(Base):
    __tablename__ = "right_table"
    id: Mapped[int] = mapped_column(primary_key=True)
    parents: Mapped[List["Association"]] = relationship(back_populates="child")

直接的にAssociationパターンを使用する際、子オブジェクトは親に追加される前にAssociationインスタンスと関連づけられる必要があります。同様に、親から子へのアクセスはAssociationオブジェクトを通じて行われます:

# 親オブジェクトを作成し、Associationオブジェクトを介して子オブジェクトを追加
p = Parent()
a = Association(extra_data="some data")
a.child = Child()
p.children.append(a)

# Associationオブジェクトを介して子オブジェクトを処理、Associationの属性も含む
for assoc in p.children:
    print(assoc.extra_data)
    print(assoc.child)

Associationオブジェクトへの直接アクセスを必須ではなくするために、SQLAlchemyはAssociation Proxy拡張を提供しています。この拡張により、関連するオブジェクトへの1つの“hop”と、ターゲット属性への2つ目の“hop”を、一回のアクセスで行う属性の設定が可能となります。

参照:

Association Proxy - 3クラスのアソシエーションオブジェクトマッピングにおいて、親と子の間での直接的な多対多スタイルのアクセスを可能にします。

注意:

Associationオブジェクトパターンと多対多パターンを直接混ぜることは避けてください。この組み合わせにより、特別な手順がない限り、データが一貫性のない方法で読み書きされる状況が生じる可能性があります。association proxyは、通常、より簡潔なアクセスを提供するために使用されるものです。この組み合わせによって導入される注意点の詳細については、次のセクションを参照してください。


Associationオブジェクトと多対多アクセスパターンの組み合わせ

前のセクションで触れたように、Associationオブジェクトパターンは、同じテーブル/カラムに対して多対多パターンの使用と自動的に統合されません。これにより、読み取り操作が矛盾したデータを返す可能性があり、書き込み操作も矛盾した変更をフラッシュしようとすることがあるため、整合性エラーや予期しない挿入削除が発生することがあります。

例を挙げると、以下の例ではParentChildの間に双方向の多対多リレーションをParent.childrenChild.parentsを通して設定しています。同時に、Associationオブジェクトのリレーションも設定されており、Parent.child_associations -> Association.childChild.parent_associations -> Association.parent の間です。

from typing import Optional

from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.orm import Mapped
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import relationship

class Base(DeclarativeBase):
    pass

class Association(Base):
    __tablename__ = "association_table"

    left_id: Mapped[int] = mapped_column(ForeignKey("left_table.id"), primary_key=True)
    right_id: Mapped[int] = mapped_column(
        ForeignKey("right_table.id"), primary_key=True
    )
    extra_data: Mapped[Optional[str]]

    # Assocation -> Child の関係
    child: Mapped["Child"] = relationship(back_populates="parent_associations")

    # Assocation -> Parent の関係
    parent: Mapped["Parent"] = relationship(back_populates="child_associations")

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)

    # Childとの多対多の関連付け。Associationクラスを経由しない
    children: Mapped[List["Child"]] = relationship(
        secondary="association_table", back_populates="parents"
    )

    # Parent -> Association -> Childのリレーション
    child_associations: Mapped[List["Association"]] = relationship(
        back_populates="parent"
    )

class Child(Base):
    __tablename__ = "right_table"

    id: Mapped[int] = mapped_column(primary_key=True)

    # ParentとのMany To Manyの関連付け。Associationクラスを経由しない
    parents: Mapped[List["Parent"]] = relationship(
        secondary="association_table", back_populates="children"
    )

    # Child -> Association -> Parentのリレーション
    parent_associations: Mapped[List["Association"]] = relationship(
        back_populates="child"
    )

このORMモデルを使用して変更を加えると、PythonでParent.childrenに加えられた変更はParent.child_associationsChild.parent_associationsの変更と連携しません。これらの関係はそれぞれ正常に機能し続けるものの、一方での変更はもう一方に表示されるまで、セッションが期限切れになるまで反映されません。これは通常、Session.commit()の後に自動的に発生します。

矛盾する変更が行われると、新しいAssociationオブジェクトの追加と同時に、関連するChildParent.childrenに追加するといった場合、ワークフローのフラッシュ処理が進行する際に整合性エラーが発生します。以下の例を参照してください:

p1 = Parent()
c1 = Child()
p1.children.append(c1)

# これは冗長であり、Associationへの重複INSERTを引き起こします
p1.child_associations.append(Association(child=c1))

Parent.childrenに直接Childを追加することは、association.extra_dataカラムの値を示さずにassociationテーブルに行を作成することを意味します。この結果、そのカラムの値としてNULLが設定されます。

上記のようなマッピングを使用することは問題ありませんが、何をしているかを正確に理解している場合に限ります。"Associationオブジェクト"パターンの使用が稀である場合、多対多の関係を使用する理由があるかもしれません。それは、単一の多対多の関係を通じて関係をロードする方が簡単であり、また"secondary"テーブルがSQL文でどのように使用されるかを、明示的なassociationクラスへの2つの別々の関係と比較してわずかに最適化することができるからです。少なくとも、"secondary"関係にrelationship.viewonly パラメータを適用することで、矛盾する変更が生じる問題を避けるとともに、追加のassociationカラムにNULLが書き込まれるのを防ぐことができるのは良いアイディアです。以下のようになります:

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)

    # `Association`クラス経由しない、Childへの多対多リレーション
    children: Mapped[List["Child"]] = relationship(
        secondary="association_table", back_populates="parents", viewonly=True
    )

    # Parent -> Association -> Child の間の関連付け
    child_associations: Mapped[List["Association"]] = relationship(
        back_populates="parent"
    )

class Child(Base):
    __tablename__ = "right_table"

    id: Mapped[int] = mapped_column(primary_key=True)

    # `Association`クラスを経由しない、Parentへの多対多リレーション
    parents: Mapped[List["Parent"]] = relationship(
        secondary="association_table", back_populates="children", viewonly=True
    )

    # Child -> Association -> Parent 間の関連付け
    parent_associations: Mapped[List["Association"]] = relationship(
        back_populates="child"

上記のマッピングでは、Parent.childrenChild.parentsへの変更はデータベースに書き込まれません。これにより、書き込みの競合が防止されます。ただし、Parent.childrenChild.parentsの読み取りは、Parent.child_associationsChild.parent_associationsから読み取られるデータと必ずしも一致しない可能性があります。これは、viewonlyのコレクションが読み取られているのと同じトランザクションやSession の中でこれらのコレクションに変更が加えられている場合に発生します。Associationオブジェクトの関係の使用がまれで、多対多のコレクションへのアクセスとの間で古い読み取りを避けるように注意深く整理されている場合、このパターンは実現可能です。

上記のパターンの代替手段として人気なのは、直接的な多対多のParent.childrenChild.parentsのリレーションを、Associationオブジェクトを透明にプロキシする拡張で置き換えるものです。この拡張は、ORMの観点からすべてを一貫して保持します。Association Proxyとして知られています。

参照

Association Proxy - 三つのクラスのAssociationオブジェクトマッピングのための親と子の間の直接の「多対多」スタイルのアクセスを許可します。


リレーション引数の遅延評価

これまでのセクションの例のほとんどは、マッピングを示しています。このマッピングでは、さまざまなrelationship() 構造は、そのターゲットクラスをクラス自体ではなく文字列名で参照しています。Mappedを使用する場合など、実行時には文字列としてのみ存在する前方参照が生成されます。

class Parent(Base):
    # ...

    children: Mapped[List["Child"]] = relationship(back_populates="parent")

class Child(Base):
    # ...

    parent: Mapped["Parent"] = relationship(back_populates="children")

同様に、アノテーションを使用しない形式、例えばアノテーションなしの宣言型や命令型マッピングを使用する場合、relationship()構造体にも文字列の名前が直接サポートされています。

registry.map_imperatively(
    Parent,
    parent_table,
    properties={"children": relationship("Child", back_populates="parent")},
)

registry.map_imperatively(
    Child,
    child_table,
    properties={"parent": relationship("Parent", back_populates="children")},
)

これらの文字列の名前は、マッパーの解決段階でクラスに解決されます。これは通常、すべてのマッピングが定義された後に発生し、マッピングの最初の使用によってトリガーされる内部プロセスです。registryオブジェクトは、これらの名前が保存され、それらが参照するマップされたクラスに解決されるコンテナです。

relationship()の主要なクラス引数に加えて、まだ未定義のクラスに存在するカラムに依存する他の引数も、Python関数として、また、一般的な文字列として指定することができます。これらの引数のうち、主要な引数を除くほとんどの引数は、完全なSQL式を受け取ることを意図しているため、Pythonの組み込みeval()関数を使用してPython式として評価される文字列入力です。

注意:

eval() 関数は、relationship() マッパー設定に渡される遅延評価の文字列引数を解析する際にPythonで使用されます。しかし、これらの引数は信頼できないユーザー入力を受け取るように使用するべきではありません。なぜなら、信頼できない入力に対して安全ではないからです。

この評価時に利用できる名前空間は、該当の宣言ベースでマッピングされたすべてのクラスや、sqlalchemy パッケージの中身が含まれています。その中には、desc()sqlalchemy.sql.functions.func のような式関数もあります。

class Parent(Base):
    # ...

    children: Mapped[List["Child"]] = relationship(
        order_by="desc(Child.email_address)",
        primaryjoin="Parent.id == Child.parent_id",
    )

複数のモジュールに同じ名前のクラスが存在する場合、文字列のクラス名は、これらの文字列式のいずれにもモジュール修飾パスとして指定することができます。

class Parent(Base):
    # ...

    children: Mapped[List["myapp.mymodel.Child"]] = relationship(
        order_by="desc(myapp.mymodel.Child.email_address)",
        primaryjoin="myapp.mymodel.Parent.id == myapp.mymodel.Child.parent_id",
    )

上記のような例では、Mappedへの文字列は、クラス位置の文字列をrelationship.argument に直接渡すことで、特定のクラス引数から区別できます。

以下は、Childの型のみのインポートと、レジストリ内で正しい名前を検索するランタイムのターゲットクラス指定を組み合わせた例です:

import typing

if typing.TYPE_CHECKING:
    from myapp.mymodel import Child

class Parent(Base):
    # ...

    children: Mapped[List["Child"]] = relationship(
        "myapp.mymodel.Child",
        order_by="desc(myapp.mymodel.Child.email_address)",
        primaryjoin="myapp.mymodel.Parent.id == myapp.mymodel.Child.parent_id",
    )

修飾パスは、名前間のあいまいさを解消するための任意の部分的なパスとなることができます。たとえば、myapp.model1.Childmyapp.model2.Child を区別するために、model1.Child または model2.Child を指定できます:

class Parent(Base):
    # ...

    children: Mapped[List["Child"]] = relationship(
        "model1.Child",
        order_by="desc(mymodel1.Child.email_address)",
        primaryjoin="Parent.id == model1.Child.parent_id",
    )

relationship()構築体は、これらの引数の入力としてPython関数またはラムダも受け入れます。Python関数アプローチは次のようになるでしょう:

import typing

from sqlalchemy import desc

if typing.TYPE_CHECKING:
    from myapplication import Child

def _resolve_child_model():
    from myapplication import Child

    return Child

class Parent(Base):
    # ...

    children: Mapped[List["Child"]] = relationship(
        _resolve_child_model,
        order_by=lambda: desc(_resolve_child_model().email_address),
        primaryjoin=lambda: Parent.id == _resolve_child_model().parent_id,
    )

eval()に渡されるPython関数/ラムダまたは文字列を受け入れるパラメータの完全なリストは次のとおりです:

注意:

前述のように、上記のrelationship()へのパラメータはeval()を使用してPythonコード式として評価されます。これらの引数に信頼できない入力を渡さないでください。


宣言後にマッピングクラスへリレーションを追加

MapperProperty構造はいつでも宣言型ベースのマッピングに追加することが可能です(この場面でのアノテーション形式はサポートされていません)。Addressクラスをすでに定義した後で、このrelationship()を実装したい場合、後から追加することも可能です。

# まず、モジュールAで、Childがまだ作成されていない状態で、
# Childについて何も知らないParentクラスを作成します。

class Parent(Base):
    ...

# その後、モジュールAの後にインポートされるモジュールBで...:

class Child(Base):
    ...

from module_a import Parent

# Parentクラスのクラス変数としてUser.addresses関連を割り当てます。
# Declarative Baseクラスがこれをインターセプトして関連をマップします。
Parent.children = relationship(Child, primaryjoin=Child.parent_id == Parent.id)

ORMマップ列の場合と同じく、[Mapped]アノテーションタイプがこの操作に参加する能力はありません。そのため、関連するクラスは、[relationship()]構築体内で直接指定する必要があります。これは、クラス自体、クラスの文字列名、またはターゲットクラスへの参照を返すcallable関数のいずれかとして行うことができます。

注意

ORMマップされたカラムの場合と同様に、すでにマップされたクラスにマップされたプロパティを割り当てることは、"宣言型ベース"のクラスが使用された場合のみ正しく機能します。これは、DeclarativeBaseのユーザー定義のサブクラスやdeclarative_base()registry.generate_base()によって動的に生成されたクラスを指します。この"base"クラスには、これらの操作をインターセプトする特別な__setattr__()メソッドを実装するPythonのメタクラスが含まれています。

マップされたクラスにクラスマップ属性をランタイムで割り当てることは、クラスがregistry.mapped()のようなデコレーターやregistry.map_imperatively()のような命令型の関数を使用してマップされている場合には動作しません

多対多の“secondary”引数に遅延評価形式を使用

多対多の関係は、通常、マップされていない Table オブジェクトや他のCore selectableオブジェクトを指すrelationship.secondaryパラメータを使用します。lambda callableまたは文字列名を使用した遅延評価がサポートされており、文字列の解決は、現在のregistryによって参照される同じMetaData コレクションに存在する同名のオブジェクトへのリンクを示すPython式の評価によって行われます。

多対多のセクションで示された例で、association_table Tableオブジェクトが、マッピングされたクラス自体よりも後の時点でモジュールに定義されると仮定するならば、ラムダを使用してrelationship()を書くことができます:

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Child"]] = relationship(
        "Child", secondary=lambda: association_table
    )

あるいは、同じTableオブジェクトを名前で探すための例として、Tableの名前が引数として使用されます。Pythonの観点からすると、これは「association_table」という名前の変数として評価されるPython式であり、MetaDataコレクション内のテーブル名と照らし合わせて解決されます。

class Parent(Base):
    __tablename__ = "left_table"

    id: Mapped[int] = mapped_column(primary_key=True)
    children: Mapped[List["Child"]] = relationship(secondary="association_table")

注意:

文字列として渡される場合、relationship.secondary引数は、通常はテーブルの名前であるにもかかわらず、Pythonのeval() 関数を使用して解釈されます。

この文字列に信頼できない入力を渡さないでください。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。