きり丸の技術日記

技術検証したり、資格等をここに残していきます。

SQLModelで親と一緒に子テーブルを削除する(cascade_delete, ondelete)

始めに

外部キー制約があるレコードを削除するとき、参照元テーブルよりも参照先テーブルを先に削除する必要があります。

Railsの場合、次のようにdependentに定義しておくと、Parentテーブルを削除したタイミングでChildテーブルも削除されます。

class Parent < ApplicationRecord
  has_one :child, dependent: :destroy
end

class Child < ApplicationRecord
  belongs_to :parent
end

今回の記事では、PythonのSQLModelを使用したときに、Parentテーブルを削除したタイミングでChildテーブルも削除されるようにします。

環境

  • Python
    • 3.12.4
  • FastAPI
    • 0.112.1
  • SQLModel
    • 0.0.21

実装

Relationshipcascade_delete属性を付与するだけです。また、外部キー制約がないDBを使用していた際に、DBで直接削除された時用にFieldondelete属性を付与しておくとより安全に処理できます。

from sqlmodel import SQLModel, Field, Relationship

class Parent(SQLModel, table=True):
    __tablename__ = "parents"

    id: int = Field(primary_key=True)
    child: "Child" = Relationship(back_populates="parent", cascade_delete=True, sa_relationship_kwargs={"uselist": False})

class Child(SQLModel, table=True):
    __tablename__ = "childs"

    id: int = Field(foreign_key="parents.id", primary_key=True, ondelete="CASCADE")

    parent: "Parent" = Relationship(back_populates="child")

こちらを定義するだけで、Parentのテーブルのモデルを削除したタイミングで一緒に削除してくれます。

async def test_01(self, db: AsyncSession) -> None:
    parent = Parent(id=1)
    db.add(task1)

    child = Child(id=1)
    db.add(done1)
    await db.commit()

    # WHEN
    print("XXXXXX")
    await db.delete(parent)
    await db.commit()
    print("XXXXXX")

    # THEN
    query: select = select(Child)
    actual = (await db.execute(query)).scalars().all()
    assert len(actual) == 0

ログを見ればParentを削除したタイミングでChildもforeign_keyをもとに検索をかけていることがわかり、Child, Parentの順番で削除されていることがわかります。

2024-08-25 21:52:39,968 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2024-08-25 21:52:39,970 INFO sqlalchemy.engine.Engine SELECT parents.id
FROM parents
WHERE parents.id = ?
2024-08-25 21:52:39,970 INFO sqlalchemy.engine.Engine [generated in 0.00021s] (1,)
2024-08-25 21:52:39,971 INFO sqlalchemy.engine.Engine SELECT childs.id FROM childs WHERE childs.id = ?
2024-08-25 21:52:39,971 INFO sqlalchemy.engine.Engine [generated in 0.00016s] (1,)
2024-08-25 21:52:39,973 INFO sqlalchemy.engine.Engine DELETE FROM childs WHERE childs.id = ?
2024-08-25 21:52:39,973 INFO sqlalchemy.engine.Engine [generated in 0.00017s] (1,)
2024-08-25 21:52:39,974 INFO sqlalchemy.engine.Engine DELETE FROM parents WHERE parents.id = ?
2024-08-25 21:52:39,974 INFO sqlalchemy.engine.Engine [generated in 0.00014s] (1,)
2024-08-25 21:52:39,975 INFO sqlalchemy.engine.Engine COMMIT

注意点

あくまでインスタンス経由で削除する場合に効く構文なので、直接Delete文を発行する処理には効きません。素直にデータベースの構文を使いましょう。ondeleteを設定していればできると思ったのですがダメでした。

        query = delete(Task).where(Task.id == 1)
        _ = await db.execute(query)

ソースコード

終わりに

こういう処理ができると直接SQLを発行しないでORMを経由するメリットがありますね。また、SQLAlchemyではできなかったDB側で直接削除された時用のondeleteオプションがあるのも面白いです。

こういう細かい部分を知っていくのは面白いですね。

参考情報

SQLAlchemyではselectinloadを使うのが安定

始めに

自分用メモ。sqlalchemyではeager loadをする際にsubqueryloadselectinload等々さまざまなload方法を指定できます。

しかし、片方はdeprecatedまでは設定されていませんが、非推奨なloading方法なのでそれを忘れないようにするための記事です。

環境

  • Python
    • 3.12.4
  • SQLAlchemy
    • 2.0.32

実装

selectinloadの方が推奨されている。SQLAlchemyがV1のころはsubqueryloadも使用されていたが、V2になってからは非推奨な場面が増えています。

もし、複数キーで突合したい場合はselectinloadを使用する必要がありますが、単一キーで突合したい場合にはselectinloadを使用する必要があります。

詳細は公式ヘルプを参照してください。

ソースコード

なし。

終わりに

実際に本番でSQLAlchemyを運用していました。

あくまで感覚値で申し訳ないのですが、subqueryloadのテストが甘いのかライブラリアップデートのたびに不具合が発生しているように感じます。

少なくとも報告が上がっているうえで踏んだ不具合は次のIssueです。ほかにもsubqueryload絡みで踏んだ不具合はあるのですが、Issueとしては見つかりませんでした。

このブログを見てくださった方が一人でも早くsubqueryloadからselectinloadに切り替えていただければと考えています。

参考情報

Pydanticではミュータブルでもdefault, default_factoryのどっちでもよさそう

結論

pydanticではdefaultdefault_factoryも同等の結果を返却しそう。

始めに

小ネタ記事。Pythonではデフォルト引数にミュータブルな値を指定したうえで、ミュータブルな操作を行うと、同じインスタンスを共有してしまいます。

def default_param(param: str, result:List[str]=[]) -> List[str]:
    result.append(param)
    return result

_ = default_param("1")
result = default_param("2")

print(result)
# "1"をappendしたresultインスタンスを共有しているので、['1', '2']が出力される
# ['1', '2']

PythonでFastAPI等のWebフレームワークを作る際には、pydanticというデータ構造化とデータバリデーションに優れたライブラリを使用しています。

そのpydanticではフィールドの初期値を設定するためにdefaultdefault_factoryいう2つのプロパティが用意されています。今回の記事では、defaultdefault_factoryの違いがないかを素振りします。

環境

  • Python
    • 3.12.4
  • FastAPI
    • 0.112.1

実装

次のようなデータ構造を用意します。

from typing import List, Annotated

from pydantic import Field, BaseModel

class CustomArray(BaseModel):
    array: Annotated[List[str], Field(default=[])]
    array_factory: Annotated[List[str], Field(default_factory=list)]

APIから実行できる状態にしておきます。

@router.put("/test/array_factory", response_model=CustomArray)
async def test_array_factory():
    result = CustomArray()
    result2 = CustomArray()
    result.array.append("A")
    result.array_factory.append("A")
    result2.array.append("B")
    result2.array_factory.append("B")
    print(result)
    print(result2)
    _ = default_param("1")
    resultx = default_param("2")
    print(resultx)


    return result2

3回実行しました。

INFO:     127.0.0.1:60892 - "PUT /array/test/array_factory HTTP/1.1" 200 OK
array=['A'] array_factory=['A']
array=['B'] array_factory=['B']
['1', '2']
INFO:     127.0.0.1:60892 - "PUT /array/test/array_factory HTTP/1.1" 200 OK
array=['A'] array_factory=['A']
array=['B'] array_factory=['B']
['1', '2', '1', '2']
INFO:     127.0.0.1:60892 - "PUT /array/test/array_factory HTTP/1.1" 200 OK
array=['A'] array_factory=['A']
array=['B'] array_factory=['B']
['1', '2', '1', '2', '1', '2']

ソースコード

終わりに

個人的な期待値としてはPythonのデフォルト引数と同じように、配列のインスタンスが共有されているのを想像しました。

しかし、pydanticRustで作られていたり、インスタンス生成後に期待通りになるというコンセプトで作られているからかdefaultでもdefault_factoryでも同じ結果で返却されました。

default_factoryの本来の使い方としては、uuidを生成したり、処理時刻を生成したりするcallableを呼ぶために使用するようですが、配列くらいならそのままdefaultでも問題なさそうですね。

とはいえ、コードリーディングで混乱しないように次の使い方をしておくことにします。

  • default
    • イミュータブルな項目を渡す
  • default_factory
    • ミュータブルな項目を生成してから渡す

参考情報