PySide6 実践講座 #2 SQLite データ層 — リポジトリパターンとマイグレーション

読了 5分

第 1 回で骨組みを立てたので、今回はアプリの記憶を作ります。習慣の一覧と毎日のチェック記録を保存するデータ層です。この層の設計目標はひとつです。SQL をこのフォルダの外に漏らさないこと。 UI のコードのどこにも SQL の文字列が見えない状態を作れば、第 3 回からの画面作業が驚くほど単純になります。

なぜ SQLite なのか、ファイルはどこに置くのか #

ローカルのデスクトップアプリの保存先として、SQLite は事実上のデフォルトです。サーバーが不要で、ファイルひとつが DB 全体で、Python に sqlite3 モジュールが同梱されているので依存関係もありません。JSON ファイル保存と比べると、データが増えても全体を書き直さず、アクセスが絡んだときの破損リスクがずっと低く、第 4 回の統計計算を SQL の集計に任せられるのが決定的な違いです。

ファイルの置き場所は間違えやすいポイントです。ソースフォルダの隣に置くと開発中は楽ですが、配布されたアプリはインストールフォルダへの書き込み権限がないことが多いのです。答えは OS が定めたアプリデータフォルダで、Qt がそのパスを知っています。

src/daily/data/paths.py
# src/daily/data/paths.py
from pathlib import Path

from PySide6.QtCore import QStandardPaths


def db_path() -> Path:
    base = QStandardPaths.writableLocation(
        QStandardPaths.StandardLocation.AppDataLocation
    )
    folder = Path(base)
    folder.mkdir(parents=True, exist_ok=True)
    return folder / "daily.db"

macOS では ~/Library/Application Support/daily/、Windows では AppData の下、Linux では ~/.local/share/ 系に自動で振り分けられます。第 1 回で setApplicationName を先に入れておいたのがここで回収されます。Qt がその名前でフォルダを作ってくれるからです。

スキーマ — テーブル 2 つで十分です #

スキーマ定義
CREATE TABLE habits (
    id         INTEGER PRIMARY KEY,
    name       TEXT NOT NULL,
    created_at TEXT NOT NULL,          -- ISO 日付文字列
    archived   INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE checks (
    habit_id INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
    day      TEXT NOT NULL,            -- 'YYYY-MM-DD'
    PRIMARY KEY (habit_id, day)
);

設計判断を 3 つ挙げておきます。第一に、チェックは「習慣 X を日付 D にやった」という事実そのものなので (habit_id, day) の複合主キーが自然で、同じ日の重複チェックがスキーマのレベルで遮断されます。第二に、習慣の削除は実際の DELETE ではなく archived フラグを使います。記録の統計が過去の習慣に依存するため、ユーザーの「削除」はほとんどの場合「アーカイブ」であるべきです。第三に、日付は ISO 文字列(YYYY-MM-DD)で保存します。SQLite では文字列比較がそのまま日付比較になる形式で、扱いやすいのです。

リポジトリ — SQL のただひとつの住処 #

リポジトリは「保存先に対してできること」をメソッドとして公開するクラスです。呼び出す側は SQL の存在を知りません。

src/daily/data/repository.py
# src/daily/data/repository.py
import sqlite3
from datetime import date
from pathlib import Path

from daily.core.habits import Habit

_SCHEMA_VERSION = 1


class HabitRepository:
    def __init__(self, path: Path) -> None:
        self._conn = sqlite3.connect(path)
        self._conn.execute("PRAGMA foreign_keys = ON")
        self._migrate()

    # --- 習慣 ---
    def add_habit(self, name: str) -> int:
        cur = self._conn.execute(
            "INSERT INTO habits (name, created_at) VALUES (?, ?)",
            (name, date.today().isoformat()),
        )
        self._conn.commit()
        return cur.lastrowid

    def active_habits(self) -> list[Habit]:
        rows = self._conn.execute(
            "SELECT id, name FROM habits WHERE archived = 0 ORDER BY id"
        ).fetchall()
        return [Habit(id=r[0], name=r[1]) for r in rows]

    def archive_habit(self, habit_id: int) -> None:
        self._conn.execute("UPDATE habits SET archived = 1 WHERE id = ?", (habit_id,))
        self._conn.commit()

    # --- チェック ---
    def set_checked(self, habit_id: int, day: date, checked: bool) -> None:
        if checked:
            self._conn.execute(
                "INSERT OR IGNORE INTO checks (habit_id, day) VALUES (?, ?)",
                (habit_id, day.isoformat()),
            )
        else:
            self._conn.execute(
                "DELETE FROM checks WHERE habit_id = ? AND day = ?",
                (habit_id, day.isoformat()),
            )
        self._conn.commit()

    def checked_days(self, habit_id: int) -> set[str]:
        rows = self._conn.execute(
            "SELECT day FROM checks WHERE habit_id = ?", (habit_id,)
        ).fetchall()
        return {r[0] for r in rows}

Habit は core に置く純粋なデータクラスです(@dataclass で id と name だけ)。UI はリポジトリのメソッドを呼ぶだけで、リポジトリは core の型だけを返します。第 1 回で描いた一方向の依存が、コードとして成立する瞬間です。

パラメータバインド(?)だけを使い、文字列フォーマットで SQL を組み立てないのは、SQLAlchemy シリーズで強調したのと同じ原則です。なお、この規模なら ORM なしの sqlite3 直接使用が適正技術です。テーブル 2 つに SQLAlchemy を持ち込むのは過剰です。

マイグレーション — user_version でスキーマのバージョン管理 #

配布されたアプリのデータ層には、Web サーバーにはない義務がひとつあります。ユーザーの端末にすでに存在する旧バージョンの DB ファイルを、壊さずにアップグレードすることです。SQLite にはこの用途にぴったりの PRAGMA user_version(DB ファイルに保存される整数)があります。

src/daily/data/repository.py
    def _migrate(self) -> None:
        version = self._conn.execute("PRAGMA user_version").fetchone()[0]
        if version < 1:
            self._conn.executescript("""
                CREATE TABLE habits (
                    id INTEGER PRIMARY KEY,
                    name TEXT NOT NULL,
                    created_at TEXT NOT NULL,
                    archived INTEGER NOT NULL DEFAULT 0
                );
                CREATE TABLE checks (
                    habit_id INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
                    day TEXT NOT NULL,
                    PRIMARY KEY (habit_id, day)
                );
            """)
        # if version < 2: ...  次のスキーマ変更はここに追加
        self._conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
        self._conn.commit()

動作原理は単純です。新規インストールの DB はバージョン 0 なので全スキーマが作られ、既存ユーザーの DB は保存されたバージョンより大きい段階だけが順に適用されます。守るルールはひとつだけです。リリース後のスキーマ変更は、既存の段階をいじらず、新しい if version < N: ブロックの追加だけで行うこと。 Alembic のミニチュア版であり、ローカルアプリにはこのくらいがちょうどいいのです。

組み立て — アプリへの接続 #

src/daily/app.py
# app.py の main() に追加
from daily.data.paths import db_path
from daily.data.repository import HabitRepository

repo = HabitRepository(db_path())
window = MainWindow(repo)   # MainWindow が repo を受け取り、各画面に配ります

リポジトリをグローバルに置かず、コンストラクタで下ろしていく(依存性注入)のは第 7 回のテストへの伏線です。テストでは一時ファイルのパスで作ったリポジトリを注入すればいいからです。

まとめ #

  • ローカルアプリの保存先は SQLite がデフォルトで、DB ファイルは QStandardPaths が教えてくれるアプリデータフォルダに置きます。
  • スキーマは habits と checks の 2 テーブルです。チェックは複合主キーで重複を遮断し、削除は archived フラグで代替します。
  • SQL はリポジトリの中にだけ存在します。UI はメソッドを呼び、core の型を受け取るだけです。
  • スキーマのバージョンは PRAGMA user_version で管理し、変更は新しいバージョンブロックの追加だけで行います。配布済みアプリのデータはユーザーの財産です。
  • リポジトリはコンストラクタ注入で下ろします。次回はこのデータを今日画面のカスタムモデルにつなぎます。
X