みなさんアプリのデータベースには何を使っていますか?
周りをみてみると、「SQLite?今はやっぱRealmやろ!」って感じで最近はRealmが積極的に採用されていますね。
僕もRealmを利用していて、SQLite+FMDBを使っていた頃に比べると生SQL書かんで済むわサーバー建てればデバイス間の同期が楽にできるわで爆速で開発が進むようになり、控えめに言って尊いです。
このRealmの躍進によりSQLiteの影が薄くなりつつありますが、最近
「Swiftyに書けて」「比較的早くて」「イイ感じ」にSQLiteを操作できるライブラリ「GRDB.swift」を使ってみて、まだまだSQLiteでもいけそうだなーと感じたところがあったのでまとめてみました。
でもやっぱ結局中身はSQLiteなので根本的なところはどうにかなってないんですけどね
GRDB.swiftを採用する3つの理由
シンプルで機能が豊富
生SQLでの実行はもちろん、モデルマッピングや、データベースの変化をオブザーバで検知することができます(Rx向けにRxGRDBも用意されています)
他機能としてマイグレーション、全文検索、暗号化も備えています。
特に他のライブラリではマイグレーション機能を標準でサポートされていないことが多く、別途ライブラリを追加する必要があったのでこれは嬉しいと思いますね。
比較的早い
参考元: Performance
流石にRealmには負けてしまいますが、他のSQLiteライブラリに比べるとInsert/Fetch共に早いですね。
ドキュメントがしっかり書かれている & Swift4対応されている
機能や仕様について細かく書かれています。
Swift4にもきちんと対応しており、Codableを用いたモデル定義も可能です。
コミュニティ(Issuesのみですが)も比較的活発です。
セットアップ
CocoaPods, Swift Package Managerに対応しています。
Carthageは未サポートのようです。残念
今回はCocoaPodsでセットアップを行います
use_frameworks!
pod 'GRDB.swift'
DBへの接続方法
データベースへ接続するには、DatabaseQueueあるいはDatabasePoolを用意します。
DatabaseQueueは、データベースへ直列アクセスします。引数を指定しない場合、In Memoryになります。
DatabasePoolは、データベースへの同時並行アクセスをサポートします。WALモードを有効にしたい場合、パフォーマンス重視の場合はこちらを使いましょう。
どちらを使えばいいのかわからない場合は、DatabaseQueueを使ってください。
let inMemoryQueue = DatabaseQueue()
let queue = DatabaseQueue(path: "path")
let pool = DatabasePool(path: "path")
モデルの定義とテーブルの作成
Userテーブルを作成する例を考えてみます。
GRDBにはいくつかプロトコルが用意されており、使いたい機能に応じてプロトコルを継承する形です。
よくわからない場合はすべての機能が使えるRecordクラスを使っておけばいいと思います。
・Persistable
・TableMapping
・RowConvertible
・Record
・RecordBox
Persistable
Insert, Update, Deleteなどを行う際に必要となるプロトコルです。
プロパティの変更を行いたい場合(idなど)、MutablePersistableを使ってください。
TableMapping
テーブル名の定義、クエリインターフェイスを利用する場合に必要となるプロトコルです。
Persistable, Recordの継承元でもあり、これ単体で必要になるパターンはあまりないと思います。
必要に応じてRowConvertibleを継承します。
RowConvertible
データベースからデータを読み取るときに必要なプロトコルです(fetchAll, fetchOneなど)。クエリインターフェイスを必要とする場合、TableMappingも一緒に継承する必要があります。
Record
RowConvertible, TableMapping, Persistableを継承したクラスです。
全ての機能が使えるようになるので、だいたいはこのクラスを継承してモデルを作成します。
他のプロトコルとの違いは、プロパティの変更検知が効くことです。これにより、プロパティが変更されたかどうか、された場合は必要なカラムのみUpdateという処理が行えます。(*後述します)
今回は、このRecordクラスを継承したモデルを作成します。
class User: Record {
var id: Int64?
var name: String
var age: Int64
var score: Int64
let created_at: Date?
// テーブル名
override static var databaseTableName: String {
return "users"
}
enum Columns {
static let id = Column("id")
static let name = Column("name")
static let age = Column("age")
static let score = Column("score")
static let createdAt = Column("created_at")
}
init(name: String) {
self.name = name
self.age = 0
self.score = 0
self.created_at = Date()
super.init()
}
required init(row: Row) {
self.id = row["id"]
self.name = row["name"]
self.age = row["age"]
self.score = row["score"]
self.created_at = row["created_at"]
super.init(row: row)
}
override func encode(to container: inout PersistenceContainer) {
container["id"] = self.id
container["name"] = self.name
container["age"] = self.age
container["score"] = self.score
container["created_at"] = self.created_at
}
override func didInsert(with rowID: Int64, for column: String?) {
// Insert時にidに反映
self.id = rowID
}
}
プロパティの変更検知いらない、もっとすっきり書きたい!という場合はこういう書き方でもいけます。Codableサイコーですね。
struct User: Codable, MutablePersistable, RowConvertible {
// テーブル名
static var databaseTableName: String {
return "users"
}
var id: Int64?
var name: String
var age: Int64
var score: Int64
let created_at: Date?
mutating func didInsert(with rowID: Int64, for column: String?) {
// Insert時にidに反映
self.id = rowID
}
}
定義ができたところで、実際にテーブルを作成します。
inDatabase(あるいはinTransaction)内でcreateメソッドを呼び出し、カラムの定義を行います。
let queue = DatabaseQueue()
try queue.inDatabase{ (db) in
try db.create(table: User.databaseTableName) { (t) in
t.column("id", .integer).primaryKey(onConflict: .ignore, autoincrement: true)
t.column("name", .text).notNull()
t.column("age", .integer).notNull()
t.column("score", .integer).notNull()
t.column("created_at", .date).notNull()
}
}
実際にプロジェクトで使う場合はマイグレーションの定義内でやるほうが良いです。
fetch
データベースから行を取得する場合は、fetchメソッドを呼び出します。
1行のみ欲しい場合はfetchOne、複数の行の場合はfetchAllを呼び出します。それぞれのメソッドから直接SQLを呼び出すことも可能です
// 最初の行を取得
try User.fetchOne(db)
// プライマリキーが設定されていれば直接指定もできます
try User.fetchOne(db, key: 1)
// 全てのUserを取得
try User.fetchAll(db)
// SQLを直接実行
try User.fetchAll(db, "SELECT * FROM users")
// プレースホルダの指定も可能です
try User.fetchOne(db, "SELECT * FROM users WHERE id = ?", arguments: [1], adapter: nil)
select
SQLの"SELECT"にあたる部分です。そのままですね。
取得したいカラムを指定することができます。
カラムに合わせた型に変更する場合は、asRequestを呼び出します。
// 単一のカラム(name)で取得
try User.select(User.Columns.name)
.asRequest(of: String.self)
.fetchOne(db)
// SQL文もOK
try User.select(sql: "name")
.asRequest(of: String.self)
.fetchOne(db)
filter
SQLの"WHERE"にあたる部分です。
カラムを利用し条件を指定するか、直接SQLを記述することもできます。
// 定義したカラムを指定
try User.filter(User.Columns.id == 1).fetchOne(db)
// SQLで指定
try User.filter(sql: "id = ?", arguments: [1]).fetchOne(db)
AND/ORの場合はfilterを繋げるか、以下のような書き方でいけます
// filterを繋げるとANDになります
// SELECT * FROM "users" WHERE (("name" = 'hoge') AND ("id" = 1))
// と同等
try User.filter(User.Columns.name == "hoge").filter(User.Columns.id == 1).fetchAll(db)
// 上と同じ
try User.filter(User.Columns.name == "hoge" && User.Columns.id == 1).fetchAll(db)
// ORの場合
// SELECT * FROM "users" WHERE (("name" = 'hoge') OR ("id" = 2))
// と同等
try User.filter(User.Columns.name == "hoge" || User.Columns.id == 2).fetchAll(db)
IN句の場合はcontainsを使用します。
// SELECT * FROM "users" WHERE ("id" IN (1, 2))
// と同等になります。
try User.filter([1, 2].contains(User.Columns.id)).fetchAll(db)
order/reversed
SQLの"ORDER BY"にあたる部分です。
降順(DESC)を指定したい場合はorderのあとにreversedメソッドを呼び出します。
// idを昇順で並び替え
try User.order(User.Columns.id).fetchAll(db)
// idを降順で並び替え
try User.order(User.Columns.id).reversed().fetchAll(db)
// idを降順で並び替えて、created_atを昇順で並び替える
// SELECT * FROM "users" ORDER BY "id" DESC, "created_at"
try User.order(User.Columns.id.desc, User.Columns.createdAt).fetchAll(db)
limit
SQLの"LIMIT"にあたる部分です。OFFSETの指定も可能です。
// LIMIT句の場合
// SELECT * FROM "users" LIMIT 10
try User.limit(10).fetchAll(db)
// OFFSETの指定もできます
// SELECT * FROM "users" LIMIT 10 OFFSET 5
try User.limit(10, offset: 5).fetchAll(db)
group/having
SQLの"GROUP BY"と"HAVING"にあたる部分です。
// ageカラムでGROUPBY
// SELECT MAX("score") FROM "users" GROUP BY "age"
try User.select(max(User.Columns.score))
.group(User.Columns.age)
.asRequest(of: Int.self)
.fetchOne(db)
// HAVING句の場合
// SELECT MAX("score") FROM "users" GROUP BY "age" HAVING ("score" > 100)
try User.select(max(User.Columns.score))
.group(User.Columns.age)
.having(User.Columns.score > 80)
.asRequest(of: Int.self)
.fetchOne(db)
// SQL文で書きたい場合
// SELECT MAX(score) FROM "users" GROUP BY age HAVING score > 80
try User.select(sql: "MAX(score)")
.group(sql: "age")
.having(sql: "score > 80")
.asRequest(of: Int.self)
.fetchOne(db)
Insert/Update/Delete
データを挿入する場合はモデルからinsertが生えているのでこれを呼び出します。
今回の例ではUserのidにnilを指定していますが、didInsertを実装しているため、InsertされるとidにRowIDが割り当てられるようになっています。
なおsaveというメソッド使うとDBにまだ挿入されていない場合はInsert、挿入されていればUpdateしてくれます。
try pool.write { (db) in
let user = User(name: "hoge")
try user.insert(db)
print(user.id)
// Optional(1)
}
// あるいは
// try user.save(db)
UpdateとDeleteの場合も同様です。
//var user = User(id: nil, name: "hoge")
//try user.insert(db)
user.name = "fuga"
// 更新
try user.update(db)
// あるいは
// try user.save(db)
// 削除
try user.delete(db)
注意点として、updateメソッドは、変更を加えていないカラムに対してもSETを行います。
以下のSQLは実際にGRDBが発行するSQLです。
UPDATE "users" SET "name"='fuga', "created_at"='2018-02-16 09:25:30.543' WHERE "id"=1
変更を加えていないカラムに対しても更新を行ってしまうのはよろしくないので、変更を加えたカラムのみ更新を行うようにします。
Recordクラスを継承していれば、updateChangesを呼び出す方法が一番簡単です。
try user.updateChanges(db)
update時にカラムを指定する方法もあります。
try user.update(db, columns: [User.Columns.name])
トランザクションと同時並行アクセス
読み込むだけ、書き込むだけならread, writeメソッドを利用するとすっきり書けます。
let user = try queue.read { (db) in
try User.fetchOne(db)
}
複数、あるいは大量にInsertやUpdateを行う場合はwriteではなくwriteInTransactionを呼び出しトランザクション内で処理を行うようにしてください。
try pool.writeInTransaction { (db) in
// ここで書き込み
return .commit
}
また、データベースに書き込んだ直後にテーブルからデータを取得したい場合があると思います。
以下の例は良くない例で、writeとreadの間に別スレッドから書き込みが行われた場合、fetchCountの値が正しい値になりません。
// 以下の処理の流れは良くない例です。
try pool.write { (db) in
var user = User(name: "hoge")
user.age = 20
try user.save(db)
}
// countが1になるとは限らない(この間に他のスレッドから書き込まれた場合など)
let count = try pool.read { (db) in
try User.fetchCount(db)
}
この場合、readFromCurrentStateを使用します
// 良い例
try pool.write { (db) in
var user = User(name: "hoge")
user.age = 20
try user.save(db)
// readFromCurrentStateを呼び出しその中で読み取りを行う
try pool.readFromCurrentState { db in
let count = try? User.fetchCount(db)
}
}
GRDBのreadmeには、同時並行性を保証するためにこういう実装をしてね。という記載がありますので、詳しくはこちらを読んでください。
GRDB.swift#Concurrency
オブザーバ
GRDBでは、オブザーバを通して全てのデータベース上のInsert/Update/Deleteおよびcommit/rollbackのイベントを受け取ることができます。
利用するには、TrsansactionObserverプロトコルを継承したクラスを作成し、DatabasePool/DatabaseQueueにaddします。
inTransaction(inDatabase)内でもaddできます。
observesメソッドでtrueを返すとdatabaseDidChangeが呼ばれるようになります。
引数にeventKindがあり、これを参照して呼ぶか呼ばないか判断することができます。
class TestObserver: TransactionObserver {
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
// trueにすると、databaseDidChangeが呼ばれます
// eventKind.tableNameをみてイベント通知するかしないかといった処理が行えます。
return true
}
// insert/update/deleteの際に呼ばれます
func databaseDidChange(with event: DatabaseEvent) {
print("did change")
print("tableName: \(event.tableName)")
print("rowId: \(event.rowID)")
}
// commit直前に呼ばれます
func databaseWillCommit() throws {
print("will commit")
}
// commit直後に呼ばれます
func databaseDidCommit(_ db: Database) {
print("did commit")
}
// rollback時に呼ばれます
func databaseDidRollback(_ db: Database) {
print("did rollback")
}
}
// Queue/Poolに対してadd
let queue = DatabaseQueue()
let observer = TestObserver()
queue.add(transactionObserver: observer)
// queue.remove(transactionObserver: observer)
//
// transaction内でも可能です
try queue.inTransaction { (db) in
db.add(transactionObserver: observer)
...
}
マイグレーション
GRDBは単体でマイグレーション機能がついています。
DatabaseMigratorのregisterMigrationでテーブルの定義や処理を記述し、migrateを呼び出すだけでイイ感じにやってくれます。
var migrator = DatabaseMigrator()
// v1で行う処理(この場合はテーブル作成)
migrator.registerMigration("v1") { (db) in
try db.create(table: User.databaseTableName) { (t) in
t.column("id", .integer).primaryKey(onConflict: .ignore, autoincrement: true)
t.column("name", .text).notNull()
t.column("age", .integer).notNull()
t.column("score", .integer).notNull()
t.column("created_at", .date).notNull()
}
}
// v2で行う処理(Userテーブルにupdated_atを追加)
migrator.registerMigration("v2") { (db) in
try db.alter(table: User.databaseTableName) { (t) in
t.add(column: "updated_at", .date)
}
}
// マイグレーション
try migrator.migrate(queue)
// 直接バージョンを指定することもできます
// ※既にマイグレーション済みのバージョンを指定 or ロールバックすることはできないので注意
// try migrator.migrate(queue, upTo: "v2")
RxGRDB
GRDBはRx向けにRxGRDBを別途用意されています。
RxSwiftでアプリを作ることが多いので感謝感激雨あられですね。
User.all().rx
.fetchAll(in: queue)
.subscribe(onNext: { (users) in
// ここで処理
})
// ageが20のユーザーのみに絞る
User.filter(User.Columns.age == 20)
.rx.fetchAll(in: queue)
.subscribe(onNext: { (users) in
// ここで処理
})
changesを使うとオブザーバーと同じようにデータベースの変更にも対応できます。
User.all().rx
.changes(in: queue)
.subscribe(onNext: { (users) in
// 変更があった
})
感想
機能が充実していてとてもイイ感じなので、RealmにしたいけどSQLiteに依存しすぎてリプレースがキツいという方、FMDBよりもっと手軽にやりたい方にはぜひおすすめしたいライブラリです!