ラベル SQLAlchemy の投稿を表示しています。 すべての投稿を表示
ラベル SQLAlchemy の投稿を表示しています。 すべての投稿を表示

2025年10月4日土曜日

SQLAlchemy でテーブルスキーマと automap を共存させる方法

SQLAlchemy でテーブルスキーマと automap を共存させる方法

概要

過去にも紹介しましたがすでにテーブルがある際にわざわざテーブルのスキーマを定義しなくても Python からテーブルの情報を取得したりできる機能が automap です

今回は automap をすでに使っている環境で新規にテーブルを追加するスキーマを automap と共存させる方法を紹介します

同一 Metadata を使うのがポイントです

環境

  • macOS 15.7.1
  • MySQL 9.4.0
  • Python 3.12.11
    • mysqlclient 2.2.7
    • SQLAlchemy 2.0.43
    • alembic 1.16.5

テーブル準備

USE testdb;
CREATE TABLE user_legacy (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

このテーブルは automap 用のテーブルになります

lib/db.py

データベース接続用のファイルです

from sqlalchemy import create_engine

# MySQL で先に testdb というデータベースを作成しておくこと
# CREATE DATABASE testdb;
# MySQL ドライバは mysqlclient なので mysqldb を指定する
# 今回はテストなのでパスワード無しで root ユーザで接続
DATABASE_URL = "mysql+mysqldb://root:@localhost:3306/testdb"

engine = create_engine(DATABASE_URL, echo=True)


def get_engine():
    return engine

lib/models.py

モデルを定義するファイルです
automap の定義と追加するテーブルのスキーマ定義を共存させます
automap では既存のテーブルのみを管理するので reflection_options を追加します

reflection_options の設定がないと以下のエラーになります

sqlalchemy.exc.InvalidRequestError: Table 'user' is already defined for this MetaData instance.  Specify 'extend_existing=True' to redefine options and columns on an existing Table object.
from sqlalchemy import Integer, MetaData, String
from sqlalchemy.ext.automap import automap_base
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from lib.db import get_engine

# 共通の metadata を作る
metadata = MetaData()


# Declarative 用 Base
class Base(DeclarativeBase):
    metadata = metadata


# automap 用 Base
AutomapBase = automap_base(metadata=metadata)

# --- 既存DBをリフレクションしてクラス自動生成 ---
engine = get_engine()
# automap の対象となるテーブルを reflection_options で指定する
AutomapBase.prepare(
    engine,
    reflect=True,
    reflection_options={"only": ["user_legacy"]},  # automap で読み込むテーブルを限定
)

# 例: 既存のテーブル user_legacy を ORM クラスとして取得できる
# UserLegacy = AutomapBase.classes.user_legacy


# --- 新規テーブルは DeclarativeBase で定義 ---
class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    age: Mapped[int] = mapped_column(Integer, nullable=False)

マイグレーション確認

今回新規で追加するのは user テーブルになります

  • pipenv run alembic revision --autogenerate -m "Create user table."

これでマイグレーションファイルが作成できたら upgrade します

  • pipenv run alembic upgrade head

これで user テーブルが作成されていれば OK です

mysql> show tables;
+------------------+
| Tables_in_testdb |
+------------------+
| alembic_version  |
| user             |
| user_legacy      |
+------------------+
3 rows in set (0.004 sec)

app.py
で動作確認

automap と手動で定義したスキーマを使って各種 CRUD ができるか確認します

  • vim app.py
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session

from lib.db import get_engine
from lib.models import AutomapBase, Base, User

# SQLite / MySQL どちらでもOK
engine = get_engine()

# Declarative で定義したテーブルを作成 (まだ存在しなければ)
Base.metadata.create_all(engine)

# automap で読み込んだ既存テーブル
UserLegacy = AutomapBase.classes.user_legacy

# セッション作成
with Session(engine) as session:
    # --- INSERT (user) ---
    new_user = User(name="Alice", age=25)
    session.add(new_user)
    session.commit()
    print(f"Inserted User ID: {new_user.id}")

    # --- SELECT (user) ---
    stmt = select(User).where(User.name == "Alice")
    result = session.scalars(stmt).all()
    for user in result:
        print(f"Selected User: id={user.id}, name={user.name}, age={user.age}")

    # --- UPDATE (user) ---
    stmt = update(User).where(User.name == "Alice").values(age=26)
    session.execute(stmt)
    session.commit()
    print("Updated Alice's age to 26")

    # --- DELETE (user) ---
    stmt = delete(User).where(User.name == "Alice")
    session.execute(stmt)
    session.commit()
    print("Deleted Alice")

    # -------------------------
    # automap: user_legacy 操作
    # -------------------------
    # --- INSERT (user_legacy) ---
    new_legacy = UserLegacy(username="bob", email="bob@example.com")
    session.add(new_legacy)
    session.commit()
    print(f"Inserted UserLegacy ID: {new_legacy.id}")

    # --- SELECT (user_legacy) ---
    stmt = select(UserLegacy).where(UserLegacy.username == "bob")
    result = session.scalars(stmt).all()
    for ul in result:
        print(f"Selected UserLegacy: id={ul.id}, username={ul.username}, email={ul.email}")

    # --- UPDATE (user_legacy) ---
    stmt = update(UserLegacy).where(UserLegacy.username == "bob").values(email="bob@newmail.com")
    session.execute(stmt)
    session.commit()
    print("Updated bob's email")

    # --- DELETE (user_legacy) ---
    stmt = delete(UserLegacy).where(UserLegacy.username == "bob")
    session.execute(stmt)
    session.commit()
    print("Deleted bob from user_legacy")
  • pipenv run python app.py

user_legacy も user もどちらも操作できることを確認しましょう

最後に

SQLAlchemy の automap と独自で定義したテーブルスキームを共存させる方法を紹介しました
automap はデフォルトだとすべてのテーブル情報を読み込んでしまうので reflection_options を使うのがポイントです
あとは Metadata を共有すれば OK です

すでにテーブルがあるサービスを automap で管理してる場合にどうしても新規でテーブルを追加しなければならないケースなどに使えるテクニックかなと思います

参考サイト

2025年10月3日金曜日

SQLAlchemy + Alembic 超入門

SQLAlchemy + Alembic 超入門

概要

Python スクリプトでテーブル情報を定義しそのスキーマ情報から実際にテーブルを作成し操作するサンプルスクリプトを紹介します

環境

  • macOS 15.7.1
  • MySQL 9.4.0
  • Python 3.12.11
    • mysqlclient 2.2.7
    • SQLAlchemy 2.0.43
    • alembic 1.16.5

インストール

  • pipenv install sqlalchemy alembic mysqlclient

初期化

  • pipenv run alembic init migrations

migrations/alembic.ini が作成されれば OK です

スキーマ定義

user テーブルを作成します
今回 SQLAlchemy は v2 なので mapped_column を使います

  • mkdir lib
  • touch lib/__ini__.py
  • vim lib/models.py
from sqlalchemy import Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    age: Mapped[int] = mapped_column(Integer, nullable=False)

接続先DBの定義

MySQL の接続先情報はここで定義します
必要に応じて環境変数化や設定ファイル化すれば環境ごとに接続先を変更できます

  • vim lib/db.py
from sqlalchemy import create_engine

# MySQL で先に testdb というデータベースを作成しておくこと
# CREATE DATABASE testdb;
# MySQL ドライバは mysqlclient なので mysqldb を指定する
# 今回はテストなのでパスワード無しで root ユーザで接続
DATABASE_URL = "mysql+mysqldb://root:@localhost:3306/testdb"

engine = create_engine(DATABASE_URL, echo=True)


def get_engine():
    return engine

migrations/env.py の修正

alembic はデフォルトでは alembic.ini に記載されているデータベースに接続にいきます
今回は lib/db.py で接続先をコントロールするのでそれを使うように修正します

  • vim migrations/env.py
from logging.config import fileConfig

from alembic import context

from lib.db import get_engine
from lib.models import Base

config = context.config
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

target_metadata = Base.metadata


def run_migrations_offline():
    """オフラインモード (SQL 出力)"""
    url = str(get_engine().url)
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )
    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online():
    """オンラインモード (DB へ実行)"""
    connectable = get_engine()

    with connectable.connect() as connection:
        context.configure(connection=connection, target_metadata=target_metadata)
        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()

migration の実行

準備が整ったので migration していきます
まずは migration に必要なファイルを生成します

  • pipenv run alembic revision --autogenerate -m "Create user table."
INFO  [sqlalchemy.engine.Engine] SELECT DATABASE()
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] SELECT @@sql_mode
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] SELECT @@lower_case_table_names
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [sqlalchemy.engine.Engine] BEGIN (implicit)
INFO  [sqlalchemy.engine.Engine] DESCRIBE `testdb`.`alembic_version`
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] DESCRIBE `testdb`.`alembic_version`
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] 
CREATE TABLE alembic_version (
        version_num VARCHAR(32) NOT NULL, 
        CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
)


INFO  [sqlalchemy.engine.Engine] [no key 0.00007s] ()
INFO  [sqlalchemy.engine.Engine] SHOW FULL TABLES FROM `testdb`
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [alembic.autogenerate.compare] Detected added table 'user'
INFO  [sqlalchemy.engine.Engine] ROLLBACK
  Generating /Users/user01/data/repo/python-try/migrations/versions/4c176b6b65d3_create_user_table.py ...  done

migrations/versions/4c176b6b65d3_create_user_table.py のようなファイルが作成できれば成功です

テーブルの作成

作成されたマイグレーションファイルから実際に MySQL にテーブルを作成します

  • pipenv run alembic upgrade head
INFO  [sqlalchemy.engine.Engine] SELECT DATABASE()
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] SELECT @@sql_mode
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] SELECT @@lower_case_table_names
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [sqlalchemy.engine.Engine] BEGIN (implicit)
INFO  [sqlalchemy.engine.Engine] DESCRIBE `testdb`.`alembic_version`
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [sqlalchemy.engine.Engine] SELECT alembic_version.version_num 
FROM alembic_version
INFO  [sqlalchemy.engine.Engine] [generated in 0.00011s] ()
INFO  [sqlalchemy.engine.Engine] DESCRIBE `testdb`.`alembic_version`
INFO  [sqlalchemy.engine.Engine] [raw sql] ()
INFO  [alembic.runtime.migration] Running upgrade  -> 4c176b6b65d3, Create user table.
INFO  [sqlalchemy.engine.Engine] 
CREATE TABLE user (
        id INTEGER NOT NULL AUTO_INCREMENT, 
        name VARCHAR(255) NOT NULL, 
        age INTEGER NOT NULL, 
        PRIMARY KEY (id)
)


INFO  [sqlalchemy.engine.Engine] [no key 0.00006s] ()
INFO  [sqlalchemy.engine.Engine] INSERT INTO alembic_version (version_num) VALUES ('4c176b6b65d3')
INFO  [sqlalchemy.engine.Engine] [generated in 0.00014s] ()
INFO  [sqlalchemy.engine.Engine] COMMIT

実際に MySQL 側を確認するとテーブルができていることが確認できます

mysql> show create table user\G
*************************** 1. row ***************************
       Table: user
Create Table: CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL,
  `age` int NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.002 sec)

テーブル操作

簡単な操作方法を紹介します

  • vim app.py
from lib.models import Base, User
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session

from lib.db import get_engine

# SQLite を例に使用
engine = get_engine()

# テーブル作成 (まだ存在しなければ)
Base.metadata.create_all(engine)

# セッション作成
with Session(engine) as session:
    # --- INSERT ---
    new_user = User(name="Alice", age=25)
    session.add(new_user)
    session.commit()
    print(f"Inserted User ID: {new_user.id}")

    # --- SELECT ---
    stmt = select(User).where(User.name == "Alice")
    result = session.scalars(stmt).all()
    for user in result:
        print(f"Selected: id={user.id}, name={user.name}, age={user.age}")

    # --- UPDATE ---
    stmt = update(User).where(User.name == "Alice").values(age=26)
    session.execute(stmt)
    session.commit()
    print("Updated Alice's age to 26")

    # --- DELETE ---
    stmt = delete(User).where(User.name == "Alice")
    session.execute(stmt)
    session.commit()
    print("Deleted Alice")
  • pipenv run python app.py

で SQL が使えることを確認しましょう

最後に

SQLAlchemy v2 + alembic でテーブルマイグレーションを行う方法を紹介しました
テーブルなどが追加で必要であれば models.py に追記すれば OK です

参考サイト

2024年9月8日日曜日

Alembic で does not provide a MetaData object or sequence of objects to the context.

Alembic で does not provide a MetaData object or sequence of objects to the context.

概要

autogenerate オプションを付与すると発生するエラーになります
対策を紹介します

環境

  • Ubuntu 22.04
  • Python 3.10.2
  • alembic 1.13.2

env.py に metadata を追加する

DeclarativeBase or declarative_base で作成された Base クラスを import しその metadata を参照します
ポイントはちゃんとマイグレーションする際の context.configure にも metadata 情報を渡す点です

  • vim env.py
from app.models import Base
target_metadata = Base.metadata
def run_migrations_online() -> None:
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    configuration = config.get_section(config.config_ini_section)
    if configuration is None:
        raise ValueError()
    connectable = engine_from_config(
        configuration=configuration,
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,  # <= ここでちゃんと設定するのが重要
        )

        with context.begin_transaction():
            context.run_migrations()

最後に

あとは普通にマイグレーションできるか確認すれば OK です
almbic や sqlalchemy を最新にすると発生することがあるようです

2024年1月16日火曜日

SQLAlchemy の cast と type_coerce の使い分けとサンプルコード

SQLAlchemy の cast と type_coerce の使い分けとサンプルコード

概要

ほぼ違いはありませんがそれぞれのサンプルコードを紹介します
基本的には type_coerce を使うのが良いかなと思います

環境

  • macOS 14.2.1
  • Python 3.11.6
  • sqlalchemy 2.0.25

テーブル準備

CREATE TABLE user (id int(11) NOT NULL AUTO_INCREMENT, profile json DEFAULT NULL, PRIMARY KEY (id));

INSERT INTO user VALUES (null, '{"name":"hawk"}');
INSERT INTO user VALUES (null, '{"name":"snowlog"}');
INSERT INTO user VALUES (null, '{"name":"hawksnowlog"}');

cast サンプルコード

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import func
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        func.cast(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "ruby"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

おまけ func.cast の場合

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import cast, func
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        cast(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "swift"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

type_coerce サンプルコード

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import func, type_coerce
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        type_coerce(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "swift"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

ちょっと解説

結果の違いはどちらも同じです
どちらも同じように動きます

func に type_coerce はありませんが func から cast はコールできます
公式での type_coerce の説明は「Associate a SQL expression with a particular type, without rendering CAST.」

SQL 側の機能ではなく Python 側の機能だけで型変換を行う感じかなと思います
公式を読む限りでは cast の代替として type_coerce を使うべきだとあるので基本的には type_coerce を使うのが良いかなと思います

参考サイト

2024年1月15日月曜日

SQLAlchemy2 では Mapped カラムを使ってモデルを定義する

SQLAlchemy2 では Mapped カラムを使ってモデルを定義する

概要

Mapped カラムを使うとより Python のデータクラスっぽくモデルを定義することができます

また declarative_base の使い方も変わったので v2 にあった記載方法を紹介します

環境

  • macOS 11.7.10
  • Python 3.11.6
  • sqlalchemy 2.0.25

サンプルコード

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy.types import JSON

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")


class Base(DeclarativeBase):
    pass


class User(Base):

    __tablename__ = 'user'

    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped[dict] = mapped_column(JSON())


class UserTable():
    def __init__(self, session: Session):
        self.session = session

    def select_all(self):
        return self.session.query(User).all()


if __name__ == '__main__':
    with Session(engine) as session:
        user_table = UserTable(session)
        records = user_table.select_all()
        for r in records:
            print(r.id)
            print(r.profile)

解説

DeclarativeBase は直接使用できないの必ず DeclarativeBase を継承したベースクラスを作成してそのベースクラスを元にモデルを定義する必要があります

モデルは Mapped と一緒に型を使ってタイプヒトっぽいく記載します
そしてカラムのオプション情報などを mapped_column を使って定義します
単純な文字列を管理するクラスであれば mapped_column を使ったオプション情報は不要です

session も session_maker からは生成せずに直接クラスに engine 情報を渡すことで生成できます
基本は with セッションを使えば自動でクローズしてくれるので with と併用しましょう

JSON を TypedDict に自動バインドする

sqlalchemy v2 では json は dict として扱うので受け取るときに TypedDict として受け取ることもできます

できれば dataclass などのクラスに変換してほしかったのですが単純にやってみたところダメそうだったので自力でやるか他の方法があるのかもしれません

from typing import TypedDict

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy.types import JSON

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")


class Profile(TypedDict):
    name: str


class Base(DeclarativeBase):
    pass


class User(Base):

    __tablename__ = 'user'

    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped[Profile] = mapped_column(JSON())


class UserTable():
    def __init__(self, session: Session):
        self.session = session

    def select_all(self):
        return self.session.query(User).all()


if __name__ == '__main__':
    with Session(engine) as session:
        user_table = UserTable(session)
        records = user_table.select_all()
        for r in records:
            print(r.id)
            print(r.profile["name"])

参考: v1 でのサンプルコード

ライブラリが v2 でも互換があるので動作しますがそのうち使えなくなるかもしれないです

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()


if __name__ == '__main__':
    user_table = UserTable()
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

最後に

SQLAlchemy v2 では Mapped を使ってモデルをタイプヒントっぽく定義できるようになっています

JSON の扱い方はもう少し検討が必要そうです

参考サイト

2023年11月13日月曜日

(SQLAlchemy) RSA Encryption not supported - caching_sha2_password plugin was built with GnuTLS support

(SQLAlchemy) RSA Encryption not supported - caching_sha2_password plugin was built with GnuTLS support

概要

mysqld 側の設定ではなかったので対処方法を紹介します

環境

  • Ubuntu 22.04
  • MySQL 8.0.34
  • Python 3.11.3
    • SQLAlchemy 2.0.20

対応方法

ユーザを mysql_native_password で作成し直します

DROP USER "user01"@"172.22.%";
CREATE USER "user01"@"172.22.%" IDENTIFIED WITH mysql_native_password BY "xxx";
GRANT ALL PRIVILEGES ON *.* TO "user01"@"172.22.%";

2023年9月27日水曜日

SQLAlchemy でクエリキャッシュを使う方法 (dogpile.cache編)

SQLAlchemy でクエリキャッシュを使う方法 (dogpile.cache編)

概要

SQLAlchemy は以前 Beker cache という仕組みを使ってクエリキャッシュしていました
しかし SQLAlchemy 2.0 の現在ではその方法は廃止され dogpile.cache という汎用キャッシュの仕組みを使ってクエリキャッシュするのが王道になっています

前回 dogpile.cache の簡単な使い方を紹介しました
今回は SQLAlchemy と組み合わせて使ってみます

環境

  • macOS 13.5.2
  • Python 3.11.5
    • dogpile.cache 1.2.2
    • SQLAlchemy 2.0.21

サンプルコード

from dogpile.cache import make_region
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


class User(Base):
    __tablename__ = "user"

    id = Column(Integer, primary_key=True)
    name = Column(String(50))
    age = Column(Integer)


# Dogpileキャッシュリージョンを作成
cache_region = make_region().configure(
    "dogpile.cache.memory",  # メモリキャッシュを使用しますが、他のバックエンドも利用可能です
    expiration_time=3600,  # キャッシュの有効期限を設定(秒単位)
)


# SQLAlchemyセッションをキャッシュ対象にする
@cache_region.cache_on_arguments()
def get_user_by_id(user_id):
    db: Session = SessionLocal()
    print("Querying database for user:", user_id)
    result = db.query(User).filter_by(id=user_id).first()
    db.close()
    return result


# ユーザーを取得し、キャッシュを利用
cached_user = get_user_by_id(1)
print("User 1:", cached_user.name)

# 同じユーザーを再度取得し、キャッシュを利用
cached_user = get_user_by_id(1)
print("User 1 (cached):", cached_user.name)

ちょっと解説

dogpile.cache は同一パラメータを使った関数呼び出しの場合はキャッシュを参照するようになります
なのでその仕組を使ってクエリを投げる関数を作成しその関数に対して cache_on_arguments アノテーションを付与することでキャッシュ化することができます

2回関数をコールしていますが2回目はキャッシュを参照しているため「Querying database for user: 1」が表示されないのが確認できます

こんな感じで既存のクエリを発行する関数を簡単にキャッシュ化することができます

最後に

dogpile.cache を使って SQLAlchemy のクエリキャッシュをする方法を紹介しました
アノテーション付与だけで使えるのでかなり簡単です

dogpile.cache には他にもキャッシュ戦略やキャッシュを特定するキーの生成方法などいろいろとカスタマイズすることができるので興味があれば参考サイトにある公式ドキュメントを見ることをおすすめします

参考サイト