9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

KenmaroAdvent Calendar 2022

Day 5

今更ながらFastAPI + SQLAlchemy でテーブルリレーションの実装まとめ(多対多)

Last updated at Posted at 2022-12-04

概要

前回の記事で、FastAPIとSqlalchemy を用いたバックエンドAPIの構築の際、

  • 1対1
  • 1対多

の構成についてまとめました。
次は、多対多の構成についてまとめてみたいと思います。

また、今回は、

ここの記事を大いに参考にして自分が実装を行ったので、その時の備忘録となります。

前回の記事にも書きましたが、FastAPI とSqlalchemy を用いたバックエンドサービス構成の実装自体は

この記事が非常にわかりやすく、多大に参考にさせていただきました。
ただ、Async Session でリレーションが引っ張れない箇所があり、自分は従来のSessionを用いて構築しているところだけ少し異なっています。

多対多

ここで、多対多とは、
Model1 が複数のModel2と紐付き、さらにModel2が複数のModel1とも結びついている時に実装する構成です。
多対多についての実装方法を調べるといろいろな記事が出てくると思いますが、いずれにしても中間テーブルを構成する必要があります。

中間テーブルとは、Model1と、Model2の関係性を保持するテーブルのことで、
例えば、Model1Model2 のような名前のテーブルを追加で作成し、関係性をそこに追加することになります。

この記事を参考に私は実装を行いましたが、

  1. 中間テーブルがリレーションだけを持つ場合
  2. 中間テーブルがリレーション以外の情報も持つ場合

1に関しては、上のリンクの Many-to-many without extra dataに相当し、
2に関しては、上のリンクの Many-to-many with extra data に相当します。
私は、2についての実装を行いたかったのですが、その際に、まずは1を実装し、多対多の構成がうまく実装できていることを確かめた後、実装を変更し2の構成にできることを確かめるステップで実装を行いました。

それに従って、1、2と順を追ってまとめたいと思います。

中間テーブルがリレーションだけを持つ場合

私の実装では、User テーブルと、Project テーブルを多対多でリレーションするようにし、
中間テーブルは、UserProject という名前でテーブル定義しています。
実装は以下のようになります。

api/models/user.py
class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(length=30))
    password = Column(String(length=30))

    # relation
    projects = relationship("Project", secondary="user_project", back_populates="users")
    
api/models/project.py
class Project(Base):
   __tablename__ = "project"
   id = Column(Integer, primary_key=True, autoincrement=True)
   name = Column(String(length=30))

   # relation
   users = relationship("User", secondary="user_project", back_populates="projects")

api/models/user_project.py
class UserProject(Base):
    __tablename__ = 'user_project'
    # foreign key
    user_id = Column(Integer, ForeignKey("user.id"), primary_key=True)
    # foreign key
    project_id = Column(Integer, ForeignKey("project.id"), primary_key=True)

UserProject のrelation に、secondary="user_project"
として、中間テーブルを指定しているところがポイントです。
紐つけるところは、SQLAlchemy が実行してくれます。

api/schemas

api/schemas については、1対多で書いた時のように、
User に対しては List[ProjectBase] を保持させ、
Project に対しては List[UserBase]を保持させるように、
双方に双方が紐つくように書きます。

api/schemas/user.py

class UserBase(BaseModel):
    id: int = Field(alias="user_id")
    class Config:
        orm_mode = True

class User(UserBase):
    projects: Optional[List[ProjectBase]]

api/schemas/project.py

class ProjectBase(BaseModel):
    id: int
    class Config:
        orm_mode = True

class Project(ProjectBase):
    users: List[UserBase]

api/cruds

api/cruds/user.py
async def get_users(db: Session) -> List[user_model.User]:

    users = db.query(user_model.User).options(joinedload(user_model.User.projects))

    return users.all()

例えば、上のようにUserを取得する時(つまりCRUDのR)は、
1対多の時と同じように、
.options(joinedload(user_model.User.projects))
を用いて、Userに紐ついたProject も複数引っ張ってきます。
これは、Projectを取得する時のロジックとしても同じロジックで実装します。

api/cruds/project.py

async def create_project(
    db: Session, project_create: project_schema.ProjectCreateRequest
) -> project_model.Project:
    user = await user_cruds.get_user(db, project_create.user_id)
    project = project_model.Project(
        name=project_create.name
    )
    project.users.append(user)
    db.add(project)
    db.commit()

    return project

ProjectUser に紐つけて追加する時は、
紐つける先のUserを取得し、
project.users.append(user) もしくは
user.projects.append(project) によってデータを紐つけた後にコミットすることで、
中間テーブルに値をセットすることができます。

以上の実装で、多対多のテーブル関係を構築でき、多対多のテーブルに対して付加情報を挿入しなくて良いのであれば、ここで実装完了です。お疲れ様でした。

中間テーブルがリレーション以外の情報も持つ場合

この条件がどういうことかというのは、例を出すとわかりやすくなります。
私の実装の例だと、Userは複数のProjectを持っている可能性があり、Projectには複数のUserが所属しているため、UserProjectは多対多の関係にあり、その関係はUserProjectという中間テーブルの実装を行うことで構成できる、ということは前節でまとめました。

今回行いたいのは、各Userが、Projectに応じて役割(role)という付加情報を持つ時の実装です。

例えば、user1 はproject1 に所属しており、ownerという役割を持っている。
しかし、user1 はproject2 にも所属しており、そこではdeveloperという役割を持っているとします。

そのとき、これらの役割は、UserProject中間テーブルに付加情報として保存されるべきです。
つまり、中間テーブルは、UserProjectテーブルの関係をidで保持するだけでなく、roleというカラムを保持します。
そのような構成が必要な時は、先ほどの多対多の実装を少し変更する必要があり、多少複雑になります。

api/models

まず、User, Project 両方のモデルにおいて、
relationship を双方からUserProjectの中間テーブルへと変更します。

api/models/user.py
class User(Base):
    __tablename__ = 'user'
    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(length=30))
    password = Column(String(length=30))

    # relation
    #projects = relationship("Project", secondary="user_project", back_populates="users")
    projects = relationship("UserProject", back_populates="user")
api/models/project.py
class Project(Base):
   __tablename__ = "project"
   id = Column(Integer, primary_key=True, autoincrement=True)
   name = Column(String(length=30))

   # relation
   #users = relationship("User", secondary="user_project", back_populates="projects")
   users = relationship("UserProject", back_populates="project")

中間テーブルには、roleというカラムを追加し、
relationship を追加します。
これは、User, ProjectUserProjectにリレーションを持つように変更されたので、その影響です。

まさに1対N対1をUserUserProjectProjectで実装しているような格好になります。

api/models/user_project.py
from sqlalchemy.ext.associationproxy import association_proxy

class UserProject(Base):
    __tablename__ = 'user_project'
    # foreign key
    user_id = Column(Integer, ForeignKey("user.id"), primary_key=True)

    # foreign key
    project_id = Column(Integer, ForeignKey("project.id"), primary_key=True)

    role = Column(String(30), nullable=False)

    user = relationship("User", back_populates="projects")
    project = relationship("Project", back_populates="users")

    # proxies
    user_name = association_proxy(target_collection="user", attr="name")
    project_name = association_proxy(target_collection="project", attr="name")

最後の association_proxyについてですが、
これは、Userからみた時のProject.name は、

api/schemas/user.py
class User(UserBase):
    projects: Optional[List[ProjectBase]]

    class Config:
        orm_mode = True

からschemaに定義されているように、user.project.name で取得できるはずですが、
今回は UserUserProjectとリレーションを持っているため、
user.project.name ではアクセスできなくなっています。
それを解決するために、user.projects.project_name
でアクセスできるようにしているというコードです。

api/schemas

先述の変更に合わせて、schema も少しだけ変更する必要があります。

UserBase, ProjectBase
Field(alias)が追加されています。
また、 allow_population_by_field_name = True
も追加されています。

これらは、User に紐ついているProjectのidは、
pydantic schema のUserからすると、project.id で取得できるはずですが、
実際は project.project_nameで取得されます。理由はUserが参照しているのは、ProjectではなくUserProjectだからですね。
したがって、そのことをpydantic が認識する必要があり、そのためにalias を用いる必要があります。

project_idとしてUserProjectから参照されている(user_id, user_name, project_nameも同じ)
ため、それらもalias で書き換えます。

api/schemas/user.py
class UserBase(BaseModel):
    id: int = Field(alias="user_id")
    name: str = Field(alias="user_name")
    role: Optional[str]
    class Config:
        orm_mode = True
        allow_population_by_field_name = True
api/schemas/project.py
class ProjectBase(BaseModel):
    id: int = Field(alias="project_id")
    name: str = Field(alias="project_name")
    role: Optional[str]
    class Config:
        orm_mode = True
        allow_population_by_field_name = True

以上を実行することで、無事今までと同じようなcruds を用いて、
実際にリレーションを加味してレコードの取得ができるようになりました。

これで問題なく取得できるのですが、出てくるスキーマに無駄なnull が入っていたりするので、そこの解消として、

Looking at this response, two annoying issues stand out:

から始まるセクションの変更を加えると、尚自然にレコードの取得ができるので、そこは元のリンクをご覧ください。

これでついに、多対多+リレーション以外のレコードを含むタイプの中間テーブルの実装ができました。
お疲れ様でした。

まとめ

前回と今回で、FastAPI + SQLAlchemy + RDS での

  • 1対1
  • 1対多
  • 多対多

のそれぞれのリレーションを持つテーブルのバックエンドを構築することができました。

ここまでできれば、おそらくいろんな構成のサービスであってもバックエンドを構築することが可能なはずです。
もちろん、特殊なケースはこの分類に入らないものもあるかと思います。(例えば木構造を持たせたいとか)

しかしながら、一度このようなバックエンドAPI構築を経験しておくことは重要だと思い、今回記事を書いてみました。

FastAPIはSwaggerUIを自動生成し、非常に強力なバックエンドツールとして、
テストなどもしやすく、非常に魅力的だなと感じました。

次回は、ふと気になったので、FastAPI + MongoDB(NoSQLタイプ)の実装というのもやってみたいと思います。

BackendAPIは今まではNestJSを使ったり、Flaskで簡易的に実装したり、などがありましたが、
慣れていればFastAPIいいなあと思いました。

ORMなど追いかけ始めるといろいろあるので経験するのは大変だとは思いますが、
いろいろ試してみて自分の好きなものを見つけるのも楽しいので、興味のある人がいればぜひ自分で構築してみてください。

みなさんもよいFastAPIライフを。

今回はこの辺で。

@kenmaro

9
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?