例えばDBで論理削除しているユーザを画面上で表示する際に「削除済ユーザ」と表示したい。PythonのSQLAlchemyであれば、画面上の表示は「削除済ユーザ」としつつ、DBの値は元の「きり丸」としておくことが可能です。
今回の記事では、SQLAlchemyを使用してアプリ上では「削除済ユーザ」、DB上では元のまま「きり丸」とする方法を記載いたします。
なお、誤った使用をしていると意図せずエラーになってしまうので気を付けましょう。
環境
- Python
- 3.12.3
- SQLAlchemy
- 2.0.30
使用方法
次のようにhybrid_property
を使用することで、アプリ上では「削除済ユーザ」として表示し、DBとしては元のまま検索が可能です。
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm.attributes import InstrumentedAttribute class User(Base): __tablename__ = "users" __name: Mapped[str] = mapped_column("name", VARCHAR(1024)) soft_destroyed_at: Mapped[datetime | None] = mapped_column(DATETIME, nullable=True) @hybrid_property def name(self) -> str: if self.soft_destroyed_at is None: return self.__name return "削除済ユーザ" @name.inplace.setter def _name__setter(self, name: str) -> None: self.__name = name @name.inplace.expression @classmethod def _name_expression(cls) -> InstrumentedAttribute[str]: return cls.__name
危険点
SQLAlchemy
だけで完結する方法ではありますが、基本的にはアプリ上の値とDB上の値を一つのプロパティで扱う方法のため、失敗に気付き辛いかなり危険な方法ではあります。
名前であれば問題ないのですが、email
等の別のテーブルとマッチングする可能性があるプロパティに関して行うと、インスタンスの値を元にした検索ができなくなります。
async def test_cant_find_app_name(self, db: AsyncSession): """ DBの kirimaru ではなく、Appで加工した「削除済ユーザ」で検索するので、Appを経由すると取得できない。 """ user1 = User( name="kirimaru", soft_destroyed_at=datetime.now(), ) db.add(user1) name = user1.name # ココが既に「削除済ユーザ」 await db.commit() # 「削除済ユーザ」で検索をかけている query: Select = select(User).where(User.name == name) result = (await db.execute(query)).scalars().first() assert result is None
具体的な回避方法としては、インスタンスとDBが別の値を持つことが問題なので、インスタンスを経由せずにjoin
等を使用して検索をすると問題なく取得できます。
または、SQLAlchemy
はあくまでDBの値を扱うORMとして振舞い、画面表示用のデータとしてはpydantic
等のライブラリ側で制御するのが良いです。
ソースコード
- https://github.com/hirotoKirimaru/fastapi-practice/blob/19aa971b165057da48afabd53174ad0d761492de/src/models/user.py#L54-L67
- https://github.com/hirotoKirimaru/fastapi-practice/blob/19aa971b165057da48afabd53174ad0d761492de/tests/unit/cruds/test_user.py#L82
終わりに
うまく使えば便利ではありますが、一つのプロパティで別の挙動をするのは人間には理解しづらいです。理解していても、新規参画者にとっては同じミスを行いかねないので難しいですね。
SQLAlchemy
はDBと接続するORMの役割だけを期待するようにすると良い気がします。