axiom-grdb について
axiom-grdbは、SQLiteを扱うSwift開発者向けのGRDB.swiftリファレンススキルです。生SQL、型安全なクエリ、マイグレーション、リアクティブな監視、クエリ分析を扱い、データベースを直接制御する用途に向いています。
主な機能
- 01生SQLと型安全なクエリ
- 02複数テーブルの結合と集計
- 03ValueObservationによるリアクティブクエリ
- 04DatabaseMigratorによるマイグレーション
- 05クエリ計画とパフォーマンス分析
活用シーン
- 01複雑なSQLクエリの作成
- 02データベース変更の監視とUI更新
- 03スキーマとデータのマイグレーション
- 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 回実行された理由を説明できない
やってはいけないこと
- やみくもにインデックスを追加する(どの列が有効なのか分かっていません)
- ロジックを Swift に移す(データベースから早まって逃げています)
- マイグレーションを過剰に複雑化する(システムを信用していません)
やること
database.traceでプロファイリングするEXPLAIN QUERY PLANを使って実行内容を把握する- 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 を参照してください