Handeling throws from the database

Hi,

A while back we had a discussion about failable initialisation..
The conclusion was that the database should be in a known state so initialisers are basically guaranteed to work.

Now I'm wondering, if initialisers for rows are guaranteed to work, do we need to guard all database accesses with do {} catch {} as they can all throw? Or can we simply use try!?
In my understanding, they can throw because either the SQL doesn't match the schema, or you're accessing data that doesn't exist. But some queries don't suffer from the last problem and I have the feeling that the first problem falls in the 'initialisers always work' category. Hence, when the database schema is fine, so will be your request.

What's your opinion about this?

Kind regards,

Remco Poelstra

Hello Remco,

The topic of errors is developed at GRDB.swift/README.md at master · groue/GRDB.swift · GitHub

Do you have any unanswered question?

Now, this chapter was written before GRDB.swift/SharingADatabase.md at master · groue/GRDB.swift · GitHub, the guide that describes how to share a database between several processes. In this case, errors are to be expected.

More precisely, let's distinguish the specific case of trustable databases accessed from a single process (this is the case, for example, of iOS apps that do not share their database with other apps or extensions):

  • the database has a well-defined schema, and the app defines Swift record types that match their database counterpart. For example, records have optional properties when the database allows NULL, and the database has NOT NULL constraints for non-optional properties.
  • the app is allowed to crash in case of database tampering.

Then the only errors you should ever see are:

  1. I/O errors. I have to mention them. Some apps are allowed to crash when such error happens.

  2. System data protection errors (the app accesses in the background a database that is not readable in the background). See Data Protection.

    When apps do not avoid such errors with a relaxed protection, they have to handle them.

  3. SQLite constraint errors (the app attempts at violating some schema constraints).

    try dbQueue.write { db in
        do {
            try Player(teamId: 1, name: "Arthur").insert(db)
        } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {
            // Oops, the team is not there.
            // Maybe it never existed?
            // Or it was deleted by another app component?
            // Anyway: do what's appropriate now.
        }
    }
    

    You do not have to handle constraint errors when they can not possibly happen:

    try dbQueue.write { db in
        // No need to catch SQLITE_CONSTRAINT_FOREIGNKEY here
        try Team(id: 1, name: "Red").insert(db)
        try Player(teamId: 1, name: "Arthur").insert(db)
    }
    
  4. GRDB PersistenceError (the app attempts at updating a database row which does not exist). This error prevents silent data loss.

    try dbQueue.write { db in
        do {
            try player.update(db)
        } catch let PersistenceError.recordNotFound(databaseTableName: table, key: key) {
            // Oops, the player is not there.
            // Maybe it never existed?
            // Or it was deleted by another app component?
            // Anyway: do what's appropriate now.
        }
    }
    

    You do not have to handle those errors when they can not possibly happen:

    try dbQueue.write { db in
        // No need to catch PersistenceError here, because
        // `save(_:)` makes sure the row ends up in the database,
        // by performing an INSERT if the UPDATE fails.
        try player.save(db)
    }
    

That's about it. Other SQLite errors and misuses are generally prevented by GRDB.

So yeah, practically speaking, and from my own experience, you can very often just use try!. If the app crashes, it usually reveals a bug in the application, or an awful low-level failure. GRDB embeds the SQL statement in the reported error, for easier crash analysis.

When try! is out of question, maybe avoid ignoring errors-that-are-not-supposed-to-happen, just in case you'd be wrong. In recent developments, I've been using this pattern a lot, which assumes the database never fails, but tell when it does:

// AnyPublisher<[Player], Never>
let publisher = ValueObservation
    .tracking(Player.fetchAll)
    .publisher(in: dbQueue)
    // On database error, assume empty results
    .onError(
        justReturn: [], 
        receiveError: { error in /* send it to some local or remote logger */})
    .eraseToAnyPublisher()

I'm sorry, although I've read the README completely before starting I had forgotten about the error handling part.
I don't think it leaves any unanswered questions. Especially with your clarification I believe I can simplify parts of my error handling.

Thanks!

Remco Poelstra

1 Like