💡

SQLModelでcreated_at とupdated_atカラムを自動で更新する【Python3】

2022/11/04に公開

はじめに

せっかくORMを使っているのであれば、いちいち手動で設定しなくても、insertやupdateしたタイミングで、created_atやupdated_atカラムをいい感じにセットして欲しいですよね。

Laravel(PHP)で使われているEloquentなどではデフォルトの機能としてありますが、SQLModelではドキュメントに記述を見つけられず少し悩みました。また悩むのも嫌なので備忘録として。

環境

M1 MBA、macOS 13で、HomebrewでインストールしたPython3.10.8を使用しています。

PC: MacBook Air M1 2020
OS: macOS Ventura 13.0

Python: 3.10.8
sqlmodel: 0.0.8
SQLAlchemy: 1.4.41
pydantic: 1.10.2

SQLModelとは

FastAPIの製作者が制作しているPython3用のORM。
ORMとはいっても、PydanticとSQLAlchemyのラッパーのようなライブラリ。

本来別々に書かないといけないPydanticとSQLAlchemyの定義を一発で書くことができ、便利そうなので使ってみることにしました。

https://sqlmodel.tiangolo.com/

まだバージョンが0.0.8であるにも関わらず、Githubのスター数が8.8kあったりします。
(コアの機能はPydanticやSQLAlchemy側が持っているので、あまり変更がいらないのかも。。。?)
Advanced User Guideがまだなかったりするので、発展途上感はあります。

裏側でSQLAlchemyを使っているので、知っているとわかりやすい部分があるかもしれません。

やり方

本題の通り、created_atとupdated_atを自動で

https://sqlmodel.tiangolo.com/tutorial/create-db-and-table/

まずは実際SQLModel定義部分のコードは以下のようになる。

from datetime import datetime
from sqlmodel import Field, SQLModel
from typing import Optional # python3.9以前のみ

class User(SQLModel, table=True):
    # 主キーを設定
    id: int | None = Field(default=None, primary_key=True)
    # python3.9までは以下
    # id: Optional[int] = Field(default=None, primary_key=True)
    
    # 通常のカラム定義
    name: str
    # ...
    # ...
    
    # created_at
    created_at: datetime = Field(default_factory=datetime.now, nullable=False)
    
    # updated_at
    updated_at: datetime = Field(
        default_factory=datetime.now, nullable=False,
        sa_column_kwargs={'onupdate': datetime.now})

それぞれ以下で説明します。

初期値の設定: created_at

defaultではなく、SQLModelのdefault_factorydatetime.now関数を設定してやります。

created_at: datetime = Field(default_factory=datetime.now, nullable=False)

自動更新: updated_at

まず、初期値はcreated_atと同様に指定します。

ドキュメントで見つけられなかったのですが、SQLModelのField関数はsa_column_kwargsというキーワード引数に辞書を渡して、SQLAlchemyのColumnに渡す引数を指定できます

これを使用して、SQLAlchmeyの機能でupdated_atを更新します。
以下のように、キーワード引数名の文字列と、渡す値を辞書型で指定します。(今回はonupdateと、datetime.now関数とを指定します。

updated_at: datetime = Field(
    default_factory=datetime.now, nullable=False,
    sa_column_kwargs={'onupdate': datetime.now})

動作確認

SQLiteを使用してテストします。

テストコード
from time import sleep
from datetime import datetime

from sqlmodel import Field, SQLModel, create_engine, Session, select


# モデルを定義
class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    name: str
    created_at: datetime = Field(default_factory=datetime.now, nullable=False)
    updated_at: datetime = Field(
        default_factory=datetime.now, nullable=False,
        sa_column_kwargs={'onupdate': datetime.now})

# DBへの接続を定義
sqlite_file_name = "database.sqlite"
sqlite_url = f"sqlite:///{sqlite_file_name}"
engine = create_engine(sqlite_url, echo=True)

# テーブルを作成
SQLModel.metadata.create_all(engine)

# Insert 1
with Session(engine) as session:
    user_1 = User(name='Taro')
    user_2 = User(name='Hanako')
    session.add(user_1)
    session.add(user_2)
    session.commit()

# 3秒Sleep
sleep(3)

with Session(engine) as session:
    user_3 = User(name='Jiro')
    session.add(user_3)
    session.commit()

# 3秒Sleep
sleep(3)

# Update
with Session(engine) as session:
    stmt = select(User)
    user = session.exec(stmt).first()
    if user is None:
        raise RuntimeError('user not found')
    user.name = 'Takashi'
    session.commit()

with Session(engine) as session:
    stmt = select(User)
    users = session.exec(stmt).all()
    print(users)

詰まったところ

SQLAlchemyのColumnとSQLModelのFieldは微妙に違うので、少し注意が必要でした。

default_factoryを使用する

SQLAlchemyのColumnのdefaultonupdate引数は公式ドキュメントで以下のように言われている通り、Pythonの関数を渡すことができます。

The Column.default and Column.onupdate keyword arguments also accept Python functions.

https://docs.sqlalchemy.org/en/14/core/defaults.html#python-executed-functions

それに対してSQLModelはdefaultではなくdefault_factoryが用意されており、defaultには関数を指定できません。 default_factoryを使用しましょう。

同じノリでdefaultに関数を渡すとエラーになりますし、datetime.now(関数)の代わりにdatetime.now()(datetimeオブジェクト)を指定すると意味が全く変わってしまうので注意が必要です。

また、sa_column_kargs側でdefaultを設定しても、SQLModel側のdefaultが優先されます。

結論

Pydanticの機能を使いつつ、ストレスフリーにRDBを操作できるようになりました。
いざとなればSQLAlchemyの機能も触れるので、割と自由度も高めです。

PydanticのおかげでVSCodeの補完や型チェックがかなりしっかりかかるようになり、快適になりました。

たとえば、動作テストのコード内の、selectから1件目のレコードを取得する場合のコードでは、以下のような恩恵があります。(ダブって表示されてたり画質が悪いのは気にしないでください)

FastAPIを使用する場合はもちろん、それ以外の場合にも使えそうです。

Discussion