概要
前回の記事で、FastAPIとSqlalchemy を用いたバックエンドAPIの構築の際、
- 1対1
- 1対多
の構成についてまとめました。
次は、多対多
の構成についてまとめてみたいと思います。
また、今回は、
ここの記事を大いに参考にして自分が実装を行ったので、その時の備忘録となります。
前回の記事にも書きましたが、FastAPI とSqlalchemy を用いたバックエンドサービス構成の実装自体は
この記事が非常にわかりやすく、多大に参考にさせていただきました。
ただ、Async Session でリレーションが引っ張れない箇所があり、自分は従来のSessionを用いて構築しているところだけ少し異なっています。
多対多
ここで、多対多とは、
Model1
が複数のModel2
と紐付き、さらにModel2
が複数のModel1
とも結びついている時に実装する構成です。
多対多についての実装方法を調べるといろいろな記事が出てくると思いますが、いずれにしても中間テーブル
を構成する必要があります。
中間テーブル
とは、Model1
と、Model2
の関係性を保持するテーブルのことで、
例えば、Model1Model2
のような名前のテーブルを追加で作成し、関係性をそこに追加することになります。
この記事を参考に私は実装を行いましたが、
- 中間テーブルがリレーションだけを持つ場合
- 中間テーブルがリレーション以外の情報も持つ場合
1に関しては、上のリンクの Many-to-many without extra data
に相当し、
2に関しては、上のリンクの Many-to-many with extra data
に相当します。
私は、2についての実装を行いたかったのですが、その際に、まずは1を実装し、多対多の構成がうまく実装できていることを確かめた後、実装を変更し2の構成にできることを確かめるステップで実装を行いました。
それに従って、1、2と順を追ってまとめたいと思います。
中間テーブルがリレーションだけを持つ場合
私の実装では、User
テーブルと、Project
テーブルを多対多でリレーションするようにし、
中間テーブルは、UserProject
という名前でテーブル定義しています。
実装は以下のようになります。
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")
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")
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)
User
とProject
のrelation に、secondary="user_project"
として、中間テーブルを指定しているところがポイントです。
紐つけるところは、SQLAlchemy が実行してくれます。
api/schemas
api/schemas については、1対多で書いた時のように、
User
に対しては List[ProjectBase]
を保持させ、
Project
に対しては List[UserBase]
を保持させるように、
双方に双方が紐つくように書きます。
class UserBase(BaseModel):
id: int = Field(alias="user_id")
class Config:
orm_mode = True
class User(UserBase):
projects: Optional[List[ProjectBase]]
class ProjectBase(BaseModel):
id: int
class Config:
orm_mode = True
class Project(ProjectBase):
users: List[UserBase]
api/cruds
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
を取得する時のロジックとしても同じロジックで実装します。
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
Project
を User
に紐つけて追加する時は、
紐つける先のUser
を取得し、
project.users.append(user)
もしくは
user.projects.append(project)
によってデータを紐つけた後にコミットすることで、
中間テーブルに値をセットすることができます。
以上の実装で、多対多のテーブル関係を構築でき、多対多のテーブルに対して付加情報を挿入しなくて良いのであれば、ここで実装完了です。お疲れ様でした。
中間テーブルがリレーション以外の情報も持つ場合
この条件がどういうことかというのは、例を出すとわかりやすくなります。
私の実装の例だと、User
は複数のProject
を持っている可能性があり、Project
には複数のUser
が所属しているため、User
とProject
は多対多の関係にあり、その関係はUserProject
という中間テーブルの実装を行うことで構成できる、ということは前節でまとめました。
今回行いたいのは、各User
が、Project
に応じて役割(role)という付加情報を持つ時の実装です。
例えば、user1 はproject1 に所属しており、owner
という役割を持っている。
しかし、user1 はproject2 にも所属しており、そこではdeveloper
という役割を持っているとします。
そのとき、これらの役割は、UserProject
中間テーブルに付加情報として保存されるべきです。
つまり、中間テーブルは、User
、Project
テーブルの関係をidで保持するだけでなく、role
というカラムを保持します。
そのような構成が必要な時は、先ほどの多対多の実装を少し変更する必要があり、多少複雑になります。
api/models
まず、User
, Project
両方のモデルにおいて、
relationship を双方からUserProject
の中間テーブルへと変更します。
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")
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
, Project
がUserProject
にリレーションを持つように変更されたので、その影響です。
まさに1対N対1をUser
対UserProject
対Project
で実装しているような格好になります。
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 は、
class User(UserBase):
projects: Optional[List[ProjectBase]]
class Config:
orm_mode = True
からschemaに定義されているように、user.project.name
で取得できるはずですが、
今回は User
はUserProject
とリレーションを持っているため、
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 で書き換えます。
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
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ライフを。
今回はこの辺で。