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

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 です

参考サイト