mcpField
A
axiom-grdb

axiom-grdb

github.com/charleswiltgenデータ・データベース1.2k180 回インストール今日更新
リポジトリ

axiom-grdb について

axiom-grdbは、SQLiteを扱うSwift開発者向けのGRDB.swiftリファレンススキルです。生SQL、型安全なクエリ、マイグレーション、リアクティブな監視、クエリ分析を扱い、データベースを直接制御する用途に向いています。

主な機能

  1. 01生SQLと型安全なクエリ
  2. 02複数テーブルの結合と集計
  3. 03ValueObservationによるリアクティブクエリ
  4. 04DatabaseMigratorによるマイグレーション
  5. 05クエリ計画とパフォーマンス分析

活用シーン

  1. 01複雑なSQLクエリの作成
  2. 02データベース変更の監視とUI更新
  3. 03スキーマとデータのマイグレーション
  4. 04遅いクエリの分析とインデックス調整

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-grdb

インストール後、Claude Code で/axiom-grdbを入力して読み込まれたか確認します。

詳細

GRDB

概要

GRDB.swift を使用した SQLite への直接アクセスです。型安全なクエリ、マイグレーション、リアクティブな監視に対応する SQLite データベース用ツールキットです。

基本原則 必要に応じて SQLite の機能をフルに活用できる、生の SQL を扱う型安全な Swift ラッパーです。

必要要件 iOS 13 以降、Swift 5.7 以降 ライセンス MIT(無料のオープンソース)

GRDB を使う場面

生の GRDB を使う場面
  • ✅ 4 つ以上のテーブルにまたがる複雑な SQL JOIN
  • ✅ ウィンドウ関数(ROW_NUMBER、RANK、LAG/LEAD)
  • ✅ ValueObservation を使ったリアクティブなクエリ
  • ✅ パフォーマンス向上のために SQL を完全に制御したい場合
  • ✅ スキーマ変更を超える高度なマイグレーションロジック

注記: SQLiteData はクエリビルダーで GROUP BY(.group(by:))と HAVING(.having())にも対応するようになりました。詳しくは axiom-sqlitedata-ref スキルをご覧ください。

代わりに SQLiteData を使う場面
  • 型安全な @Table モデルで十分な場合
  • CloudKit との同期が必要な場合
  • SQL より宣言的なクエリを好む場合
SwiftData を使う場面
  • Apple のネイティブ統合を利用したシンプルな CRUD
  • 生の SQL を制御する必要がない場合

マイグレーションについて 安全なスキーマ進化のパターンについては、axiom-database-migration スキルをご覧ください。

プロンプトの例

以下は、開発者から実際に寄せられる質問で、このスキルが回答できるよう設計されています。

1. 「メッセージとその作成者、リアクション数を 1 つのクエリで取得したいです。JOIN はどう書けばよいですか?」

→ このスキルでは、複数のテーブルや集計を含む複雑な JOIN クエリを紹介します。

2. 「フィルタリングしたリストを監視し、特定のタグが付いたノートが変更されるたびに UI を更新したいです。」

→ このスキルでは、リアクティブなクエリ更新のための ValueObservation パターンを扱います。

3. 「数千件のチャットレコードをインポートしており、独自のマイグレーションロジックが必要です。DatabaseMigrator の使い方を教えてください。」

→ このスキルでは、マイグレーションの登録、データ変換、安全なロールバックのパターンを説明します。

4. 「クエリが遅く、完了までに 10 秒以上かかります。プロファイリングして最適化するにはどうすればよいですか?」

→ このスキルでは、EXPLAIN QUERY PLAN、プロファイリング用の database.trace、インデックスの作成を扱います。

5. 「期日ごとにタスクをグループ化し、完了数を付けて優先度順に取得したいです。型安全なクエリより生の SQL のほうが簡単そうです。」

→ このスキルでは、型安全なラッパーよりも GRDB の生の SQL のほうが明確になる場面を紹介します。


データベースのセットアップ

DatabaseQueue(単一接続)
import GRDB

// File-based database
let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
let dbQueue = try DatabaseQueue(path: "\(dbPath)/db.sqlite")

// In-memory database (tests)
let dbQueue = try DatabaseQueue()
DatabasePool(接続プール)
// For apps with heavy concurrent access
let dbPool = try DatabasePool(path: dbPath)

Queue を使う場面 ほとんどのアプリ(シンプルで十分) Pool を使う場面 複数スレッドからの同時書き込みが多い場合

レコード型

Codable を使う
struct Track: Codable {
    var id: String
    var title: String
    var artist: String
    var duration: TimeInterval
}

// Fetch
let tracks = try dbQueue.read { db in
    try Track.fetchAll(db, sql: "SELECT * FROM tracks")
}

// Insert
try dbQueue.write { db in
    try track.insert(db)  // Codable conformance provides insert
}
FetchableRecord(読み取り専用)
struct TrackInfo: FetchableRecord {
    var title: String
    var artist: String
    var albumTitle: String

    init(row: Row) {
        title = row["title"]
        artist = row["artist"]
        albumTitle = row["album_title"]
    }
}

let results = try dbQueue.read { db in
    try TrackInfo.fetchAll(db, sql: """
        SELECT tracks.title, tracks.artist, albums.title as album_title
        FROM tracks
        JOIN albums ON tracks.albumId = albums.id
        """)
}
PersistableRecord(書き込み)
struct Track: Codable, PersistableRecord {
    var id: String
    var title: String

    // Customize table name
    static let databaseTableName = "tracks"
}

try dbQueue.write { db in
    var track = Track(id: "1", title: "Song")
    try track.insert(db)

    track.title = "Updated"
    try track.update(db)

    try track.delete(db)
}

生の SQL クエリ

データの読み取り
// Fetch all rows
let rows = try dbQueue.read { db in
    try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])
}

// Fetch single value
let count = try dbQueue.read { db in
    try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tracks")
}

// Fetch into Codable
let tracks = try dbQueue.read { db in
    try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY title")
}
データの書き込み
try dbQueue.write { db in
    try db.execute(sql: """
        INSERT INTO tracks (id, title, artist, duration)
        VALUES (?, ?, ?, ?)
        """, arguments: ["1", "Song", "Artist", 240])
}
トランザクション
try dbQueue.write { db in
    // Automatic transaction - all or nothing
    for track in tracks {
        try track.insert(db)
    }
    // Commits automatically on success, rolls back on error
}

型安全なクエリインターフェイス

フィルタリング
let request = Track
    .filter(Column("genre") == "Rock")
    .filter(Column("duration") > 180)

let tracks = try dbQueue.read { db in
    try request.fetchAll(db)
}
並べ替え
let request = Track
    .order(Column("title").asc)
    .limit(10)
JOIN
struct TrackWithAlbum: FetchableRecord {
    var trackTitle: String
    var albumTitle: String
}

let request = Track
    .joining(required: Track.belongsTo(Album.self))
    .select(Column("title").forKey("trackTitle"), Column("album_title").forKey("albumTitle"))

let results = try dbQueue.read { db in
    try TrackWithAlbum.fetchAll(db, request)
}

複雑な JOIN

let sql = """
    SELECT
        tracks.title as track_title,
        albums.title as album_title,
        artists.name as artist_name,
        COUNT(plays.id) as play_count
    FROM tracks
    JOIN albums ON tracks.albumId = albums.id
    JOIN artists ON albums.artistId = artists.id
    LEFT JOIN plays ON plays.trackId = tracks.id
    WHERE artists.genre = ?
    GROUP BY tracks.id
    HAVING play_count > 10
    ORDER BY play_count DESC
    LIMIT 50
    """

struct TrackStats: FetchableRecord {
    var trackTitle: String
    var albumTitle: String
    var artistName: String
    var playCount: Int

    init(row: Row) {
        trackTitle = row["track_title"]
        albumTitle = row["album_title"]
        artistName = row["artist_name"]
        playCount = row["play_count"]
    }
}

let stats = try dbQueue.read { db in
    try TrackStats.fetchAll(db, sql: sql, arguments: ["Rock"])
}

ValueObservation(リアクティブなクエリ)

基本的な監視
import GRDB
import Combine

let observation = ValueObservation.tracking { db in
    try Track.fetchAll(db)
}

// Start observing with Combine
let cancellable = observation.publisher(in: dbQueue)
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { tracks in
            print("Tracks updated: \(tracks.count)")
        }
    )
SwiftUI との統合
import GRDB
import GRDBQuery  // https://github.com/groue/GRDBQuery

@Query(Tracks())
var tracks: [Track]

struct Tracks: Queryable {
    static var defaultValue: [Track] { [] }

    func publisher(in dbQueue: DatabaseQueue) -> AnyPublisher<[Track], Error> {
        ValueObservation
            .tracking { db in try Track.fetchAll(db) }
            .publisher(in: dbQueue)
            .eraseToAnyPublisher()
    }
}

参照 SwiftUI のリアクティブバインディングについては、GRDBQuery のドキュメントをご覧ください。

フィルタリングした監視
func observeGenre(_ genre: String) -> ValueObservation<[Track]> {
    ValueObservation.tracking { db in
        try Track
            .filter(Column("genre") == genre)
            .fetchAll(db)
    }
}

let cancellable = observeGenre("Rock")
    .publisher(in: dbQueue)
    .sink { tracks in
        print("Rock tracks: \(tracks.count)")
    }

マイグレーション

DatabaseMigrator
var migrator = DatabaseMigrator()

// Migration 1: Create tables
migrator.registerMigration("v1") { db in
    try db.create(table: "tracks") { t in
        t.column("id", .text).primaryKey()
        t.column("title", .text).notNull()
        t.column("artist", .text).notNull()
        t.column("duration", .real).notNull()
    }
}

// Migration 2: Add column
migrator.registerMigration("v2_add_genre") { db in
    try db.alter(table: "tracks") { t in
        t.add(column: "genre", .text)
    }
}

// Migration 3: Add index
migrator.registerMigration("v3_add_indexes") { db in
    try db.create(index: "idx_genre", on: "tracks", columns: ["genre"])
}

// Run migrations
try migrator.migrate(dbQueue)

マイグレーションの安全性に関するパターンについて axiom-database-migration スキルをご覧ください。

データ変換を伴うマイグレーション
migrator.registerMigration("v4_normalize_artists") { db in
    // 1. Create new table
    try db.create(table: "artists") { t in
        t.column("id", .text).primaryKey()
        t.column("name", .text).notNull()
    }

    // 2. Extract unique artists
    try db.execute(sql: """
        INSERT INTO artists (id, name)
        SELECT DISTINCT
            lower(replace(artist, ' ', '_')) as id,
            artist as name
        FROM tracks
        """)

    // 3. Add foreign key to tracks
    try db.alter(table: "tracks") { t in
        t.add(column: "artistId", .text)
            .references("artists", onDelete: .cascade)
    }

    // 4. Populate foreign keys
    try db.execute(sql: """
        UPDATE tracks
        SET artistId = (
            SELECT id FROM artists
            WHERE artists.name = tracks.artist
        )
        """)
}

パフォーマンスのパターン

バッチ書き込み
try dbQueue.write { db in
    for batch in tracks.chunked(into: 500) {
        for track in batch {
            try track.insert(db)
        }
    }
}
プリペアドステートメント
try dbQueue.write { db in
    let statement = try db.makeStatement(sql: """
        INSERT INTO tracks (id, title, artist, duration)
        VALUES (?, ?, ?, ?)
        """)

    for track in tracks {
        try statement.execute(arguments: [track.id, track.title, track.artist, track.duration])
    }
}
インデックス
try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"])
try db.create(index: "idx_tracks_genre_duration", on: "tracks", columns: ["genre", "duration"])

// Unique index
try db.create(index: "idx_tracks_unique_title", on: "tracks", columns: ["title"], unique: true)
クエリプランニング
// Analyze query performance
let explanation = try dbQueue.read { db in
    try String.fetchOne(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM tracks WHERE artist = ?", arguments: ["Artist"])
}
print(explanation)

SQLiteData から GRDB に切り替える

SQLiteData を使っていて、特定の操作で GRDB が必要な場合:

import SQLiteData
import GRDB

@Dependency(\.database) var database  // SQLiteData Database

// Access underlying GRDB DatabaseQueue
try await database.database.write { db in
    // Full GRDB power here
    try db.execute(sql: "CREATE INDEX idx_genre ON tracks(genre)")
}
よくあるケース
  • 複雑な JOIN クエリ
  • 独自のマイグレーション
  • 大量の SQL 操作
  • ValueObservation のセットアップ

クイックリファレンス

よく使う操作
// Read single value
let count = try db.fetchOne(Int.self, sql: "SELECT COUNT(*) FROM tracks")

// Read all rows
let rows = try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"])

// Write
try db.execute(sql: "INSERT INTO tracks VALUES (?, ?, ?)", arguments: [id, title, artist])

// Transaction
try dbQueue.write { db in
    // All or nothing
}

// Observe changes
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.publisher(in: dbQueue)

リソース

GitHub:groue/GRDB.swift、groue/GRDBQuery

ドキュメント:sqlite.org/docs.html

スキル:axiom-database-migration、axiom-sqlitedata、axiom-swiftdata

本番環境でのパフォーマンス:負荷が高い状況でのクエリ最適化

赤信号 — GRDB クエリが遅くなるとき

次の症状が 1 つでも見られる場合:

  • ❌ 複雑な JOIN クエリに 10 秒以上かかる
  • ❌ ValueObservation が変更のたびに実行される(バッテリーを消耗する)
  • ❌ 古いバージョンでマイグレーションが 2 回実行された理由を説明できない
やってはいけないこと
  1. やみくもにインデックスを追加する(どの列が有効なのか分かっていません)
  2. ロジックを Swift に移す(データベースから早まって逃げています)
  3. マイグレーションを過剰に複雑化する(システムを信用していません)
やること
  1. database.trace でプロファイリングする
  2. EXPLAIN QUERY PLAN を使って実行内容を把握する
  3. GRDB のマイグレーションバージョン管理システムを信頼する
複雑なクエリのプロファイリング
クエリが遅い場合(10 秒以上)
var database = try DatabaseQueue(path: dbPath)

// Enable tracing to see SQL execution
database.trace { print($0) }

// Run the slow query
try database.read { db in
    let results = try Track.fetchAll(db)  // Watch output for execution time
}

// Use EXPLAIN QUERY PLAN to understand execution:
try database.read { db in
    let plan = try String(fetching: db, sql: "EXPLAIN QUERY PLAN SELECT ...")
    print(plan)
    // Look for SCAN (slow, full table) vs SEARCH (fast, indexed)
}
インデックスを戦略的に追加する
// Add index on frequently queried column
try database.write { db in
    try db.execute(sql: "CREATE INDEX idx_plays_track_id ON plays(track_id)")
}
所要時間
  • プロファイリング:10 分(トレースを有効にし、クエリを実行して、出力を確認)
  • 理解:5 分(EXPLAIN QUERY PLAN の結果を解釈)
  • 修正:5 分(インデックスを追加)
  • 合計:20 分(やみくもに解決策を試す場合の 30 分以上と比べて)
ValueObservation のパフォーマンス
リアクティブクエリを使う場合は、コストを把握する
// Re-evaluates query on ANY write to database
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.start(in: database, onError: { }, onChange: { tracks in
    // Called for every change — CPU spike!
})
最適化パターン
// Coalesce rapid updates (recommended)
ValueObservation.tracking { db in
    try Track.fetchAll(db)
}.removeDuplicates()  // Skip duplicate results
 .debounce(for: 0.5, scheduler: DispatchQueue.main)  // Batch updates
 .start(in: database, ...)
判断の指針
  • 小規模なデータセット(1000 レコード未満):通常の .tracking を使用
  • 中規模なデータセット(1~10k レコード):.removeDuplicates() と .debounce() を追加
  • 大規模なデータセット(10k レコード以上):テーブル依存関係または述語を明示的に指定
マイグレーションのバージョン管理に関する保証
GRDB の DatabaseMigrator を信頼してください。マイグレーションの再実行を防ぎます
var migrator = DatabaseMigrator()

migrator.registerMigration("v1_initial") { db in
    try db.execute(sql: "CREATE TABLE tracks (...)")
}

migrator.registerMigration("v2_add_plays") { db in
    try db.execute(sql: "CREATE TABLE plays (...)")
}

// GRDB guarantees:
// - Each migration runs exactly ONCE
// - In order (v1, then v2)
// - Safe to call migrate() multiple times
try migrator.migrate(dbQueue)
防御的な SQL(IF NOT EXISTS)は不要です
  • GRDB は実行済みのマイグレーションを追跡します
  • migrate() を 2 回実行しても、新しいマイグレーションのみが実行されます
  • 過剰な複雑化はメリットなしに複雑さを増すだけです
信頼してください。

よくある間違い

❌ 一括書き込みにトランザクションを使わない
for track in 50000Tracks {
    try dbQueue.write { db in try track.insert(db) }  // 50k transactions!
}

修正方法: バッチを単一のトランザクションにまとめます

❌ メインスレッドで同期的にデータベースへアクセスする
let tracks = try dbQueue.read { db in try Track.fetchAll(db) }  // Blocks UI

修正方法: async/await を使うか、バックグラウンドキューに処理をディスパッチします

❌ インデックスの追加を忘れる
// Slow query without index
try Track.filter(Column("genre") == "Rock").fetchAll(db)

修正方法: 頻繁にクエリされる列にインデックスを作成します

❌ N+1 クエリ
for track in tracks {
    let album = try Album.fetchOne(db, key: track.albumId)  // N queries!
}

修正方法: JOIN または一括取得を使います

tvOS

tvOS では、ローカルの GRDB データベースは永続化されません。 ストレージの空き容量が不足すると、システムは Caches(Application Support を含む)を削除します。ローカルのみの GRDB データベースでは、アプリを起動するたびにすべてのデータが失われます。

tvOS を対象にする場合は、 GRDB を CloudKit 同期(CKSyncEngine または SQLiteData の SyncEngine 経由)と組み合わせてください。これにより、iCloud が永続ストアとなり、ローカルデータベースは再構築可能なキャッシュになります。tvOS のストレージに関する制約の詳細は axiom-tvos を参照してください。


対象: iOS 13 以降、Swift 5.7 以降 フレームワーク: GRDB.swift 6.0 以降 履歴: 変更内容は git log を参照してください

コメント