SQLite and iOS: Getting started with GRDB

Designed to provide local data storage for individual applications and devices, SQLite is a small, fast, highly reliable SQL database engine that I have a lot of love for.

GRDB is a fantastic SQLite toolkit maintained by Gwendal Roué that does all the hard work for us: it provides, amongst other features, a query interface so we don't have to write raw SQL (though you can still do that if you need to), migrations, database observation, and encryption. It's what I use in my own projects, and it's what we'll be using in this article.

This article will cover setting up a local SQLite database for your iOS app, writing migrations, writing a struct to the database without any manual value mapping, and querying data pre-modelled into a struct. There will be a follow-up article that'll cover some of GRDB's more advanced features.


Part 1: How to setup GRDB in your app

This section will cover installing GRDB, creating the database file, connecting to it, and writing a migration.

Installation

First things first, you need to add GRDB to your project. Carthage isn't supported, but CocoaPods and the Swift Package Manager are.

Podfile

pod 'GRDB.swift'

Package.swift

let package = Package(
    dependencies: [
        .package(url: "https://github.com/groue/GRDB.swift.git", ...)
    ]
)

Creating the database file and connecting to it

Now that GRDB is installed, we can get down to business. Create a file named DatabaseManager.swift, AppDatabase.swift, or something similar. This is where your migrations will live alongside the setup function.

DatabaseManager.swift

import GRDB

var dbQueue: DatabaseQueue!

class DatabaseManager {

    static func setup(for application: UIApplication) throws {
        let databaseURL = try FileManager.default
            .url(for: .applicationDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
            .appendingPathComponent("db.sqlite")

        dbQueue = try DatabaseQueue(path: databaseURL.path)
        dbQueue.setupMemoryManagement(in: application)
    }

}

Right at the top on line 3 you'll see the global dbQueue variable. The connection to the database gets closed when the DatabaseQueue gets deallocated, so for most use cases you'll want it kept in memory throughout the lifetime of your app.

You'll notice that we never explicitly create the db.sqlite file, we just compose its path. That's because the DatabaseQueue initializer on line 12 will automatically create the database file for us if it can't find an existing one.

setupMemoryManagement on line 13 tells GRDB to automatically run dbQueue.releaseMemory() both when your app enters the background and if your app receives memory warnings; releaseMemory will then free up as much of the memory GRDB is using as possible.

All that's left to do is call the setup method in your AppDelegate:

AppDelegate.swift

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    try! DatabaseManager.setup(for: application)
    return true
}

Adding migrations

We now have a database and a connection to it, but before we can insert data, we still need to define a table and its columns.

GRDB's DatabaseMigrator is responsible for registering and applying migrations. If you're unfamiliar with the concept, migrations provide an easy way to evolve the structure of a database over time. They are guaranteed to be applied in order, and only once.

For example, say you added a new feature to version 2 of your app that required a new column in one of your tables (or even a new table entirely). GRDB will check when the DatabaseMigrator is run and see that there's a new migration that has yet to be applied to the database. It will then run that migration, ignoring all the other ones that have already been run, and the database structure in version 2 of your app will now match your updated model(s).

DatabaseManager.swift

class DatabaseManager {

    static var migrator: DatabaseMigrator {
        var migrator = DatabaseMigrator()

        migrator.registerMigration("createProject") { db in
            try db.create(table: "project") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("name", .text).notNull()
                t.column("description", .text)
                t.column("due", .date)
            }
        }

        return migrator
    }

}

Most of the above is fairly self-explanatory, but we'll run through it quickly just in case.

You can see that line 6 marks the start of a new migration. It's important to remember that once the first version of your app has been released, you can't go back and make changes to old migrations because those changes won't be applied on user's devices. Any changes must be applied in a new migration.

Line 7 creates a new table named project, then lines 8 and 9 specify the name, type, and characteristics of 2 columns. autoIncrementedPrimaryKey is the best way to define a primary key that's numeric, as it automatically prevents reuse of the value over the lifetime of the database.

Once your first migration(s) are in place, don't forget to update your setup method to run the migrations.

DatabaseManager.swift

class DatabaseManager {

    static func setup(for application: UIApplication) throws {
        // Existing setup code…
        try migrator.migrate(dbQueue)
    }

}

Should you need to make alterations to your tables in future versions of your app, you can do so quite straightforwardly by registering a new migration.

DatabaseManager.swift

static var migrator: DatabaseMigrator {
    var migrator = DatabaseMigrator()

    // `createProject` migration…

    migrator.registerMigration("v2") { db in
        try db.alter(table: "project") { t in
            t.add(column: "isDraft", .boolean).notNull().defaults(to: true)
        }

        try db.rename(table: "project", to: "collection")
    }

    return migrator
}

💡 Tip from the GRDB docs

Database table names should be singular and camel-cased (e.g. country, postalAddress). Names that follow another convention are totally OK, but you will need to perform extra configuration when using Associations.

In the early days of an app's development, its database schema changes frequently. To avoid a pileup of migrations before v1 is even out the door, you can tell GRDB to automatically reset the database if it detects a change to an existing migration.

DatabaseManager.swift

static var migrator: DatabaseMigrator {
    // Migrations…

    #if DEBUG
    migrator.eraseDatabaseOnSchemaChange = true
    #endif

    return migrator
}


Part 2: Reading from and writing to your database

We'll ignore line 11 in the "alterations snippet" in Part 1, meaning that we now have a project table that has id, name, description, due, and isDraft columns that need modelling into a struct. That's what we'll be doing in Part 2, along with some simple reading and writing.

Structuring your data

Create a new group in your project and name it Models. Within that group, create a Project.swift file and add the following struct.

Models/Project.swift

struct Project {
    var id: Int64?
    var name: String
    var description: String?
    var due: Date?
    var isDraft: Bool
}

GRDB recommends using an Int64 for auto-incrementing ids, so that's what we'll do. You'll notice that it's written as an optional — that's because an instance of the struct can exist before it's been written to the database for the first time and thus before it's been assigned its database id.

name isn't optional because we added the notNull constraint to it during the migration. The inverse is why description and due are both optional.

isDraft was specified during the "v2" migration. It too was assigned the notNull constraint, but importantly, it was also assigned a default value. After the first release of your app, you can't create a non-nullable column without a default value because any records created prior will not have assigned a value to the inexistent column.

Adopting GRDB's protocols

Next, import GRDB if you haven't already, then add the following below the Project struct:

Models/Project.swift

extension Project: Codable, FetchableRecord, MutablePersistableRecord { }

Adopting FetchableRecord tells GRDB that a Project can be initialized from a database row, and adopting MutablePersistableRecord tells GRDB that a Project can be inserted into, updated in, and deleted from the database. Codable is simply shorthand for Decodable & Encodable.

As we're working with such a simple model, we could stop here and reading and writing of the database would work just fine. There's one improvement we can make to writing though before we move onto reading.

Let's say we have the following:

do {
    try dbQueue.write { db in
        var project = Project(
            name: "Getting started with GRDB",
            description: "A blog post",
            due: Date().addingTimeInterval(24 * 60 * 60)
        )

        try! project.insert(db)

        print(project)
    }
} catch {
    print("\(error)")
}

At the moment it'll run just fine and the Project will be written to the database. However, print on line 11 will return something like this:

Project(id: nil, name: "Getting started with GRDB", description: Optional("A blog post"), due: Optional(2019-12-11 14:16:15 +0000), isDraft: true)

As you can see, the project's id is still nil even though the write succeeded. To get the id, you'd need to go back in and read from the database, which is far from ideal. Fortunately, we can tell GRDB to mutate the variable insert was called on once the insertion has completed.

Go back to your model and add the following to the extension:

Models/Project.swift

extension Project: Codable, FetchableRecord, MutablePersistableRecord {

    mutating func didInsert(with rowID: Int64, for column: String?) {
        id = rowID
    }

}

Now, if you were to run the write snippet above, print would return the id without having to go back for an extra read:

Project(id: Optional(1), name: "Getting started with GRDB", description: Optional("A blog post"), due: Optional(2019-12-11 14:16:15 +0000), isDraft: true)

Querying the database

We've already seen an example of a write, but let's break it down quickly.

do {
    try dbQueue.write { db in
        var project = Project(
            name: "Getting started with GRDB",
            description: "A blog post",
            due: Date().addingTimeInterval(24 * 60 * 60)
        )

        try! project.insert(db)
    }
} catch {
    print("\(error)")
}

On line 2 you can see that write is called on the global dbQueue connection we made earlier. write synchronously executes the database updates; one of the nice things about it is that concurrent reads are guaranteed not to return any partial updates.

Line 9 is where the SQL INSERT is actually performed.

Time to do some reading. Go back to Project.swift and add another extension — this one will house our queries.

A simple query to, for example, only return Projects that are drafts would look like this:

Models/Project.swift

extension Project {

    static func drafts() -> QueryInterfaceRequest<Project> {
        return Project.filter(Column("isDraft") == true)
    }

}

Not bad, but the readability could be improved and the room for developer error could be lessened. Let's map the columns to an enum.

Models/Project.swift

extension Project {

    private enum Columns {
        static let id = Column(CodingKeys.id)
        static let name = Column(CodingKeys.name)
        static let description = Column(CodingKeys.description)
        static let due = Column(CodingKeys.due)
        static let isDraft = Column(CodingKeys.isDraft)
    }

    static func drafts() -> QueryInterfaceRequest<Project> {
        return Project.filter(Columns.isDraft == true)
    }

}

Much better. Here are some examples of how that query can now be used:

do {
    try dbQueue.read { db in
        let drafts = try Project.drafts().fetchAll(db)
        let oneDraft = try Project.drafts().fetchOne(db)
        let tenDrafts = try Project.drafts().limit(10).fetchAll(db)
        let draftsCount = try Project.drafts().fetchCount(db)
    }
} catch {
    print("\(error)")
}

You might want to order the results:

Models/Project.swift

extension Project {

    static func dueDrafts() -> QueryInterfaceRequest<Project> {
        return Project
            .filter(Columns.isDraft == true)
            .order(Columns.due)
    }

}

Or have multiple filters:

Models/Project.swift

extension Project {

    static func draftsDueSoon() -> QueryInterfaceRequest<Project> {
        return Project
            .filter(Columns.isDraft == true)
            .filter(Columns.due < Date().addingTimeInterval(24 * 60 * 60))
            .order(Columns.due)
    }

}

Or get a specific draft:

Models/Project.swift

extension Project {

    static func draft(withId id: Int64) -> QueryInterfaceRequest<Project> {
        return Project.filter(Columns.id == id)
    }

}
do {
    try dbQueue.read { db in
        let draft = try Project.draft(withId: 12).fetchOne(db)
    }
} catch {
    print("\(error)")
}

As it's specifically matching against the row id, the above could also be written as:

do {
    try dbQueue.read { db in
        let draft = try Project.fetchOne(db, key: 12)
    }
} catch {
    print("\(error)")
}

As you can see, GRDB's query interface makes database requests very approachable, even if you have no experience writing SQL.


Until next time

GRDB is packed with features — far too many to go through in a single post and many of which don't belong in a "getting started" guide. I'll write about my favourite ones (including reacting to database changes and saving custom types) in a follow-up post next week, but even then, there'll still be things I won't have covered. Fortunately, GRDB has excellent documentation. I'd encourage you to check out.