GRDB.swift as a Solution for iOS Database
When targeting Apple operating systems the most natural choice seems to be Core Data. Designed by Apple in California. Nevertheless I am not the only one who has mixed feelings about it.
Why not Core Data?
Mobile Core Data appeared in 2009 and for around five years was the one and only solution designed especially for iOS. For many years there was no major changes that would make the framework easier and more friendly. Fortunately, since iOS 10 creation a stack is easier. More and more code is generated automatically, which is great, but… It's still Core Data. It's quite fast, it's quite nice, but still – it's not the fastest, not the simplest solution.
What about GRDB.swift?
It's fast and powerful, but it's not easy. If you're looking for an easy database framework, there is only one choice – Realm. It's stupidly easy, especially if you do not want to fight with threads.
But right now the concurrency between mobile databases libraries is quite strong. GRDB.swift may not be the most beloved one (it has ten times less GitHub stars than a closest competitor – FMDB), but it's written in Swift, 100% open source and extremely powerful, it even allows executing raw SQL commands!
It's based on SQLite, compatible with Swift 4.0 / Xcode 9+ and works on iOS, watchOS and macOS.
Creating a database
That may be weird, but even as simple thing as opening a connection to a database is not obvious in GRDB. It's simpler than in Core Data, but requires a while to think about it.
// Simple database connection
let dbQueue = try DatabaseQueue(path: "/path/to/database.sqlite")
// Enhanced multithreading based on SQLite's WAL mode
let dbPool = try DatabasePool(path: "/path/to/database.sqlite")
As you can see there are two ways to open a connection. In fact they return two different objects as well. Queues are simpler, but pools uses Write-Ahead Logging mode, provide better performance and – the most important – do not block UI. In the example app I use a queue, but it's easy to change it to a pool later.
A important thing is that you should instantiate a single database queue or pool for each database file, and make it available to controllers (single database object to multiple controllers approach).
It may look this way in iOS:
private func setupDatabase(in application: UIApplication) {
let databaseURL = try! FileManager.default
.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appendingPathComponent("db.sqlite")
dbQueue = try! Database.openDatabase(atPath: databaseURL.path)
dbQueue.setupMemoryManagement(in: application)
}
Then this function can be easily called from UIApplicationDelegate.application(_:didFinishLaunchingWithOptions:)
.
I believe that two bottom lines of the body requires an explanation:
Database
type is a kind of helper and is explained in the very next chapter.setupMemoryManagement(in:)
allows GRDB to release memory when the app enters background or receives memory warning. We should definitely call this for all opened connections.
Migrators and initial data
That's how my Database
struct looks like:
struct Database {
static func openDatabase(atPath path: String) throws -> DatabaseQueue {
dbQueue = try DatabaseQueue(path: path)
try migrator.migrate(dbQueue)
return dbQueue
}
static var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("createDevelopers") { database in
try database.create(table: Developer.databaseTableName) { tableDefinition in
tableDefinition.column("id", .integer).primaryKey()
tableDefinition.column("appId", .integer).references(App.databaseTableName, column: "id", onDelete: .setNull)
tableDefinition.column("name", .text).notNull().collate(.localizedCaseInsensitiveCompare)
}
}
migrator.registerMigration("createApps") { database in
try database.create(table: App.databaseTableName) { tableDefinition in
tableDefinition.column("id", .integer).primaryKey()
tableDefinition.column("name", .text).notNull().collate(.localizedCaseInsensitiveCompare)
}
}
return migrator
}
}
The static function is self-explanatory. It creates a database queue, then executes migration and returns the queue (or throw an error, if not so lucky).
But what is going on with a migrator? Simply, it is an object that allows migrating! Something we need when our database models change or when we prepare our database for the first time. As you may noticed I create a simple app that allows adding developers and apps, and then create a relationship between an app and a developer(s) working on it. Project Managers are going to love it!
Recorded GIF from the example app
Each registered migration is executed only once, that's why it has to be named uniquely (i.e. createDevelopers
, createApps
).
As you can see in create(table:)
I use static properties from object models. I am going to explain them in the next chapter.
Then in the closures I define columns, types, primary keys, nullability and collation for sorting. There is also a one reference that allows easily connect an app with a developer and it automatically sets a Developer
's appId
to nil
on an App
delete action.
That may be a right moment to show true GRDB power. The second migration's table creation if you want may look like this:
try dbQueue.inDatabase { db in
try db.execute("""
CREATE TABLE apps (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL)
""")
}
But I am still not sure if that means powerful or cursed. As far as I know if you want a many-to-many relationship in GRDB you have no other choice than use raw SQL statements and that is not user-friendly.
Models
I am going to present only Developer
class model, because App
is pretty much the same.
final class Developer: Record, TextRepresentable {
var id: Int64?
var name: String
var appId: Int64?
var app: QueryInterfaceRequest<App> {
return App.filter(Column("id") == appId)
}
override class var databaseTableName: String {
return "developers"
}
init(name: String) {
self.name = name
self.appId = nil
super.init()
}
required init(row: Row) {
id = row["id"]
name = row["name"]
appId = row["appId"]
super.init(row: row)
}
override func encode(to container: inout PersistenceContainer) {
container["id"] = id
container["name"] = name
container["appId"] = appId
}
override func didInsert(with rowID: Int64, for column: String?) {
id = rowID
}
}
First of all: each record class has to inherit from Record
. Properties id
, name
, and appId
let store record data. Then you can see QueryInterfaceRequest
type object that allows easily fetch an app that the developer is working on. Next is an overriden variable with table name – the same I used in migrator.
Then you can see my custom initializer with name – this one is self-explanatory. Second initializer and encode(to:)
are similar to NSCoding
protocol functions. GRDB uses them to fetch from and save object to database. The last one overriden function assures that id
is unique and I do not need to care about it.
Notifications
Of course I have got table views with delegate and data source that allows adding and removing apps and developers. That's nothing special, but you can check it out on the repository. The cool thing about GRDB.swift are controllers that fetch records and let execute closures on notification callbacks, pretty much the same way as Realm.
let controller = try! FetchedRecordsController(dbQueue, request: App.order(Column("name")))
That's how you initialize a controller. Instead Record.order(_:)
, you can give it simpler request, like Record.all()
or more complex like Record.filter(_:)
. After this you should call performFetch()
on it.
A controller like this allows easily complete bodies of table or collection view's data source protocol.
func numberOfSections(in tableView: UITableView) -> Int {
return controller.sections.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return controller.sections[section].numberOfRecords
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
let object = controller.record(at: indexPath)
cell.textLabel?.text = object.name
return cell
}
Removing records is simple and can be easily maintained by data source as well:
func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete else { return }
let object = controller.record(at: indexPath)
try! dbQueue.inDatabase { database in
_ = try object.delete(database)
}
}
The three bottom lines of the last function are crucial. inDatabase(_:)
synchronously executes a block in a protected dispatch queue and then inside of this delete(_:)
executes a raw DELETE
statement.
Adding an object to database is similar.
try! dbQueue.inTransaction { database in
let app = App(name: "Netguru")
try app.insert(database)
return .commit
}
inTransaction(_:)
is pretty much the same as inDatabase(_:)
, but in addition a block is wrapped inside a transaction, which makes it faster, especially when performing multiple updates. A closure expects returned TransactionCompletion
enum value – commit or rollback, the last one is silent and does not allow rethrowing an error from an inner try.
Yeah, all of these look nice, but you may be curious why I never call reloadData()
on a table view or manually insert/(re)move/reload rows. That's because of notifications.
controller
.trackChanges(willChange: { [unowned self] _ in
self.tableView.beginUpdates()
}, onChange: { [unowned self] _, _, change in
switch change {
case .insertion(let indexPath):
self.tableView.insertRows(at: [indexPath], with: .automatic)
case .deletion(let indexPath):
self.tableView.deleteRows(at: [indexPath], with: .automatic)
case .update(let indexPath, _):
self.tableView.reloadRows(at: [indexPath], with: .automatic)
default:
break
}
}, didChange: { [unowned self] _ in
self.tableView.endUpdates()
})
This makes working with table or collection views extremely easy.
To sum up
You can find an example app code here. It is extended (incl. updating objects and relationships), refactored and uses protocol-oriented-programming to make things easier. Unfortunately it still uses try!
instead of do-try-catch
, so remember to not follow this in a real life :)
I believe it is a right time to sum up GRDB.swift with pros and cons.
Pros
- Fast (according to the benchmarks)
- Notifications
- Quite easy (especially if you do not want advanced relationships)
- Well documented
- Allows execute raw SQL statements
Cons
- Must execute raw SQL statements for some things
- String driven API
- Lack of tutorials (except the official series), Stack Overflow threads, etc.