はじめに
RealmSwift
を使うことが多いけど別なOSSや公式のCoreDataならもっと楽に実装できるのでは?
と思ったので今回は以下のフレームワークで
- CoreData
- Realm v5.4.4
- GRDB v4.14.0
- FMDB v2.7.5
CRUD, migrationを実装してみるのと
それぞれで単純なオブジェクトをinsert/Selectした際のベンチマークを取ってみたい思います。
記事で扱うモデルの定義は以下のようにし
struct Publisher {
let id: Int
let name: String
let owner: Owner
let books: [Book]
}
struct Book {
let id: Int
let name: String
let price: Int
}
struct Owner {
let id: Int
let name: String
let age: Int
let profile: String
}
テーブルの定義は以下のようにしました。
- publisher
id | name | owner_id |
---|---|---|
INTEGER | TEXT | INTEGER |
- owner
id | name | age | profile |
---|---|---|---|
INTEGER | TEXT | INTEGER | TEXT |
- book
id | name | price | publisher_id |
---|---|---|---|
INTEGER | TEXT | INTEGER | INTEGER |
このモデルにした理由としては、ORMを扱う記事の多くはシンプルなモデルを扱っているので
1:多と1:1の説明が少なかったのでそのどちらもを含む構造にしたかったからです。
各フレームワークを使用して操作を行うクラスは以下のprotocolに準拠するようにしました。
protocol PublisherStore {
func create(_ publisher: Publisher)
func read() -> [Publisher]
func update(_ publisher: Publisher)
func delete(_ publisher: Publisher)
}
XcodeとCocoaPodsのバージョンは以下
- Xcode v12.0
- CocoaPods v1.9.1
コードはこちらです
CoreData
Apple公式が提供するSQLiteのORMで.xcdatamodel
でモデルを定義し、NSPersistentContainer
のcontextを使用してCRUD操作を行います。
contextはfetchしたオブジェクトのキャッシュや変更などを記録しておりredo
,undo
,rollback
,reset
などの操作を備えています。
テーブルの作成は明示的に行う必要はなく、自動的に作成されます。
Read以外の操作では変更を.sqlite
のファイルに反映するために共通して操作後にcontext.save()
を呼ぶ必要があります。
Create
init(context:)
で作成し、プロパティに値をセットした後にcontext.insert(object:)
で挿入します。
重複させたくない場合は事前にクエリを投げて確認して回避することもできますが、AttributeにConstraintをつけることでユニークにすることができ回避できます。
今回はPublisher,Owner,Bookともに一意なidを持っているのでidに対してConstraintを設定します。
これによってオブジェクトが同じidを持つ場合は挿入時にConflictが発生するようになります。
Conflictは解決方法が設定でき、今回の場合ではNSMergeByPropertyObjectTrumpMergePolicy
を設定しているので上書きされるようになります。
大量の件数を処理したい時はiOS13以上に限定されますがNSBatchInsertRequest
を使用することができます。こちらはrelationshipを持たないentityのみcontextを通さずにDBにinsertすることができ、メモリ消費量や速度の面で優れています。
func create(_ publisher: Publisher) {
let publisherEntity = PublisherEntity(context: context)
publisherEntity.id = Int64(publisher.id)
publisherEntity.name = publisher.name
let ownerEntity = getOrCreateOwner(publisher.owner)
ownerEntity.name = publisher.owner.name
ownerEntity.age = Int32(publisher.owner.age)
ownerEntity.profile = publisher.owner.profile
publisherEntity.owner = ownerEntity
publisher.books.forEach { book in
let bookEntity = BookEntity(context: context)
bookEntity.id = Int64(book.id)
bookEntity.name = book.name
bookEntity.price = Int64(book.price)
publisherEntity.addToBooks(bookEntity)
}
context.insert(publisherEntity)
try? context.save()
}
Read
CoreDataのfetchでは単純に取得する方法と、NSFetchResultController
を使う方法の2種類があります。
前者はその通りな振る舞いに対して後者はdelegateで変更の通知を受け取れたり
indexPathでfetchされたオブジェクトにアクセスできるのでAppleはTableViewとの連携をする際の使用をオススメしています。
またNSFetchedResultsController
はinit時にキャッシュ名を設定するとキャッシュを作成でき、
更新日時を監視して必要に応じてキャッシュから読み込んでくれる機能も付いています。
しかしながら指定できる型はCoreDataで設定したEntityのみに限定されます。
どちらの方法でもベースは同じで、まずはリクエストを作成します。
作成するのは非常に簡単で取得したいタイプでHoge.fetchRequest()
で作成できます。
検索条件や取得数、ソートの設定はリクエストに対して行なっていきます。
検索条件の設定はNSPredicate
で行います。概ねSQLと同じように書けば特に問題なく動作します。
注意点としてはIN
のようにリストで渡す場合はIntやStringのままでいいのですが
単体で渡す場合にはその型に応じてNSNumber
, NSString
, NSDate
などに変換する必要があります。
- 普通にfetchする方法
func read() -> [Publisher] {
let context = container.viewContext
let request: NSFetchRequest<PublisherEntity> = PublisherEntity.fetchRequest()
//検索条件の設定
//request.predicate = .init(format: "id < %@", NSNumber(10))
//request.predicate = .init(format: "id IN %@", [0, 1, 2, 3])
guard let publisherEntities = try? context.fetch(request) else {
return []
}
return publisherEntities.map { publisherEntity -> Publisher in
//ただのmapなので省略
}
}
- NSFetchControllerを使う方法
func read() -> NSFetchController<PublisherEntity> {
let context = container.viewContext
let request: NSFetchRequest<PublisherEntity> = PublisherEntity.fetchReqest()
let controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
try? controller.performFetch()
return controller
}
//fetchした結果にアクセスするには下記でできる
controller.fetchedObjects
//またIndexPathでもアクセスできる
controller.object(at: IndexPath(row: 0, section: 0))
@FetchRequest
SwiftUIを使用している場合では@FetchRequest
のAttributeで自動的にfetchすることができます。
@FetchRequest(
sortDescriptors: [.init(key: "id", ascending: true)],
predicate: .init(format: "id < %@", NSNumber(10)),
animation: .default
)
private var result: FetchedResults<PublisherEntity>
Update
アップデートは既に保存されているオブジェクトのプロパティを変更してcontextをセーブすれば反映されます
relationshipのbooksに関しては差分更新、全部消してinsertし直すなどの様々な更新方法があると思いますが
今回はinsertし直す方法を載せています。他の方法も元のコードには書いています。
NSBatchInsertRequest
のようにNSBatchUpdateRequest
がiOS8以上で使用可能です
func update(_ publisher: Publisher) {
let context = container.viewContext()
//idが同じobjectを1個fetchするようにrequestを作成
let request: NSFetchRequest<PublisherEntity> = PublisherEntity.fetchRequest()
request.predicate = .init("id = @%", id)
request.fetchLimit = 1
guard let publisherEntity = try? context.fetch(request).first else {
return
}
publisherEntity.name = publisher.name
publisherEntity.owner.name = publisher.owner.name
publisherEntity.owner.age = Int32(publisher.owner.age)
publisherEntity.owner.profile = publisher.owner.profile
bookEntities.forEach { bookEntity in
publisherEntity.removeFromBooks(bookEntity)
context.delete(bookEntity)
}
publisher.books.forEach { book in
let bookEntity = BookEntity(context: context)
bookEntity.id = Int64(book.id)
bookEntity.name = book.name
bookEntity.price = Int64(book.price)
context.insert(bookEntity)
publisherEntity.addToBooks(bookEntity)
}
}
Delete
context.delete(object:)
でdeleteすることができます。
Relationshipを持っているオブジェクトを削除した場合は設定してあるDeleteRuleによって挙動が変わります。
- No Action
- 何もしない
- Nullify
- 参照された場合nilになる
- Cascade
- 参照先も削除
- Deny
- 参照先がされている場合は削除されない
func delete(_ publisher: Publisher) {
let context = container.viewContext
let request: NSFetchRequest<PublisherEntity> = PublisherEntity.fetchRequest()
request.fetchLimit = 1
request.predicate = .init(format: "id = %@", NSNumber(value: publisher.id))
guard
let publisherEntity = try? context.fetch(request).first
else {
return
}
context.delete(publisherEntity)
try? context.save()
}
特定のEntityを全て削除したい場合は以下の2通りの方法があります。
例として今回扱っているPublisherEntity
を全て削除する場合は次のコードになります
func deleteAll() {
let context = container.viewContext
//単純に全部fetchしてループで削除する方法
let request: NSFetchRequest<PublisherEntity> = PublisherEntity.fetchRequest()
let publisherEntities = try? context.fetch(request)
publisherEntities?.forEach { publisherEntity in
context.delete(publisherEntity)
}
try? context.save()
//NSBatchDeleteRequestを使う方法(iOS9以上)
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = .init(
entityName: String(describing: PublisherEntity.self)
)
let deletePublisherEntityRequest = NSBatchDeleteRequest(
fetchRequest: fetchRequest
)
try? context.execute(deletePublisherEntityRequest)
}
migration
CoreDataのmigrationにはLightWeightMigrationとHeavyWeightMigrationの2種類があります。
LightWeightMigrationの条件は以下にあたり、コードを書かなくてもcontainerのload時に自動的に行われます。
- Attributeの追加
- Attributeの削除
- AttributeのOptional/非Optionalの変更
- EntityとAttributeの名前の変更
それに対してHeavyWeightMigrationではMappingModelやコードでどのように対応しているかを示す必要があります。
手順としてはフラグを設定、以下のようにFile -> New -> MappingModel
でMappingModelを作成、必要に応じてCustom Policyを設定、といったようになります
まずはcontainerに対して、以下のようにNSPersistentStoreDescription
でLightWeightMigrationを使わないように設定します。
NSPersistentStoreDescription
のデフォルトのurlは/Dev/null
になっており、init時に指定しないとinMemory
の動作になってしまうため注意が必要です。
let path = try! FileManager.default
.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false
)
.appendingPathComponent("<ModuleName>.sqlite")
//もしくは
let path = (container.persistentStoreDescriptions.last?.url!)!
let description = NSPersistentStoreDescription(url: path)
description.shouldInferMappingModelAutomatically = false
description.shouldMigrateStoreAutomatically = true
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _,_ in
//HogeHoge
}
MappingModelは以下の画像のように以降元のモデルから移行先のモデルへの対応づけが記述されています。
しかしながら複雑な処理を行いたい場合はCustom Policyを使う必要があります。
実装としては以下のようにNSEntityMigrationPolicy
のサブクラスを作成します。
final class PublisherMigration: NSEntityMigrationPolicy {
override func createDestinationInstances(
forSource sInstance: NSManagedObject,
in mapping: NSEntityMapping,
manager: NSMigrationManager
) throws {
//Migration
if sInstance.entity.name == "Publisher" {
let context = manager.destinationContext
let publisherName = sInstance.primitiveValue(forKey: "name") as? String
let publisherID = sInstance.primitiveValue(forKey: "id") as? Int
// example
// let newEntity = NSEntityDescription
// .insertNewObject(
// forEntityName: .init(describing: NewEntity.self),
// into: context
// )
// newEntity.setValue(publisherName, forKey: "name")
}
}
}
注意点として、createDestinationInstance
において新しいオブジェクトをCreateの際に使っていた.init(context: )
は使えないので
insertedObject
を使ってオブジェクトを作成する必要があります。
その後、MappingModelの任意のMappingのCustomPolicyに対してclass名をセットすれば完了です。
この時モジュール名をつけるのを忘れないでください。
また、Sourceの欄に何もセットしていない場合、MappingModelが無視されます。
上記で使われているNSEntityMigrationPolicy
にはViewControllerのようにライフサイクルがあり、以下のようになっています。
-
begin
: Migration開始 -
createDestinationInstance
: 移行先のインスタンスの作成 -
endInstanceCreation
: 移行先のインスタンスの作成の完了 -
createRelationship
: relationshipの作成 -
endRelationshipCreation
: relationshipの作成完了 -
performCustomValidation
: 移行先のインスタンスのValidation -
end
: Migration終了
Realm
定番なのであまり説明は要らないと思いますが、モバイル向けに作られた軽量なデータベースです。
コードでモデルの定義がかけることや、パフォーマンスの高さから採用が多いと思います。
###モデルの定義
Object
を継承したClassで定義します。1 : 1の関係を持つ際は必ずOptionalで宣言します。
1 : 多ではList<Object>
を使います。
PrimaryKeyはprimaryKey() -> String?
でプロパティ名を返すことで設定できます。
class PublisherObject: Object {
@objc dynamic var id: Int = -1
@objc dynamic var name: String = ""
@objc dynamic var owner: OwnerObject?
let books = List<BookObject>()
static override func primaryKey() -> String? {
return "id"
}
}
Create
基本的にはCoredataとほとんど同じで、Objectをinitした後に値をセットし
realm.add(object: )
でinsertします。
func create(_ publisher: Publisher) {
try? realm.write {
let newPublisherObject = PublisherObject()
newPublisherObject.id = publisher.id
newPublisherObject.name = publisher.name
let newOwnerObject = OwnerObject()
newOwnerObject.id = owner.id
newOwnerObject.name = owner.name
newOwnerObject.profile = owner.profile
newOwnerObject.age = owner.age
newPublisherObject.owner = newOwnerObject
publisher.books.forEach { book in
let bookObject = BookObject()
bookObject.id = book.id
bookObject.name = book.name
bookObject.price = book.price
newPublisherObject.books.append(bookObject)
}
try? realm.add(newPublisherObject)
}
}
Read
Readに関しては非常に簡単でrealm.objects(objectType: )
でfetchすることができます。
func read() -> [Publisher] {
let publisherObjects = realm.objects(PublisherObject.self)
return publisherObjects.map { publisherObject -> Publisher in
//ただのmapなので省略
}
}
Update
realmでは二つの方法でupdateができます。
一つはトランザクションの中でオブジェクトのプロパティを変更する方法です。
これはCoredataとほとんど変わらずに実装することができます。
もう一つは単純にrealm.add(object: , update: )
を使う方法です。
こちらでは、updateに対して.all
を設定することでadd時に重複した場合は、自動的にupdateしてくれます。
func update(_ publisher: Publisher) {
guard
let publisherObjects = realm.object(
ofType: PublisherObject.self,
forPrimaryKey: publisher.id
)
else {
return
}
try? realm.write {
publisherObject.name = publisher.name
publisherObject.owner?.age = publisher.owner.age
publisherObject.owner?.name = publisher.owner.name
publisherObject.owner?.profile = publisher.owner.profile
realm.delete(publisherObject.books)
publisher.books.forEach { book in
let bookObject = BookObject()
bookObject.id = book.id
bookObject.name = book.name
bookObject.price = book.price
publisherObject.books.append(bookObject)
}
}
}
Delete
Deleteに関してもCoredataと似ていて、Delete対象をfetchしてきて
realm.delete(object: )
で消去することができます。
func delete(_ publisher: Publisher) {
guard let publisherObject = realm.object(
ofType: PublisherObject.self,
forPrimaryKey: publisher.id)
else {
return
}
try? realm.write {
realm.delete(publisherObject)
}
}
特定のObjectを全て消したい時は、一度全てをfetchする必要があります。
func deleteAll() {
realm.delete(realm.objects(PublisherObjects.self))
}
migration
migrationはrealmのinit時に渡すConfigurationで設定します。
(Migration, UInt64)
のクロージャーであれば渡せるのでそのままConfigurationに書くのもいいですし、分割して書くのもいいと思います。
値の変更を伴う処理ではmigration.enumerateObjects(ofType: )
で特定のObjectのmigrationの処理を書くことができます。
このクロージャーの中では、第一引数に以前のオブジェクトが第二引数に新しいオブジェクトが渡されるので、
以下のコードのようにobject!["プロパティ名"]
で値にアクセスして任意の処理を行いましょう。
またプロパティのリネームのみの場合はmigration.renameProperty(onType: , from: , to: )
で変更前のプロパティ名と変更後のプロパティ名を指定するだけで行えます。
schemevesion
は手動で管理しないといけないので気をつけて扱いましょう。
もしデータがなくなってもいい場合は、 deleteRealmIfMigrationNeeded
をtrueにすることでデータを消して新しく作り直してくれます。
let realmConfiguration = Realm.Configuration(
schemaVersion: 1,
migrationBlock: { migration, schemaVersion in
if schemaVersion < 2 {
migration.enumerateObjects(ofType: PublisherObject.className()) { oldObject, newObject in
//let publisherName = oldObject!["name"]
//newObject!["Hoge"] = publisherName + "hogehoge"
}
migration.renameProperty(
onType: PublisherObject.className(),
from: "name",
to: "hoge"
)
}
},
deleteRealmIfMigrationNeeded: false
)
GRDB.swift
2015にリリースされて今も活発に更新されているSQLiteのORMです。
モデルに対してProtocolを準拠させていくだけでCRUDが可能でSwiftらしく書けるのが特徴です。
またドキュメントが豊富なので一通り読んで軽く動かしてみれば、
実戦で使用できる難易度の低さもいい点だと思います。
テスト時やデバッグ時でデータベースをInMemoryにしたい場合はpathの指定なしで
DatabaseQueue()
でinitすることで可能です。
DatabaseへのアクセスはQueue
とPool
の2つが用意されています。
Tableの作成方法
テーブルの作成は以下のように行います。
カラムはカラム名と型を渡して設定します。
デフォルトではnullを許容しているので許容したくない場合は.notNull()
indexの貼り方もコードでわかると思いますが.indexd()
ではれます。
外部キーはreferences(tableName:)
でテーブル名を指定すると設定できます。
下記ではownerIdをownerテーブルのidに対して外部キーを設定しています。
ownerId
がキャメルケースなのは後述しますがCodingKeyを使用して省略している為です。
try? databaseQueue.write { database in
try? database.create(
table: GRDBObject
.Publisher
.databaseTableName,
temporary: false,
ifNotExists: true
) { table in
table.column("id", .integer)
.indexed()
.notNull()
.primaryKey()
table.column("name", .text).notNull()
table.column("ownerId", .integer)
.notNull()
.indexed()
.references(
Owner.databaseTableName
)
}
}
CRUD
CRUDを行うにはモデルがFetchableRecord
に加えてMutablePersistableRecord
かPersistableRecord
のどちらに準拠させる必要があります。
まずは前者のFetchableRecord
について
こちらはその名の通りで準拠したモデルをfetch可能にするprotocolです。
準拠するとDBのカラムからinitするためのinit(row:)
が追加されます。
TableRecord
というPrimaryKeyでの検索を可能にするprotocolも存在しますが、
後述するMutablePersistableRecord
とPersistableRecord
に内包されているのでfetchのみの場合をのぞいて単体で使うことはあまりありません。
次に後者のMutablePersistableRecord
とPersistableRecord
について
これはinsertやdelete,updateといった操作を可能にするためのprotocolで、
準拠するとencode()
というDBへ保存する際にどのプロパティがカラムに対応しているかを設定するメソッドが追加されます。
またDidInsert(rowID: UInt64)
というメソッドも追加され、Insert時にIDが渡されるのでそれを自身のIDにセットすることができます。
使い分けについては
- モデルがstructで
AUTO INCREMENT
な主キーを使いたい ->MutablePersistableRecord
を使用、DidInsertを実装 - モデルがclass or structで主キーは別に設定 ->
PersistableRecord
を使用、DidInsertは実装しない
という使い分けが推奨されています。
FetchableRecord
とTableRecord
はDecodableに対応していればinit(row: )
を
MutablePersistableRecord
とPersistableRecord
はEncodableに対応していればencode()
を省略することができます。
つまりはCodableに対応させると特に処理を書かないでCRUDが可能になります。
このようにして省略した場合はエンコード、デコードどちらでもCodingKeyを使って操作するのでCodingKeyかプロパティ名をスネークケースにしない場合はキャメルケースが使われます。
今回の例ではCodableに準拠させているので、カラム名をキャメルケースにしています。
モデルの定義
以上を踏まえて今回使用するモデルは以下のようになります。
1 : 1の場合はbelongsTo() or hasOne()
、1 : 多の場合はhasMany()
を使用することで関連づけが可能です。
GRDBObjectの下に置いてるのは名前の関係上そうしてるだけなので実際に使う場合は普通に宣言して構わないです。
final class GRDBObject {
struct Publisher: Codable {
let id: Int
let name: String
let ownerId: Int
}
struct Book: Codable {
let id: Int
let name: String
let price: Int
let publisherId: Int
}
}
extension Publisher: FetchableRecord {}
extension Owner: FetchableRecord, PersistableRecord {
static let publishers = hasMany(GRDBObject.Publisher.self)
var publishers: QueryInterfaceRequest<GRDBObject.Publisher> {
request(for: Owner.publishers)
}
}
extension GRDBObject.Book: FetchableRecord, PersistableRecord {
static let publisher = belongsTo(GRDBObject.Publisher.self)
var publisher: QueryInterfaceRequest<GRDBObject.Publisher> {
request(for: Self.publisher)
}
// enum Columns: String, ColumnExpression {
// case id, name, price, publisherId
// }
// CodingKeyを使わずに独自に設定したい場合
// func encode(to container: inout PersistenceContainer) {
// container[Columns.id] = id
// container[Columns.name] = name
// container["price"] = price
// container["publisherId"] = publisherId
// }
// CodingKeyを使わずに独自に設定したい場合
// init(row: Row) {
// id = row["id"]
// name = row["name"]
// price = row["price"]
// publisherId = row["publisherId"]
// }
}
extension GRDBObject.Publisher: FetchableRecord, PersistableRecord {
static let books = hasMany(GRDBObject.Book.self)
static let owner = belongsTo(Owner.self)
var books: QueryInterfaceRequest<GRDBObject.Book> {
request(for: Self.books)
}
var owner:QueryInterfaceRequest<Owner> {
request(for: Self.owner)
}
}
Create
func create(_ publisher: Publisher) {
try? databaseQueue.write { database in
var owner = publisher.owner
try? owner.insert(database)
let publisherObject = GRDBObject.Publisher(
id: publisher.id,
name: publisher.name,
ownerId: publisher.owner.id
)
try? publisherObject.insert(database)
publisher.books.forEach { book in
let bookObject = GRDBObject.Book(
id: book.id,
name: book.name,
price: book.price,
publisherId: publisher.id
)
try? bookObject.insert(database)
}
}
}
Read
func read() -> [Publisher] {
return databaseQueue.read { database in
let books = GRDBObject.Publisher.books.forKey("books")
let request = GRDBObject.Publisher
.including(all: books)
.including(required: GRDBObject.Publisher.owner)
return (try? Publisher.fetchAll(database, request)) ?? []
}
}
books.forKey("books")
について
GRDBはrelationの場合はテーブル名、テーブル名の複数形、テーブル名 + countの3つのパターンでデコードを試みます。
(例: テーブル名がbookならbook
,books
,bookItem
の3つでデコードを試みる)
しかしながら、プロパティ名が違いそのどれもでデコードできない場合はデコードエラーが起きてしまいます。
例えば今回ではFetchableRecordを使用しGRDBObject.Publisher
とGRDBObject.Book
,Owner
からPublisher
をFetchしようとしていますが、
テーブル名にGRDBObject.Book.databaseTableName
を使用しているので、Publisherのbooksに対応していません。
GRDBではこれに対処するために一時的に別なCodingKeyの使用を設定できます。
これが今回使ったbooks.forKey("books")
で、
このようにして一時的に別なkeyを使うことができるので元のモデルを書き変えずにfetchすることが可能です。
Update
プロトコルに準拠するとupdate
とsave
の2つが追加されるのでこれを使用するだけでupdateが可能です。
update
はその通りに値をアップデートし対象がない場合は失敗するのに対して、
save
ではアップデート対象が存在する場合はアップデートを行い無い場合にはcreate
を行うといった違いがあります。
func update(_ publisher: Publisher) {
try? databaseQueue.write { database in
try? publisher.owner.update(database)
publisher.books.forEach { book in
let bookObject = GRDBObject.Book(
id: book.id,
name: book.name,
price: book.price,
publisherId: publisher.id
)
try? bookObject.save(database)
}
try? GRDBObject.Book
.filter(
!publisher.books.map { $0.id }.contains(Column("id")) &&
Column("publisherId") == publisher.id
)
.deleteAll(database)
let publisherObject = GRDBObject.Publisher(
id: publisher.id,
name: publisher.name,
ownerId: publisher.owner.id
)
try? publisherObject.update(database)
}
}
Delete
Deleteに関しては今まで紹介したフレームワークと似ていてObject.delete(database: )
で消去可能です。
また型名.filter()
で様々な条件を設定できるのでfetchせずに下記のようにfilterにidを設定して消去することも可能です。
func delete(_ publisher: Publisher) {
try? databaseQueue.write { database in
try? GRDBObject.Book
.filter(Column("publisherId") == publisher.id)
.deleteAll(database)
try? GRDBObject.Publisher
.filter(Column("id") == publisher.id)
.deleteAll(database)
try? publisher.owner.delete(database)
}
}
テーブル内のデータを全て削除するのはとても簡単で
型名.deleteAll
で実行できます。
func deleteAll() {
try? databaseQueue.write { database in
try? GRDBObject.Book.deleteAll(database)
try? GRDBObject.Publisher.deleteAll(database)
}
}
SQLの実行
パフォーマンスに不満がある場合や自分でSQLを書いて使用したい場合はSELECT
ではRow.fetchCursor(database: , sql: )
で
その他の操作ではexecute(database: ,sql: )
で実行することが可能です。
SELECT
ではRow.fetchCursor
の他にType.fetchAll
も使用することができますが
Rowの特徴としては次のようになっており
- 1回だけ繰り返せる
- メモリの消費量が少ない
- Sequenceと同じような操作はできるがSequenceではない(SQLiteのエラーを投げるため)
- スレッドセーフではない
単純に言えば、パフォーマンスを求めている時はfetchCursor
で求めていない場合はfetchAll
を使うべきと公式は書いています。
次にRowについての説明ですが、RowはDictionaryに似た構造でkeyを渡すことで値にアクセスすることができます。
しかしながらDictionaryとは違いkeyは一意ではありません。これは異なるTableをJOINで結合された場合も想定されているためです。
例えばPublisherテーブルとOwnerテーブルをJOINした場合、どちらもカラムとしてidを持っているのでRowは[id: 0, id:0]
のようになります。
この時にRow["id"]
で値にアクセスした場合には一番最初の値にアクセスすることになります。
またRow[index]
でもアクセスできるので状況に応じて使い分けるようにしましょう。
特定の一列しか取得しない場合は型を決めてfetchすることが可能です。
例えばPublisherのidの列しかfedchしない場合は型がIntであるのがわかり切っているのでInt.fetchAll(database: , sql: )
で取得することができます。
func executeSQL() {
try? databaseQueue.read { database in
let sql = "SELECT * FROM publishers;"
if let rows = try? Row.fetchCursor(database, sql: sql) {
while let row = rows.next() {
let id = row[0]
let name = row["name"]
}
}
}
}
migration
migrationは以下のように非常にシンプルで
migratorに対してmigrationの処理をv1
、v2
とregisterしていき
migrator.migrate(db)
で実行します
var migrator = DatabaseMigrator()
let database = DatabaseQueue("hogehoge")
migrator.registerMigration("v1") { database in
// something do on v1
}
migrator.migration(database)
FMDB
Obj-C時代からあるSQLiteのWrapperで現在は更新頻度が少なく、同様のことがGRDB
でも可能なので採用すべきではないと思います。
他のフレームワークとは違い、薄いWrapperであるためマルチスレッドにおける動作やN+1に対してはエンジニアが頑張って対処する必要があります。
説明することは特になく、SQLiteの文法に従ってSQLを書いてdatabase.executeUpdate(sql: , values: )
で実行するか、
database.executeQuery(sql: )
で取得するかのみです。
注意するとしたらexecuteUpdate(sql: , values: )
の度にトランザクションが実行されるので大量にupdateしたい場合などのクエリを大量に実行する場面では
beginTransaction
を使用して自分でトランザクションを実行することと
FMDatabase
のインスタンスを共有するのではなく、FMDatabaseQueue
かFMDatabasePool
を共有することの2点になります。
例としてDeleteを挙げると以下のようになります。
残りはプロジェクトの方には詳しい実装は書いてあるので気になった方は見ていただければ
func delete(publisher: Publisher) {
let deletePublisherSQL =
"DELETE FROM publishers WHERE id = ?;"
let deleteBookSQL =
"DELETE FROM books WHERE publisher_id = ?;"
fmDatabaseQueue.inDatabase { database in
database.open()
try? database.executeUpdate(
deletePublisherSQL,
values: [
publisher.id
]
)
try? database.executeUpdate(
deleteBookSQL,
values: [
publisher.id
]
)
database.close()
}
}
性能比較
シンプルなオブジェクト、1 : 1の関係を持つオブジェクト、1 : 多の関係を持つオブジェクトをInsert/Readともに1000件でXCTestのmeasureを使用して処理時間の計測し比較しました。
また1 : 多の関係では子のオブジェクトを10個持つように設定しています。
CoreDataはシンプルなオブジェクトではNSBatchInsertRequest(BIR)
が使えるので計測しました。
GRDBはSQLを実行した場合も計測しました。
計測結果は以下のようになりました。
Read
シンプル | 1 : 1 | 1 : 多 | |
---|---|---|---|
CoreData | 1 ms | 1 ms | 2 ms |
GRDB | 3 ms | 15 ms | 121 ms |
GRDB(SQL) | 2ms | 4ms | 30ms |
Realm | 0.022 ms | 0.024 ms | 0.025 ms |
FMDB | 1 ms | 3 ms | 22 ms |
Insert
シンプル | 1 : 1 | 1 : 多 | |
---|---|---|---|
CoreData | 15 ms (BIR: 14ms) | 30 ms | 202 ms |
GRDB | 30 ms | 152 ms | 872 ms |
GRDB(SQL) | 5ms | 31ms | 378ms |
Realm | 13 ms | 30 ms | 158 ms |
FMDB | 6 ms | 11 ms | 56ms |
終わりに
今回は様々なORMを比較してみました。
この記事が何かの役に立てれば幸いです。