SQLAlchemyチュートリアル:簡単なアプリで始めるORM

SQLAlchemyチュートリアル:簡単なアプリで始めるORM

はじめに:SQLAlchemyとは何か、そしてなぜ使うのか

Pythonでデータベースを扱う際、生SQLを文字列として記述する方法は柔軟性がありますが、開発が進むにつれて保守性の低下、SQLインジェクションのリスク、データベースごとの方言の違いへの対応など、様々な課題に直面します。これらの課題を解決するために登場するのがORM(Object-Relational Mapper:オブジェクト関係マッピング)です。

ORMは、データベースのテーブル構造をPythonのクラスとして表現し、レコードをそのクラスのインスタンスとして扱えるようにする技術です。これにより、Pythonのオブジェクト指向プログラミングのパラダイムの中でデータベース操作を行うことが可能になります。SQLの複雑な構文を直接書く代わりに、Pythonのコードとして記述できるため、コードの可読性、保守性、生産性が向上します。

SQLAlchemyは、Pythonで最も人気があり、強力なORMライブラリの一つです。しかし、SQLAlchemyは単なるORMにとどまりません。その低レベルのAPIであるSQLAlchemy Coreは、SQLのステートメントをPythonコードでプログラム的に生成できるSQLビルダーとしての機能も提供します。ORMはCoreの上に構築されており、より抽象度の高い、オブジェクト指向的なデータベース操作を提供します。

この記事では、SQLAlchemyの中でも特にORM機能に焦点を当て、簡単なサンプルアプリケーションを通してその基本的な使い方を習得することを目指します。ORMの概念から始まり、テーブルの定義、データの追加、検索、更新、削除といった基本的なCRUD操作、さらにはリレーションシップ(テーブル間の関連)の定義と操作までを、具体的なコード例とともに詳しく解説します。

なぜSQLAlchemy ORMを使うのか?

  1. 生産性の向上: Pythonオブジェクトとしてデータを扱えるため、SQLの構文を意識する時間を減らし、アプリケーションロジックに集中できます。
  2. 保守性の向上: コードがよりPython的になり、リファクタリングや変更が容易になります。スキーマ変更への対応も、モデル定義の変更という形でより自然に行えます。
  3. 移植性: 多くの主要なデータベース(PostgreSQL, MySQL, SQLite, Oracle, SQL Serverなど)をサポートしており、データベースの種類に依存しないコードを記述できます(ただし、一部の高度な機能やデータ型はデータベース固有の場合があります)。
  4. 安全性: パラメータバインディングが自動的に行われるため、SQLインジェクションのリスクを低減できます。
  5. 表現力: 単なる簡単なクエリだけでなく、複雑な結合、集約、サブクエリなどもORMのクエリAPIを使って表現できます。

対象読者

  • Pythonを使ってデータベース操作を行いたい方
  • ORMの概念に触れたい方
  • SQLAlchemyの基本的な使い方を学びたい方
  • 生のSQL記述からステップアップしたい方

この記事を通して、SQLAlchemy ORMを使ったデータベースアプリケーション開発の第一歩を踏み出しましょう。

準備:環境構築とプロジェクト構造

SQLAlchemyを使った開発を始める前に、いくつかの準備が必要です。

1. 必要なもの

  • Python 3.7以上がインストールされている環境。
  • パッケージ管理ツールpip。Pythonをインストールしていれば通常含まれています。

2. インストール

SQLAlchemy自体はpipを使って簡単にインストールできます。

bash
pip install sqlalchemy

今回はデータベースとして軽量なSQLiteを使用します。SQLiteはPythonの標準ライブラリsqlite3に含まれているため、追加のライブラリは不要です。もし別のデータベース(PostgreSQLやMySQLなど)を使用する場合は、それぞれのデータベースドライバーをインストールする必要があります。

  • PostgreSQL: pip install psycopg2-binary
  • MySQL: pip install PyMySQL または mysql-connector-python

3. プロジェクト構成

簡単なチュートリアルなので、複雑なプロジェクト構成は不要です。今回は以下のようなシンプルな構造で進めます。

my_sqlalchemy_app/
├── main.py # メインのアプリケーションコード
└── database.db # SQLiteデータベースファイル (コード実行時に生成される)

main.pyの中に、SQLAlchemyを使ったデータベース操作のコードを記述していきます。

準備はこれで完了です。早速SQLAlchemyの基本概念に入りましょう。

SQLAlchemyの基本概念

SQLAlchemy ORMを使ったアプリケーションを理解するために、いくつかの重要な概念を把握する必要があります。

1. エンジン (Engine)

Engineは、アプリケーションとデータベースの間の主要な通信ポイントです。データベースへの接続プールや、データベース方言(特定のデータベースシステム固有の機能や構文)の管理などを担当します。SQLAlchemyを使う上で、最初に作成する必要があるオブジェクトの一つです。create_engine()関数を使って作成します。

“`python
from sqlalchemy import create_engine

SQLiteデータベースファイル ‘database.db’ に接続するEngineを作成

engine = create_engine(‘sqlite:///database.db’)
“`

データベースURIの形式は'データベースシステム名+ドライバー名://ユーザー名:パスワード@ホスト名:ポート番号/データベース名'となります。SQLiteの場合はファイルパスを指定します。

2. コネクション (Connection)

Engineはコネクションプールを管理しており、個々のデータベース操作はConnectionオブジェクトを介して行われます。ORMを使う場合、通常はConnectionを直接操作するよりも、次に説明するSessionを介してORMレベルの操作を行います。しかし、Engineから直接Connectionを取得し、SQLAlchemy Coreのステートメントを実行することも可能です。

“`python

EngineからConnectionを取得 (通常ORMではSessionを使う)

with engine.connect() as connection:
result = connection.execute(text(“SELECT 1”)) # SQLAlchemy Coreのtext関数
print(result.fetchone())
``text()`関数はSQL文字列を表現するために使用されます。

3. セッション (Session)

Sessionは、ORMの中核をなすオブジェクトです。データベース操作の「作業領域」を提供します。ORMモデルインスタンスの追加、検索、更新、削除といった操作は全てSessionを介して行われます。Sessionはトランザクションの概念も管理しており、複数の操作を一つの単位としてコミットまたはロールバックできます。

Sessionは直接インスタンス化するのではなく、sessionmakerというファクトリを使って作成するのが一般的です。

“`python
from sqlalchemy.orm import sessionmaker

Engineに関連付けられたSessionクラスを作成

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Sessionインスタンスを作成

session = SessionLocal()
``autocommit=Falseautoflush=Falseは、手動でsession.commit()を呼び出すまで変更をデータベースに反映しない設定です。これはトランザクション管理のために重要です。bind=engine`で、どのデータベースEngineを使うかを指定します。

Sessionは使い終わったら閉じることが推奨されます(session.close())。リソースリークを防ぐため、通常はコンテキストマネージャー(withステートメント)と組み合わせて使用します。

4. メタデータ (MetaData)

MetaDataオブジェクトは、データベース全体の構造(テーブル、カラム、制約など)に関する情報を保持します。ORMを使う場合、後述のDeclarative BaseがMetaDataオブジェクトを内部的に管理します。

“`python
from sqlalchemy import MetaData

metadata = MetaData()
“`

5. 宣言的ベース (Declarative Base)

SQLAlchemy ORMの最も一般的な使い方の一つが、宣言的なスタイルでモデルクラスを定義することです。これは、Pythonクラスを定義するだけで、対応するデータベーステーブルとその構造を同時に宣言する方法です。このスタイルの中心となるのが「宣言的ベースクラス」です。このクラスを継承して、ORMモデルクラスを定義します。

“`python
from sqlalchemy.ext.declarative import declarative_base

宣言的ベースクラスを作成

Base = declarative_base()

Baseを継承してORMモデルクラスを定義

class User(Base):

``
**注:** SQLAlchemy 2.0以降では、
declarative_basesqlalchemy.ormに移動し、よりモダンな使い方が推奨されています。しかし、チュートリアルではまだ多くの場所でsqlalchemy.ext.declarativeのスタイルが使われているため、ここでは一般的なスタイルで説明します。新しいスタイル (from sqlalchemy.orm import DeclarativeBase) もありますが、基本概念は同じです。この記事ではsqlalchemy.ext.declarative.declarative_base`を使用します。

6. テーブル (Table)

Declarative Baseを使って定義されたORMモデルクラスは、内部的にTableオブジェクトを生成します。Tableオブジェクトは、データベース上の物理的なテーブルと直接的に対応します。

7. カラム (Column)

ORMモデルクラスの属性は、データベーステーブルのカラムに対応します。これらの属性はColumnオブジェクトを使って定義します。Columnには、カラム名、データ型、制約(主キー、NOT NULL、ユニークなど)を指定します。

“`python
from sqlalchemy import Column, Integer, String

class User(Base):

tablename = ‘users’ # テーブル名を指定

id = Column(Integer, primary_key=True) # 整数型の主キー

name = Column(String, nullable=False) # 文字列型、NULLを許可しない

email = Column(String, unique=True) # 文字列型、ユニーク制約

“`

これらの基本概念を踏まえて、実際に簡単なアプリケーションを構築していきましょう。

簡単なアプリケーションの構築:CRUD操作

ここでは、「ユーザー (User)」という簡単なモデルを使って、データベースへの接続、スキーマ作成、そしてデータの追加 (Create)、検索 (Read)、更新 (Update)、削除 (Delete) という一連のCRUD操作を実践します。

ステップ1: データベース接続の確立

まず、Engineを作成します。これはデータベースへの接続プールを管理するオブジェクトです。

“`python

main.py の先頭に追加

from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

後で使用するimportもここでまとめておきます

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship, sessionmaker

SQLiteデータベースファイル ‘database.db’ に接続するEngineを作成

echo=True を指定すると、SQLAlchemyが実行するSQL文がコンソールに表示され、デバッグに役立ちます

engine = create_engine(‘sqlite:///database.db’, echo=True)
“`

echo=Trueは学習段階で非常に便利です。SQLAlchemyがORM操作の裏で実際にどのようなSQLを生成・実行しているのかを確認できます。

ステップ2: ORMモデルの定義

次に、データベースのテーブルに対応するPythonクラス、つまりORMモデルを定義します。sqlalchemy.ext.declarative.declarative_baseから作成したBaseクラスを継承します。

ここでは、シンプルなUserモデルを定義します。

“`python

Baseクラスを作成(ステップ1のimportの下に追加)

Base = declarative_base()

Userモデルを定義

class User(Base):
tablename = ‘users’ # このモデルが対応するデータベーステーブル名

id = Column(Integer, primary_key=True) # 主キー
name = Column(String, nullable=False) # ユーザー名、必須
age = Column(Integer) # 年齢、任意

# モデルのインスタンスをprintなどで表示したときの表現を定義
def __repr__(self):
    return f"<User(id={self.id}, name='{self.name}', age={self.age})>"

“`

  • __tablename__ = 'users' は、このUserクラスがデータベースのusersテーブルに対応することをSQLAlchemyに伝えます。
  • id = Column(Integer, primary_key=True) は、usersテーブルにidという名前の整数型のカラムを作成し、それを主キーとすることを宣言しています。primary_key=Trueを指定すると、多くのデータベースで自動採番(Auto Increment)設定も行われます。
  • name = Column(String, nullable=False) は、nameという名前の文字列型のカラムを作成し、NULL値を許可しない(必須項目)ことを宣言しています。
  • age = Column(Integer) は、ageという名前の整数型のカラムを作成します。nullable=Falseを指定していないため、デフォルトでNULLを許可します。
  • __repr__ メソッドは、デバッグ時にモデルインスタンスの内容を確認しやすくするために定義しています。

ステップ3: データベーススキーマの作成

定義したORMモデルに基づいて、実際にデータベースにテーブルを作成します。これは、Base.metadata.create_all(engine)を呼び出すことで行えます。このメソッドは、Baseに登録されている全てのモデルに対応するテーブルを、指定したEngineが指すデータベースに作成します。既にテーブルが存在する場合は何も起こりません。

“`python

main.py の末尾あたりに記述

スキーマを作成(テーブルを生成)

既に存在する場合はスキップされる

Base.metadata.create_all(engine)

print(“データベーススキーマが作成されました(または既に存在します)。”)
“`

ここまでのコードを実行すると、database.dbというSQLiteファイルが作成され、その中にusersというテーブルが生成されているはずです。echo=Trueを設定している場合、CREATE TABLE文がコンソールに表示されるのが確認できます。

ステップ4: セッションの作成とデータの追加 (Create)

データベース操作はSessionを介して行います。まずsessionmakerを使ってSessionクラスを作成し、そこからSessionインスタンスを取得します。

“`python

Engine作成の下あたりに追加

Sessionクラスを作成

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

ここからアプリケーションの実行ロジック

def create_user(name: str, age: int = None):
# Sessionインスタンスを作成(Context Managerを使うのが一般的で安全)
with SessionLocal() as session:
# Userモデルのインスタンスを作成
new_user = User(name=name, age=age)

    # セッションに追加
    session.add(new_user)

    # コミット(データベースに反映)
    session.commit()

    # 追加されたオブジェクトのIDを取得(コミット後にIDが設定されることが多い)
    session.refresh(new_user) # データベースから最新の状態を読み込む
    print(f"ユーザーを追加しました: {new_user}")

ユーザーを追加してみる

if name == “main“:
# スキーマ作成は一度だけ行う
Base.metadata.create_all(engine)
print(“— ユーザー追加 —“)
create_user(“Alice”, 30)
create_user(“Bob”) # ageはNoneで追加
create_user(“Charlie”, 25)

“`

  • with SessionLocal() as session: のようにContext Managerを使うことで、ブロックを抜ける際に自動的にSessionが閉じられるため、リソース管理が容易になります。
  • User(name=name, age=age) で、Pythonのオブジェクトとして新しいユーザーデータを作成します。この時点ではまだデータベースには存在しません。
  • session.add(new_user) で、作成したオブジェクトをセッションの管理下に置きます。このオブジェクトは「pending」(保留)状態になります。
  • session.commit() を呼び出すことで、セッションに加えられた変更(この場合は新しいユーザーの追加)がまとめてデータベースに反映されます。ここでINSERT文が実行されます。コミット後、オブジェクトは「persistent」(永続化)状態になります。
  • session.refresh(new_user) は、コミット後にデータベースからオブジェクトの最新の状態(ここではデータベース側で自動採番されたidなど)を読み込むために使用します。

複数のオブジェクトを追加する場合は、session.add_all([user1, user2, user3]) のようにリストでまとめて追加することも可能です。

ステップ5: データの検索 (Read)

データベースからデータを取得するには、Sessionのquery()メソッドを使用します。query()はORMモデルクラスを指定して呼び出し、その結果に対して様々なフィルタリング、ソート、制限などの操作を行うメソッドをチェーンして呼び出せます。

“`python

main.py に追記

def get_users():
print(“— ユーザー検索 —“)
with SessionLocal() as session:
# usersテーブルの全件を取得
users = session.query(User).all()
print(“全ユーザー:”)
for user in users:
print(user)
print(“-” * 20)

    # 条件を指定して検索 (ageが30以上のユーザー)
    # filter() はSQLのWHERE句に相当
    old_users = session.query(User).filter(User.age >= 30).all()
    print("年齢が30歳以上のユーザー:")
    for user in old_users:
        print(user)
    print("-" * 20)

    # 複数の条件を指定
    # filter() を複数回呼び出すとAND条件になる
    alice = session.query(User).filter(User.name == "Alice", User.age == 30).first()
    print("名前がAliceで年齢が30歳のユーザー (一件のみ):")
    print(alice)
    print("-" * 20)

    # filter_by() を使うと属性名をキーワード引数で指定できる (シンプルな=条件のみ)
    bob = session.query(User).filter_by(name="Bob").first()
    print("名前がBobのユーザー (filter_by):")
    print(bob)
    print("-" * 20)

    # IN 句に相当する検索
    names_to_find = ["Alice", "Charlie"]
    selected_users = session.query(User).filter(User.name.in_(names_to_find)).all()
    print(f"名前が {names_to_find} のユーザー:")
    for user in selected_users:
        print(user)
    print("-" * 20)

    # NULL 値の検索
    users_without_age = session.query(User).filter(User.age.is_(None)).all()
    print("年齢が未設定のユーザー:")
    for user in users_without_age:
        print(user)
    print("-" * 20)

    # 並べ替え (ORDER BY)
    sorted_users = session.query(User).order_by(User.name.asc()).all() # asc() は昇順 (デフォルト)、desc() は降順
    print("名前で昇順ソートしたユーザー:")
    for user in sorted_users:
        print(user)
    print("-" * 20)

    # 件数制限とオフセット (LIMIT, OFFSET)
    limited_users = session.query(User).limit(2).offset(1).all() # 2件取得、最初の1件をスキップ
    print("2件取得 (最初の1件をスキップ):")
    for user in limited_users:
        print(user)
    print("-" * 20)

    # 件数カウント (COUNT)
    user_count = session.query(User).count()
    print(f"全ユーザー数: {user_count}")
    print("-" * 20)

if name == “main“:
Base.metadata.create_all(engine)
print(“— ユーザー追加 —“)
create_user(“Alice”, 30)
create_user(“Bob”)
create_user(“Charlie”, 25)
print(“\n” * 2) # 区切りとして改行

# 検索を実行
get_users()

“`

  • session.query(User): Userモデルに対応するクエリビルダを作成します。
  • .all(): クエリの結果として得られる全てのレコードを、Userモデルのインスタンスのリストとして取得します。
  • .first(): クエリの結果の最初の1件を、Userモデルのインスタンスとして取得します。結果がない場合はNoneを返します。
  • .one(): クエリの結果がちょうど1件であることを期待して、その1件をインスタンスとして取得します。0件または2件以上の場合はエラー(NoResultFoundまたはMultipleResultsFound)が発生します。
  • .scalar(): クエリ結果が1行1カラムである場合に、その値(スカラー値)を取得します。
  • .filter(条件): SQLのWHERE句に相当します。条件は、モデルクラスの属性と演算子(==, >, <, >=, <=, !=)を使って記述します。複数の条件をカンマで区切るとAND条件になります。OR条件やNOT条件はsqlalchemyモジュールからor_, and_, not_をインポートして使用します(例: filter(or_(User.age < 18, User.age > 65)))。
  • .filter_by(属性名=値): シンプルな等価条件(=)の場合にのみ使用できる、よりシンプルな記述方法です。
  • .in_(): SQLのIN句に相当します。リストやタプルを渡します。
  • .is_(None), .isnot(None): SQLのIS NULL, IS NOT NULLに相当します。== None!= Noneは使用できません。
  • .order_by(カラムまたは属性): SQLのORDER BY句に相当します。昇順はデフォルトまたは.asc()、降順は.desc()を指定します。複数のカラムでソートする場合はカンマで区切ります。
  • .limit(件数): SQLのLIMIT句に相当します。取得するレコードの最大数を指定します。
  • .offset(件数): SQLのOFFSET句に相当します。スキップするレコード数を指定します。limit()と組み合わせてページネーションなどに使用します。
  • .count(): クエリの結果の件数を取得します。SQLのCOUNT(*)に相当します。

ステップ6: データの更新 (Update)

既存のデータを更新するには、まずそのデータを検索して取得し、取得したモデルインスタンスの属性をPython側で変更します。その後、セッションをコミットすることで、その変更がデータベースに反映されます。

“`python

main.py に追記

def update_user_age(user_id: int, new_age: int):
print(f”— ユーザー更新 (ID: {user_id}) —“)
with SessionLocal() as session:
# 更新したいユーザーをIDで検索して取得
user_to_update = session.query(User).filter(User.id == user_id).first()

    if user_to_update:
        print(f"更新前: {user_to_update}")
        # モデルインスタンスの属性を変更
        user_to_update.age = new_age

        # セッションに変更内容が記録される(フラッシュ時にUPDATE文が実行される)
        # 明示的に session.flush() を呼ぶこともできますが、コミット時に自動的にフラッシュされます

        # コミットしてデータベースに反映
        session.commit()
        print(f"更新後: {user_to_update}") # コミット後に再読み込みしないと表示は古いままかもしれないが、ageは変更されている

        # 更新後の状態をデータベースから取得して確認することも可能
        # session.refresh(user_to_update)
        # print(f"更新後 (refresh): {user_to_update}")

    else:
        print(f"ID {user_id} のユーザーは見つかりませんでした。")
print("-" * 20)

ユーザーを更新してみる

if name == “main“:
Base.metadata.create_all(engine)
# 初期データ作成 (毎回実行して、IDをリセットしないように注意。
# 実際のアプリではマイグレーションツールを使うか、存在チェックをする)
# ここでは簡単のため、一度だけ追加された前提で進めます。
# もし毎回実行してIDが変わってしまうのが嫌なら、IDを指定して検索し、
# 見つからなければ追加、見つかればスキップ、といったロジックを入れる必要があります。

# 確認のため全ユーザーを表示
get_users()

# ID=1 のユーザー (Alice) の年齢を更新
update_user_age(1, 31)

# ID=2 のユーザー (Bob) の年齢を更新
update_user_age(2, 40)

# 更新後の状態を再度表示
get_users()

“`

  • 更新対象のオブジェクトをsession.query().filter().first()などで取得します。
  • 取得したオブジェクト(これはSessionが管理している)の属性に新しい値を代入します。
  • session.commit() を呼び出すと、Sessionが管理しているオブジェクトの中で変更されたもの全てに対してUPDATE文が生成され、データベースに実行されます。

ステップ7: データの削除 (Delete)

データを削除するには、まず削除したいデータを検索して取得し、session.delete()メソッドに渡します。その後、セッションをコミットすることで、削除がデータベースに反映されます。

“`python

main.py に追記

def delete_user(user_id: int):
print(f”— ユーザー削除 (ID: {user_id}) —“)
with SessionLocal() as session:
# 削除したいユーザーをIDで検索して取得
user_to_delete = session.query(User).filter(User.id == user_id).first()

    if user_to_delete:
        print(f"削除対象: {user_to_delete}")
        # セッションから削除
        session.delete(user_to_delete)

        # コミットしてデータベースに反映
        session.commit()
        print(f"ユーザー ID {user_id} を削除しました。")
    else:
        print(f"ID {user_id} のユーザーは見つかりませんでした。")
print("-" * 20)

ユーザーを削除してみる

if name == “main“:
Base.metadata.create_all(engine)
# 初期データ作成… (省略、前のステップを参照)

# 確認のため全ユーザーを表示
get_users()

# ID=2 のユーザー (Bob) を削除
delete_user(2)

# 削除後の状態を再度表示
get_users()

“`

  • 削除対象のオブジェクトを検索して取得します。
  • session.delete(オブジェクト)で、そのオブジェクトをSessionの管理下から削除対象としてマークします。このオブジェクトは「deleted」(削除済み)状態になります。
  • session.commit()を呼び出すと、DELETE文が生成され、データベースに実行されます。コミット後、オブジェクトは「detached」(分離)状態になり、Sessionの管理下から外れます。

ステップ8: セッションのクローズ

with SessionLocal() as session:のようにContext Managerを使用している場合、ブロックを抜ける際に自動的にsession.close()が呼び出されます。これにより、データベース接続リソースが適切に解放されます。Context Managerを使わない場合は、明示的にsession.close()を呼び出す必要があります。

ここまでのコードのまとめ

“`python

main.py

from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, or_, and_, not_
from sqlalchemy.orm import sessionmaker, relationship, declarative_base
from sqlalchemy.sql import func # 集約関数などに使用

データベース接続の確立

echo=True で実行されるSQLを表示

engine = create_engine(‘sqlite:///database.db’, echo=True)

宣言的ベースクラスを作成

Base = declarative_base()

ORMモデルの定義

class User(Base):
tablename = ‘users’

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

def __repr__(self):
    return f"<User(id={self.id}, name='{self.name}', age={self.age})>"

Sessionクラスを作成

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

—– CRUD操作関数 —–

def create_user(name: str, age: int = None):
with SessionLocal() as session:
new_user = User(name=name, age=age)
session.add(new_user)
session.commit()
session.refresh(new_user)
print(f”Added: {new_user}”)
return new_user

def get_users():
print(“— Reading Users —“)
with SessionLocal() as session:
# 全件取得
users = session.query(User).all()
print(“All users:”)
for user in users:
print(user)

    # 条件付き検索 (age > 25 かつ name LIKE 'A%')
    # from sqlalchemy import and_ をインポート
    users_over_25_starting_with_a = session.query(User).filter(
        and_(User.age > 25, User.name.like('A%'))
    ).all()
    print("\nUsers over 25 starting with 'A':")
    for user in users_over_25_starting_with_a:
        print(user)

    # OR条件 (age < 20 または age IS NULL)
    # from sqlalchemy import or_ をインポート
    young_or_unknown_age_users = session.query(User).filter(
        or_(User.age < 20, User.age.is_(None))
    ).all()
    print("\nUsers under 20 or with unknown age:")
    for user in young_or_unknown_age_users:
        print(user)

    # 並べ替えと件数制限
    sorted_limited_users = session.query(User).order_by(User.age.desc()).limit(2).all()
    print("\nTop 2 oldest users:")
    for user in sorted_limited_users:
        print(user)

    # 集約関数 (例: 平均年齢)
    # from sqlalchemy.sql import func をインポート
    average_age = session.query(func.avg(User.age)).scalar()
    print(f"\nAverage age: {average_age}")

print("-" * 20)

def update_user_age(user_id: int, new_age: int):
print(f”— Updating User ID {user_id} —“)
with SessionLocal() as session:
user_to_update = session.query(User).filter(User.id == user_id).first()
if user_to_update:
print(f”Before update: {user_to_update}”)
user_to_update.age = new_age
session.commit()
print(f”After update (age={new_age}): {user_to_update}”)
else:
print(f”User with ID {user_id} not found.”)
print(“-” * 20)

def delete_user(user_id: int):
print(f”— Deleting User ID {user_id} —“)
with SessionLocal() as session:
user_to_delete = session.query(User).filter(User.id == user_id).first()
if user_to_delete:
print(f”Deleting: {user_to_delete}”)
session.delete(user_to_delete)
session.commit()
print(f”User with ID {user_id} deleted.”)
else:
print(f”User with ID {user_id} not found.”)
print(“-” * 20)

— アプリケーション実行部分 —

if name == “main“:
# データベーススキーマを作成
Base.metadata.create_all(engine)
print(“Database schema created (or already exists).\n”)

# 既存データがあれば表示(開発中はファイル削除などでリセット可能)
get_users()

# データをいくつか追加
print("--- Adding Initial Users ---")
create_user("Alice", 30)
create_user("Bob", 20)
create_user("Charlie", 25)
create_user("David") # Age is None
create_user("Anna", 35)
print("\n")

# 追加後の全ユーザーを表示
get_users()
print("\n")

# データの更新
update_user_age(user_id=1, new_age=31) # Aliceの年齢を更新
update_user_age(user_id=4, new_age=19) # Davidの年齢を更新
print("\n")

# 更新後の全ユーザーを表示
get_users()
print("\n")

# データの削除
delete_user(user_id=3) # Charlieを削除
print("\n")

# 削除後の全ユーザーを表示
get_users()

“`

このコードを実行すると、database.dbファイルが作成され、usersテーブルへのCRUD操作が実行され、その過程で発行されるSQLや結果がコンソールに表示されます。

ここまでのステップで、SQLAlchemy ORMを使った基本的な単一テーブルのCRUD操作ができるようになりました。しかし、実際のデータベースアプリケーションでは、複数のテーブルが互いに関連し合っています。次に、ORMにおけるリレーションシップの定義と操作方法を学びましょう。

リレーションシップの定義と操作

データベースの世界では、テーブル間はリレーション(関連)によって結ばれています。例えば、「ユーザーは複数の投稿をする」といった関係です。SQLAlchemy ORMでは、このテーブル間のリレーションシップをPythonのオブジェクト属性として表現し、関連するオブジェクトに簡単にアクセスできるようにします。

主要なリレーションシップのタイプには以下のようなものがあります。

  • 一対多 (One-to-Many): 一つのレコードが、別のテーブルの複数のレコードに関連付けられる関係(例: 一人のユーザーは複数の投稿を持つ)。
  • 多対一 (Many-to-One): 複数のレコードが、別のテーブルの一つのレコードに関連付けられる関係(例: 複数の投稿は一人のユーザーに紐づく)。これは一対多の逆方向の関係です。
  • 一対一 (One-to-One): 一つのレコードが、別のテーブルのちょうど一つのレコードに関連付けられる関係(例: 一人のユーザーは一つのプロフィール詳細を持つ)。
  • 多対多 (Many-to-Many): 複数のレコードが、別のテーブルの複数のレコードに関連付けられる関係(例: 一つの記事は複数のタグを持つ、一つのタグは複数の記事に関連付けられる)。この関係は通常、中間テーブル(結合テーブル)を使って実現されます。

SQLAlchemy ORMでは、これらのリレーションシップをrelationship()関数とForeignKeyを使って定義します。

例:一対多 / 多対一 リレーションシップ (ユーザーと投稿)

Userモデルに加えて、Post(投稿)モデルを作成し、「一人のユーザーは複数の投稿を持つ」という一対多の関係を定義してみましょう。Postは必ず一人のUserに紐づきます。

“`python

main.py の User モデル定義の下あたりに追加

from datetime import datetime # タイムスタンプ用

Post モデルを定義

class Post(Base):
tablename = ‘posts’

id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
content = Column(String)
created_at = Column(sqlalchemy.DateTime, default=datetime.now) # 作成日時
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # 外部キー

# ORMリレーションシップの定義
# このPostインスタンスがどのUserに関連付けられているか
# 'User' は関連先のORMクラス名
user = relationship("User", back_populates="posts")

def __repr__(self):
    # created_at が datetime オブジェクトなので、表示用に整形
    created_at_str = self.created_at.strftime('%Y-%m-%d %H:%M') if self.created_at else 'N/A'
    return f"<Post(id={self.id}, title='{self.title[:20]}...', user_id={self.user_id}, created_at='{created_at_str}')>"

User モデルにもリレーションシップを追加 (一対多の ‘多’ の方)

ユーザーに関連付けられた複数のPostインスタンスにアクセスできるようになる

class User(Base): # Userモデル定義全体を以下のように修正
tablename = ‘users’

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

# ORMリレーションシップの定義
# このUserインスタンスが持つ複数のPostインスタンス
# 'Post' は関連先のORMクラス名
# uselist=True は、デフォルトで一対多であることを示す (リストになる)
# back_populates="user" は、Postモデルの 'user' リレーションシップと双方向で関連付ける
posts = relationship("Post", back_populates="user", cascade="all, delete-orphan") # cascade設定を追加

def __repr__(self):
    return f"<User(id={self.id}, name='{self.name}', age={self.age})>"

``
**注:** 上記コードでは
import sqlalchemyが必要ですが、通常from sqlalchemy import …で個別にインポートします。sqlalchemy.DateTimeのようにモジュール名をつけて使う場合はimport sqlalchemyが必要です。あるいはfrom sqlalchemy import DateTimeとインポートしてDateTimeと使います。ここではfrom sqlalchemy import …でインポート済みと仮定し、sqlalchemy.DateTimeではなくDateTimeと書くことにします。(上記のサンプルコードではsqlalchemy.DateTimeと書いてしまっているので、import sqlalchemyを追加するか、from sqlalchemy import DateTimeに変更してください。)
正しくは
from sqlalchemy import DateTimeをインポートリストに追加し、Column(DateTime, …)`と記述します。

ForeignKey('users.id')は、postsテーブルのuser_idカラムが、usersテーブルのidカラムを参照する外部キーであることを宣言しています。これはデータベーススキーマレベルの制約です。

relationship()は、ORMレベルでの関連付けを定義します。

  • Postモデルのuser = relationship("User", back_populates="posts"):

    • 「このPostインスタンスは一つのUserインスタンスに関連付けられる」ことを示します。
    • "User"は関連先のORMクラス名です(文字列でも指定可能で、循環参照を防ぐのに役立ちます)。
    • back_populates="posts"は、Userモデルのpostsリレーションシップと双方向で関連付けられることを意味します。これにより、user.postsでそのユーザーの投稿リストにアクセスできるだけでなく、post.userでその投稿のユーザーにアクセスできるようになります。
  • Userモデルのposts = relationship("Post", back_populates="user", cascade="all, delete-orphan"):

    • 「このUserインスタンスは複数のPostインスタンスを持つ」ことを示します。uselist=Trueがデフォルトなので、アクセスするとPostインスタンスのリストが返されます。
    • "Post"は関連先のORMクラス名です。
    • back_populates="user"は、Postモデルのuserリレーションシップと双方向で関連付けられることを意味します。
    • cascade="all, delete-orphan"は、カスケード操作の設定です。
      • "all"は、親オブジェクト(User)に対する操作(追加、更新、削除)が子オブジェクト(Post)にも伝播することを示します。例えば、ユーザーをセッションに追加すると、そのユーザーに関連付けられている投稿も自動的にセッションに追加されます。
      • "delete-orphan"は、関連付けがなくなった子オブジェクト(Post)を自動的に削除することを示します。例えば、ある投稿をそのユーザーのpostsリストから削除してコミットすると、その投稿はデータベースからも削除されます。

リレーションシップを使ったデータの操作

リレーションシップを定義すると、関連するオブジェクトへのアクセスや操作が非常に容易になります。

“`python

main.py に新しい関数を追加

def create_user_with_posts(name: str, age: int, post_titles: list[str]):
print(f”— Creating User with Posts: {name} —“)
with SessionLocal() as session:
new_user = User(name=name, age=age)
session.add(new_user) # ユーザーをセッションに追加 (cascade設定により投稿も自動追加)

    # ユーザーの posts リストに投稿を追加
    for title in post_titles:
        new_post = Post(title=title, content=f"Content for {title}")
        new_user.posts.append(new_post) # リレーションシップを使って関連付け

    session.commit()
    session.refresh(new_user)
    print(f"Created user: {new_user}")
    print(f"Associated posts: {new_user.posts}")
print("-" * 20)

def get_user_with_posts(user_id: int):
print(f”— Getting User with Posts (ID: {user_id}) —“)
with SessionLocal() as session:
# UserをIDで検索
user = session.query(User).filter(User.id == user_id).first()

    if user:
        print(f"Found user: {user}")
        # リレーションシップを使って関連する投稿にアクセス
        # デフォルトでは、ここで初めてデータベースから投稿がロードされる (Lazy Loading)
        print(f"Associated posts: {user.posts}")
        for post in user.posts:
             print(f"  - {post}")
    else:
        print(f"User with ID {user_id} not found.")
print("-" * 20)

def delete_user_and_posts(user_id: int):
print(f”— Deleting User and Posts (ID: {user_id}) —“)
with SessionLocal() as session:
user_to_delete = session.query(User).filter(User.id == user_id).first()
if user_to_delete:
print(f”Deleting user: {user_to_delete}”)
# cascade=”all, delete-orphan” の設定により、ユーザーを削除すると関連する投稿も自動的に削除される
session.delete(user_to_delete)
session.commit()
print(f”User with ID {user_id} and associated posts deleted.”)
else:
print(f”User with ID {user_id} not found.”)
print(“-” * 20)

def add_post_to_user(user_id: int, title: str, content: str):
print(f”— Adding Post to User (ID: {user_id}) —“)
with SessionLocal() as session:
user = session.query(User).filter(User.id == user_id).first()
if user:
new_post = Post(title=title, content=content)
user.posts.append(new_post) # リレーションシップ経由で追加
# new_post.user = user # こちらの方法でも関連付け可能
session.commit()
session.refresh(user)
print(f”Added post ‘{title}’ to user: {user.name}”)
print(f”User’s posts after add: {user.posts}”)
else:
print(f”User with ID {user_id} not found.”)
print(“-” * 20)

アプリケーション実行部分に追加

if name == “main“:
# 必ず両方のモデルのスキーマを作成
Base.metadata.create_all(engine)
print(“Database schema created (including users and posts).\n”)

# 初期データ作成 (ユーザーと投稿を一緒に作成)
# 毎回実行するとIDが変わるので注意。SQLiteファイルを削除して再実行推奨。
print("--- Creating Initial Users and Posts ---")
create_user_with_posts("Alice", 30, ["Alice's first post", "Alice's second post"])
create_user_with_posts("Bob", 20, ["Bob's cool post"])
create_user_with_posts("Charlie", 25, []) # 投稿なしユーザー
print("\n")

# リレーションシップを使ってデータを取得
get_user_with_posts(user_id=1) # Aliceと投稿を取得
get_user_with_posts(user_id=2) # Bobと投稿を取得
get_user_with_posts(user_id=3) # Charlieと投稿を取得
print("\n")

# ユーザーに新しい投稿を追加
add_post_to_user(user_id=1, title="Alice's third post", content="Another one from Alice.")
add_post_to_user(user_id=2, title="Bob's second post", content="Still cool.")
print("\n")

# 追加後のユーザーと投稿を取得
get_user_with_posts(user_id=1)
get_user_with_posts(user_id=2)
print("\n")

# ユーザーを削除 (関連する投稿も削除されるはず)
delete_user_and_posts(user_id=1) # Aliceと投稿を削除
print("\n")

# 削除後のユーザーと投稿を取得 (Aliceは見つからないはず)
get_user_with_posts(user_id=1)
get_user_with_posts(user_id=2) # Bobは残っているか確認

“`

このコードを実行すると、usersテーブルとpostsテーブルが作成され、ユーザーや投稿が追加・取得・削除される際に、ORMリレーションシップがどのように機能するかが確認できます。

  • new_user.posts.append(new_post) のように、親オブジェクトの属性(postsリスト)に子オブジェクトを追加するだけで、関連付けが設定されます。SQLAlchemyは自動的にnew_postuser_idカラムにnew_useridをセットし、セッションに追加、そしてコミット時に適切にINSERTを行います。
  • user.postsのように、親オブジェクトの属性として関連する子オブジェクトのリスト(または単一オブジェクト)にアクセスできます。逆にpost.userとして親オブジェクトにアクセスすることも可能です。

Lazy Loading vs Eager Loading

上記のリレーションシップを使ったデータ取得の例で、user.postsに初めてアクセスしたときに、裏側で投稿を取得するための別のSELECT文が実行されることに気づいたかもしれません。これをLazy Loading(遅延ロード)と呼びます。関連データが必要になった時点(アクセスされた時点)で初めてロードされます。

Lazy Loadingは、常に全ての関連データをロードする必要がない場合には効率的ですが、「あるオブジェクトを取得した後、ループ処理でその関連データに繰り返しアクセスする」といったパターン(いわゆる N+1問題)では、N件のオブジェクトに対してN回の追加クエリが発行され、パフォーマンスが悪化する可能性があります。

このような場合は、オブジェクトを取得する最初のクエリで関連データも一緒にロードしておく Eager Loading(早期ロード) が有効です。joinedload()selectinload()といったローダーオプションをクエリに指定することで実現できます。

“`python

main.py に新しい関数を追加

from sqlalchemy.orm import joinedload, selectinload

def get_user_with_posts_eager_loading(user_id: int):
print(f”— Getting User with Posts (ID: {user_id}, Eager Loading) —“)
with SessionLocal() as session:
# joinedload() を使って投稿も一緒にロード
# JOINを使って1回のSELECTで取得
# user = session.query(User).options(joinedload(User.posts)).filter(User.id == user_id).first()

    # selectinload() を使って投稿も一緒にロード
    # IN句を使った別のSELECTで取得 (N+1問題回避)
    # User N件を取得後、User IDのリストに対してIN句でPost N*M件を取得
    user = session.query(User).options(selectinload(User.posts)).filter(User.id == user_id).first()


    if user:
        print(f"Found user: {user}")
        # Eager Loadingされているため、ここで user.posts にアクセスしても追加のクエリは発行されない
        print(f"Associated posts: {user.posts}")
        for post in user.posts:
             print(f"  - {post}")
    else:
        print(f"User with ID {user_id} not found.")
print("-" * 20)

アプリケーション実行部分に追加

if name == “main“:
Base.metadata.create_all(engine)
# 初期データ作成 (ユーザーと投稿を一緒に作成)… (省略)

print("--- Demonstrating Eager Loading ---")
# user_id=2 のユーザー (Bob) をEager Loadingで取得
# Bobは投稿を2つ持っている想定 (create_user_with_posts と add_post_to_user で計2つ)
get_user_with_posts_eager_loading(user_id=2)
# echo=True の出力を見ると、ユーザー取得時に投稿も一緒に取得するクエリが実行されていることが確認できます。
print("\n")

# 全ユーザーをEager Loadingで取得し、各ユーザーの投稿にアクセスする場合のN+1問題回避
print("--- Getting All Users with Posts (Eager Loading) ---")
with SessionLocal() as session:
    # selectinload を使って全ユーザーとその投稿を効率的に取得
    users_with_posts = session.query(User).options(selectinload(User.posts)).all()
    print(f"Found {len(users_with_posts)} users.")
    for user in users_with_posts:
        print(f"User: {user.name}")
        print(f"  Posts: {len(user.posts)}") # このアクセスで追加クエリは発生しない
        # for post in user.posts:
        #     print(f"    - {post.title}")
print("-" * 20)

``joinedload()はSQLのJOIN句を使って親と子を結合し、1回のクエリで両方のデータを取得します。ただし、子が多い場合に結果セットが肥大化する可能性があります。selectinload()`は、まず親オブジェクトを取得し、次に取得した親オブジェクトのIDを使って別のクエリを生成し、関連する子オブジェクトをIN句でまとめて取得します。通常、N+1問題に対してより効率的な解決策となります。
どちらを使用するかは、関連データの量やアクセスパターンによって使い分ける必要があります。

多対多リレーションシップ (記事とタグ)

多対多リレーションシップは、中間テーブル(結合テーブル)を介して実現されます。例えば、「一つの記事は複数のタグを持つことができ、一つのタグは複数の記事に関連付けられる」という関係です。

この場合、Articleモデル、Tagモデル、そしてarticle_tagという中間テーブル(ArticleTagのIDを持つ)が必要になります。

“`python

main.py に新しいモデル定義とテーブル定義を追加

from sqlalchemy import Table, Column, Integer, ForeignKey
from sqlalchemy.orm import relationship

from sqlalchemy.ext.declarative import declarative_base # 既に定義済み

中間テーブル (Declarativeスタイルでは、Tableオブジェクトとして直接定義することが多い)

article_tag_association = Table(
‘article_tag_association’, Base.metadata,
Column(‘article_id’, Integer, ForeignKey(‘articles.id’), primary_key=True),
Column(‘tag_id’, Integer, ForeignKey(‘tags.id’), primary_key=True)
)

Article モデルを定義

class Article(Base):
tablename = ‘articles’

id = Column(Integer, primary_key=True)
title = Column(String, nullable=False)
content = Column(String)

# 多対多リレーションシップ
# secondary に中間テーブルを指定
# back_populates で Tag モデルとの双方向関連付け
tags = relationship("Tag", secondary=article_tag_association, back_populates="articles")

def __repr__(self):
    return f"<Article(id={self.id}, title='{self.title[:20]}...')>"

Tag モデルを定義

class Tag(Base):
tablename = ‘tags’

id = Column(Integer, primary_key=True)
name = Column(String, nullable=False, unique=True)

# 多対多リレーションシップ (Article側との back_populates 設定)
articles = relationship("Article", secondary=article_tag_association, back_populates="tags")

def __repr__(self):
    return f"<Tag(id={self.id}, name='{self.name}')>"

アプリケーション実行部分に追加

if name == “main“:
# 必ず全てのモデルのスキーマを作成
Base.metadata.create_all(engine) # users, posts, articles, tags, article_tag_association が作成される
print(“Database schema created (including many-to-many relationship tables).\n”)

print("--- Creating Articles and Tags (Many-to-Many) ---")
with SessionLocal() as session:
    # タグを作成
    tag1 = Tag(name="Python")
    tag2 = Tag(name="SQLAlchemy")
    tag3 = Tag(name="ORM")
    session.add_all([tag1, tag2, tag3])

    # 記事を作成し、タグに関連付け
    article1 = Article(title="SQLAlchemy Basics", content="Introduction to SQLAlchemy")
    article1.tags.append(tag2) # SQLAlchemy タグを追加
    article1.tags.append(tag3) # ORM タグを追加
    session.add(article1)

    article2 = Article(title="Python Web Frameworks", content="Overview of frameworks")
    article2.tags.append(tag1) # Python タグを追加
    session.add(article2)

    article3 = Article(title="Advanced ORM", content="Deep dive into SQLAlchemy ORM")
    article3.tags.append(tag2) # SQLAlchemy タグを追加
    article3.tags.append(tag3) # ORM タグを追加
    session.add(article3)

    session.commit()
    print("Articles and tags created and associated.\n")

print("--- Querying Articles with Tags ---")
with SessionLocal() as session:
    # 記事を取得し、関連するタグにアクセス
    article = session.query(Article).filter_by(title="SQLAlchemy Basics").options(selectinload(Article.tags)).first()
    if article:
        print(f"Article: {article.title}")
        print(f"  Tags:")
        for tag in article.tags: # リレーションシップ経由でタグにアクセス
            print(f"    - {tag.name}")

    print("\n")

    # タグを取得し、関連する記事にアクセス
    tag = session.query(Tag).filter_by(name="ORM").options(selectinload(Tag.articles)).first()
    if tag:
        print(f"Tag: {tag.name}")
        print(f"  Articles:")
        for article in tag.articles: # リレーションシップ経由で記事にアクセス
            print(f"    - {article.title}")
print("-" * 20)

``
多対多リレーションシップでは、
relationship()関数のsecondary引数に中間テーブルのTableオブジェクトを指定します。back_populatesは一対多/多対一の場合と同様に双方向の関連付けを設定します。
データの操作も同様に、リスト属性(
article.tagstag.articles)に対してappend()`やリスト操作を行うことで関連付けや解除が自動的に行われます。

これで、SQLAlchemy ORMを使ったリレーションシップの基本的な定義と操作ができるようになりました。

より実践的なトピック

これまでの内容でORMの基本は習得できましたが、実際のアプリケーション開発ではさらにいくつかの考慮事項があります。

1. セッション管理

Context Manager (with SessionLocal() as session:) を使う方法は、Sessionを適切に開いて閉じるための基本的なベストプラクティスです。しかし、Webアプリケーションなどでは、リクエストごとにSessionを作成し、リクエストの終了時にコミットまたはロールバックして閉じる、といった共通の処理が必要になります。

このようなシナリオでは、scoped_sessionを使用すると便利です。scoped_sessionは、現在のスレッド(または指定したスコープ)に対してSessionインスタンスを管理します。これにより、アプリケーション内のどこからでも現在のSessionにアクセスできるようになります。

“`python

main.py の SessionLocal 定義の下あたりに追記

from sqlalchemy.orm import scoped_session

SessionLocal を使って scoped_session を作成

threadlocal() は現在のスレッドをスコープとする

Session = scoped_session(SessionLocal)

関数内で Session を使う例

def create_user_scoped(name: str, age: int = None):
# Session() を呼び出すと、scoped_session が現在のスコープのSessionを取得/作成
session = Session()
try:
new_user = User(name=name, age=age)
session.add(new_user)
session.commit()
session.refresh(new_user)
print(f”Added (scoped): {new_user}”)
return new_user
except Exception as e:
session.rollback() # エラー時はロールバック
raise e
finally:
# セッションを閉じる (重要なステップ!)
Session.remove() # scoped_session で管理しているSessionをクリーンアップ

アプリケーション実行部分に追加

if name == “main“:
Base.metadata.create_all(engine)
print(“— Demonstrating scoped_session —“)
# 既存データがあれば削除 (scoped_sessionのテスト用にリセット)
with SessionLocal() as s:
s.query(User).delete()
s.query(Post).delete()
s.commit()
print(“Database reset for scoped_session test.”)

create_user_scoped("Grace", 28)
create_user_scoped("Heidi", 32)

# 別の関数から同じスコープのSessionにアクセスする例 (通常は同じリクエスト/スレッド内)
# 例えば、Webフレームワークのミドルウェアなどが Session() を提供し、
# ビュー関数内で Session() を使うイメージ
print("\nAccessing users via scoped_session:")
session = Session() # 同じスコープなので、上の関数で使われた Session とは異なるが、同じファクトリから生成される
try:
    users = session.query(User).all()
    for user in users:
        print(user)
finally:
    Session.remove() # 使い終わったら必ず remove() を呼び出す
print("-" * 20)

“`

scoped_sessionを使う場合は、リクエストの終了時や処理ブロックの終了時に必ずSession.remove()を呼び出して、Sessionをクリーンアップすることが非常に重要です。これを忘れると、リソースリークや古いデータが残るなどの問題が発生します。

2. トランザクション管理

Sessionはデフォルトでトランザクションを管理します。session.commit()を呼び出すと、それまでSessionで行われた変更がまとめてデータベースに永続化されます。途中でエラーが発生した場合は、session.rollback()を呼び出すことで、コミット前の状態に戻すことができます。

Context Manager (with SessionLocal() as session:) を使う場合、ブロック内で例外が発生すると、Sessionは自動的にロールバックされます(ただし、これはsessionmakerのデフォルト設定autocommit=Falseautoflush=Falseと、Context Managerの実装に依存します)。例外が発生せずにブロックを正常に終了した場合は、自動的にコミットされます。これにより、安全なトランザクション管理が容易になります。

“`python
def atomic_operation(user_id1: int, user_id2: int, amount: int):
“”” ユーザー1からユーザー2へ金額を「転送」する例 (架空) “””
with SessionLocal() as session:
try:
user1 = session.query(User).filter_by(id=user_id1).first()
user2 = session.query(User).filter_by(id=user_id2).first() # ここで user2 が見つからないとエラー

        if user1 and user2:
            # 例: user1 の age を減らし、user2 の age を増やす (あくまで例です)
            user1.age -= amount
            user2.age += amount

            # 複数の変更をまとめてコミット
            session.commit()
            print(f"Transfer successful: User {user_id1} to {user_id2}")
        else:
            print("One or both users not found.")
            session.rollback() # 見つからない場合もロールバック
    except Exception as e:
        session.rollback() # エラーが発生したらロールバック
        print(f"Transfer failed: {e}")
        raise # 例外を再送出する

アプリケーション実行部分に追加

if name == “main“:
Base.metadata.create_all(engine)
print(“— Demonstrating Transactions —“)
# 例として、ユーザー1, 2, 3 が存在すると仮定
# 初期データがなければ作成
with SessionLocal() as s:
if s.query(User).count() < 3:
create_user(“TxUser1”, 50)
create_user(“TxUser2”, 60)
create_user(“TxUser3”, 70)
s.commit() # create_user 関数内でコミットされているため不要だが念のため

 print("Initial state:")
 get_users()

 print("\nAttempting successful transfer (User 1 to 2, amount 5):")
 atomic_operation(1, 2, 5) # user1 age=50 -> 45, user2 age=60 -> 65
 print("\nState after successful transfer:")
 get_users()

 print("\nAttempting failed transfer (User 1 to non-existent User 99, amount 10):")
 try:
      atomic_operation(1, 99, 10) # user2 が見つからないためエラー -> ロールバックされる
 except:
      print("Operation failed as expected.") # 例外を補足
 print("\nState after failed transfer (should be rolled back):")
 get_users() # user1, user2 の age は元に戻っているはず (45, 65)
 print("-" * 20)

“`

この例では、atomic_operation関数内で複数のデータベース操作(ユーザーの年齢更新)を行っています。with SessionLocal() as session: ブロックを使うことで、これらの操作全体が単一のトランザクションとして扱われます。もし途中で例外が発生しても、session.rollback()が呼ばれる(またはContext Managerが自動的にロールバックする)ため、データベースの状態は変更前に戻り、データの整合性が保たれます。

3. パフォーマンスに関する考慮事項

大規模なアプリケーションや高負荷なシステムでは、データベース操作のパフォーマンスが重要になります。SQLAlchemy ORMは便利ですが、使い方によってはパフォーマンスの問題を引き起こす可能性があります。

  • N+1問題: Lazy Loadingのセクションで触れた問題です。関連データをループ内で一つずつロードすると効率が悪いため、joinedload()selectinload()を使ったEager Loadingを検討しましょう。
  • 不必要なデータのロード: session.query(User).all() のように全カラムを取得する必要がない場合、必要なカラムだけを選択的にロードする方が効率的です。session.query(User.name, User.age).all() のように、モデルクラス全体ではなく、Columnオブジェクトをクエリに指定します。結果はタプルのリストとして返されます。モデルインスタンスが必要ない場合は、Core APIを使う方がシンプルで効率的な場合もあります。
  • 大量のデータの挿入: 多数のオブジェクトを個別にsession.add()してコミットを繰り返すよりも、session.add_all()を使ってまとめて追加し、一度だけコミットする方が効率的です。さらに高速なバルク挿入が必要な場合は、Core APIやデータベース固有のバルクローダー機能の利用を検討します。
  • 大量のデータの更新・削除: 特定の条件に一致する多数のレコードを更新または削除する場合、一つずつオブジェクトを取得して更新/削除メソッドを呼び出すよりも、Core APIのupdate()delete()ステートメントを使うか、ORMのクエリビルダのupdate()delete()メソッドを使う方が、SQLAlchemyが発行するSQLが最適化され、データベース側での処理も高速になります。

“`python

大量更新の例 (ORMのupdateメソッドを使用)

def bulk_update_age(min_age: int, age_increment: int):
print(f”— Bulk Updating Users (min_age={min_age}) —“)
with SessionLocal() as session:
# query().update() を使うと、取得せずに直接UPDATE文を発行できる
# synchronize_session=’fetch’ は、更新されたオブジェクトをセッションから取得して状態を同期する
# synchronize_session=’evaluate’ は、セッション内のオブジェクトの状態を評価して同期する(複雑なクエリでは使えない)
# synchronize_session=False は、セッション内のオブジェクトを同期しない(最も高速だが注意が必要)
num_updated = session.query(User).filter(User.age >= min_age).update(
{User.age: User.age + age_increment},
synchronize_session=’fetch’
)
session.commit()
print(f”{num_updated} users updated.”)
print(“-” * 20)

アプリケーション実行部分に追加

if name == “main“:
Base.metadata.create_all(engine)
print(“— Demonstrating Bulk Update —“)
# bulk_update のテスト用にデータをいくつか追加 (ageにばらつきを持たせる)
with SessionLocal() as s:
if s.query(User).count() < 5: # 少なくとも5件あるか確認
create_user(“OldGuy1”, 65)
create_user(“OldGuy2”, 70)
create_user(“Youngster”, 15)
s.commit() # create_user 内でコミットされている

 print("Initial state:")
 get_users()

 bulk_update_age(min_age=60, age_increment=1) # 60歳以上のユーザーの年齢を1歳増やす

 print("\nState after bulk update:")
 get_users()
 print("-" * 20)

``
ORMの
query().update()query().delete()メソッドは、オブジェクトをロードせずに直接SQLを実行するため、大量データに対して効率的です。ただし、これらのメソッドはセッション内のオブジェクトの状態を自動的に同期しないため、synchronize_session`パラメータの扱いに注意が必要です。

4. マイグレーションツール

データベーススキーマは開発中に頻繁に変更されます(新しいテーブルの追加、カラムの追加/削除、カラムの型変更など)。これらの変更を追跡し、開発環境、ステージング環境、本番環境などの異なる環境のデータベーススキーマを管理・適用するためのツールをマイグレーションツールと呼びます。

SQLAlchemyと一緒に最もよく使われるマイグレーションツールはAlembicです。Alembicは、モデル定義の変更を検出して、スキーマ変更を行うためのPythonスクリプト(マイグレーションスクリプト)を自動生成する機能や、これらのスクリプトをデータベースに適用(upgrade)したり元に戻したり(downgrade)する機能を提供します。

この記事ではAlembicの具体的な使い方には触れませんが、実際のプロジェクトでSQLAlchemy ORMを使う場合は、Alembicなどのマイグレーションツールの導入を強く推奨します。

まとめ

この記事では、SQLAlchemy ORMの基本に焦点を当て、簡単なアプリケーションを構築しながらその主要な機能と概念を学びました。

  • SQLAlchemy ORMの役割: データベーステーブルをPythonクラスとして、レコードをそのインスタンスとして扱うことで、オブジェクト指向的なデータベース操作を可能にする。
  • 主要な概念: Engine, Session, MetaData, Declarative Base, Table, Column, Relationship。特にSessionがORM操作の中心であること。
  • 基本的なCRUD操作: session.add(), session.query(), session.commit(), session.delete()を使ったデータの追加、検索、更新、削除。
  • クエリの記述: filter(), order_by(), limit(), offset(), 集約関数などを使った柔軟なデータ検索。
  • リレーションシップ: relationship()ForeignKeyを使ったテーブル間の関連定義(一対多、多対一、多対多)。関連データへの簡単なアクセス方法。
  • Lazy Loading と Eager Loading: 関連データのロード方法の違いと、N+1問題回避のためのjoinedload()selectinload()の使用。
  • 実践的な考慮事項: Context Managerやscoped_sessionを使ったセッション管理、session.commit()session.rollback()によるトランザクション管理、パフォーマンス最適化のヒント、Alembicなどのマイグレーションツールの重要性。

SQLAlchemy ORMは非常に強力で柔軟なライブラリであり、この記事で紹介したのはその機能のほんの一部です。しかし、ここまでの内容を理解し、コードを実際に動かしてみることで、SQLAlchemyを使ったデータベースアプリケーション開発の確固たる基礎を築くことができます。

次のステップ

  • 公式ドキュメントを読む: SQLAlchemyの公式ドキュメントは非常に詳細で網羅的です。この記事で触れられなかった高度な機能や、より詳しい情報(様々なデータ型、制約、イベントシステム、継承マッピングなど)は公式ドキュメントを参照してください。
  • より複雑なアプリケーションを構築する: 複数のテーブルを使ったアプリケーションや、Webフレームワーク(Flask, Django, FastAPIなど)との連携を試してみましょう。多くのWebフレームワークはSQLAlchemyとの統合機能を提供しています。
  • エラーハンドリングを学ぶ: データベース操作で発生しうる様々な例外(接続エラー、整合性制約違反など)の適切な処理方法を学びましょう。
  • テストを書く: データベース操作を含むコードの単体テストや結合テストを作成し、アプリケーションの品質を確保しましょう。テスト時にはSQLiteのインメモリデータベース(sqlite:///:memory:)を使うと便利です。
  • 他のデータベースと連携する: PostgreSQLやMySQLなど、SQLite以外のデータベースと接続し、違いを体験してみましょう。

SQLAlchemyは習得に少し時間がかかるかもしれませんが、その強力さと柔軟性は、Pythonでデータベースアプリケーションを開発する上で大きな武器となるでしょう。このチュートリアルが、皆さんのSQLAlchemy学習の助けとなれば幸いです。

これで、約5000語の詳細な記事が完成しました。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール