きり丸の技術日記

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

SQLAlchemyで同一項目でアプリとDBの値を変更する(hybrid_property)

例えば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等のライブラリ側で制御するのが良いです。

ソースコード

終わりに

うまく使えば便利ではありますが、一つのプロパティで別の挙動をするのは人間には理解しづらいです。理解していても、新規参画者にとっては同じミスを行いかねないので難しいですね。

SQLAlchemyはDBと接続するORMの役割だけを期待するようにすると良い気がします。

参考情報