LoginSignup
8
2

More than 3 years have passed since last update.

iOSのORMの比較

Last updated at Posted at 2021-01-17

はじめに

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へのアクセスはQueuePoolの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に加えてMutablePersistableRecordPersistableRecordのどちらに準拠させる必要があります。

まずは前者のFetchableRecordについて

こちらはその名の通りで準拠したモデルをfetch可能にするprotocolです。
準拠するとDBのカラムからinitするためのinit(row:)が追加されます。

TableRecordというPrimaryKeyでの検索を可能にするprotocolも存在しますが、
後述するMutablePersistableRecordPersistableRecordに内包されているのでfetchのみの場合をのぞいて単体で使うことはあまりありません。

次に後者のMutablePersistableRecordPersistableRecordについて

これはinsertやdelete,updateといった操作を可能にするためのprotocolで、
準拠するとencode()というDBへ保存する際にどのプロパティがカラムに対応しているかを設定するメソッドが追加されます。
またDidInsert(rowID: UInt64)というメソッドも追加され、Insert時にIDが渡されるのでそれを自身のIDにセットすることができます。

使い分けについては

  • モデルがstructでAUTO INCREMENTな主キーを使いたい -> MutablePersistableRecordを使用、DidInsertを実装
  • モデルがclass or structで主キーは別に設定 -> PersistableRecordを使用、DidInsertは実装しない

という使い分けが推奨されています。

FetchableRecordTableRecordはDecodableに対応していればinit(row: )
MutablePersistableRecordPersistableRecordは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.PublisherGRDBObject.Book,OwnerからPublisherをFetchしようとしていますが、
テーブル名にGRDBObject.Book.databaseTableNameを使用しているので、Publisherのbooksに対応していません。
GRDBではこれに対処するために一時的に別なCodingKeyの使用を設定できます。
これが今回使ったbooks.forKey("books")で、
このようにして一時的に別なkeyを使うことができるので元のモデルを書き変えずにfetchすることが可能です。

Update

プロトコルに準拠するとupdatesaveの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の処理をv1v2と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のインスタンスを共有するのではなく、FMDatabaseQueueFMDatabasePoolを共有することの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を比較してみました。
この記事が何かの役に立てれば幸いです。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2