TransactionObserver

A type that tracks database changes and transactions performed in a database.

TransactionObserver.swift:791TransactionObserver.md
protocol TransactionObserver : AnyObject
Browse conforming types

Overview

TransactionObserver is the low-level protocol that supports all Database Observation features.

A transaction observer is notified of individual changes (inserts, updates and deletes), before they are committed to disk, as well as transaction commits and rollbacks.

Activate a Transaction Observer

An observer starts receiving change notifications after it has been added to a database connection with the add(transactionObserver:extent:) DatabaseWriter method, or the add(transactionObserver:extent:) Database method:

let observer = MyObserver()
dbQueue.add(transactionObserver: observer)

By default, database holds weak references to its transaction observers: they are not retained, and stop getting notifications after they are deallocated. See Observation Extent for more options.

Database Changes And Transactions

Database changes are notified to the databaseDidChange(with:) callback. This includes indirect changes triggered by ON DELETE and ON UPDATE actions associated to foreign keys, and SQL triggers.

Transaction completions are notified to the databaseWillCommit, databaseDidCommit(_:) and databaseDidRollback(_:) callbacks.

Notified changes are not actually written to disk until the transaction commits, and the databaseDidCommit callback is called. On the other side, databaseDidRollback confirms their invalidation:

try dbQueue.write { db in
    try db.execute(sql: "INSERT ...") // 1. didChange
    try db.execute(sql: "UPDATE ...") // 2. didChange
}                                     // 3. willCommit, 4. didCommit

try dbQueue.inTransaction { db in
    try db.execute(sql: "INSERT ...") // 1. didChange
    try db.execute(sql: "UPDATE ...") // 2. didChange
    return .rollback                  // 3. didRollback
}

try dbQueue.write { db in
    try db.execute(sql: "INSERT ...") // 1. didChange
    throw SomeError()
}                                     // 2. didRollback

Database statements that are executed outside of any explicit transaction do not drop off the radar:

try dbQueue.writeWithoutTransaction { db in
    try db.execute(sql: "INSERT ...") // 1. didChange, 2. willCommit, 3. didCommit
    try db.execute(sql: "UPDATE ...") // 4. didChange, 5. willCommit, 6. didCommit
}

Changes that are on hold because of a savepoint are only notified after the savepoint has been released. This makes sure that notified events are only those that have an opportunity to be committed:

try dbQueue.inTransaction { db in
    try db.execute(sql: "INSERT ...")            // 1. didChange

    try db.execute(sql: "SAVEPOINT foo")
    try db.execute(sql: "UPDATE ...")            // delayed
    try db.execute(sql: "UPDATE ...")            // delayed
    try db.execute(sql: "RELEASE SAVEPOINT foo") // 2. didChange, 3. didChange

    try db.execute(sql: "SAVEPOINT bar")
    try db.execute(sql: "UPDATE ...")            // not notified
    try db.execute(sql: "ROLLBACK TO SAVEPOINT bar")
    try db.execute(sql: "RELEASE SAVEPOINT bar")

    return .commit                               // 4. willCommit, 5. didCommit
}

Eventual errors thrown from databaseWillCommit are exposed to the application code:

do {
    try dbQueue.inTransaction { db in
        ...
        return .commit // 1. willCommit (throws), 2. didRollback
    }
} catch {
    // 3. The error thrown by the transaction observer.
}

Filtering Database Events

Transaction observers can choose the database changes they are interested in.

The observes(eventsOfKind:) method filters events that are notified to databaseDidChange(with:). It is the most efficient and recommended change filtering technique, because it is only called once before a database query is executed, and can completely disable change tracking:

// Calls `observes(eventsOfKind:)` once.
// Calls `databaseDidChange(with:)` for every updated row, or not at all.
try db.execute(sql: "UPDATE player SET score = score + 1")

The DatabaseEventKind argument of observes(eventsOfKind:) can distinguish insertions from deletions and updates, and is also able to tell the columns that are about to be changed.

For example, an observer can focus on the changes that happen on the “player” database table only:

class PlayerObserver: TransactionObserver {
    func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
        // Only observe changes to the "player" table.
        eventKind.tableName == "player"
    }

    func databaseDidChange(with event: DatabaseEvent) {
        // This method is only called for changes that happen to
        // the "player" table.
    }
}

When the observes(eventsOfKind:) method returns false for all event kinds, the observer is still notified of transactions.

Observation Extent

You can specify how long an observer is notified of database changes and transactions.

The remove(transactionObserver:) method explicitly stops notifications, at any time:

// From a database queue or pool:
dbQueue.remove(transactionObserver: observer)

// From a database connection:
dbQueue.inDatabase { db in
    db.remove(transactionObserver: observer)
}

Alternatively, use the extent parameter of the add(transactionObserver:extent:) method:

let observer = MyObserver()

// On a database queue or pool:
dbQueue.add(transactionObserver: observer) // default extent
dbQueue.add(transactionObserver: observer, extent: .observerLifetime)
dbQueue.add(transactionObserver: observer, extent: .nextTransaction)
dbQueue.add(transactionObserver: observer, extent: .databaseLifetime)

// On a database connection:
dbQueue.inDatabase { db in
    db.add(transactionObserver: ...)
}
  • The default extent is .observerLifetime: the database holds a weak reference to the observer, and the observation automatically ends when the observer is deallocated. Meanwhile, the observer is notified of all changes and transactions.

  • .nextTransaction activates the observer until the current or next transaction completes. The database keeps a strong reference to the observer until its databaseDidCommit or databaseDidRollback callback is called. Hereafter the observer won’t get any further notification.

  • .databaseLifetime has the database retain and notify the observer until the database connection is closed.

Finally, an observer can avoid processing database changes until the end of the current transaction. After stopObservingDatabaseChangesUntilNextTransaction, the databaseDidChange callback will not be called until the current transaction completes:

class PlayerObserver: TransactionObserver {
    var playerTableWasModified = false

    func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
        eventKind.tableName == "player"
    }

    func databaseDidChange(with event: DatabaseEvent) {
        playerTableWasModified = true

        // It is pointless to keep on tracking further changes:
        stopObservingDatabaseChangesUntilNextTransaction()
    }
}

Support for SQLite Pre-Update Hooks

When SQLite is built with the SQLITE_ENABLE_PREUPDATE_HOOK option, TransactionObserver gets an extra callback which lets you observe individual column values in the rows modified by a transaction:

protocol TransactionObserver: AnyObject {
    #if SQLITE_ENABLE_PREUPDATE_HOOK
    /// Notifies before a database change (insert, update, or delete)
    /// with change information (initial / final values for the row's
    /// columns).
    ///
    /// The event is only valid for the duration of this method call. If you
    /// need to keep it longer, store a copy: event.copy().
    func databaseWillChange(with event: DatabasePreUpdateEvent)
    #endif
}

This extra API can be activated in two ways:

  1. Use the GRDB.swift CocoaPod with a custom compilation option, as below.

    pod 'GRDB.swift'
    
    post_install do |installer|
      installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target|
        target.build_configurations.each do |config|
          # Enable extra GRDB APIs
          config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_PREUPDATE_HOOK"
          # Enable extra SQLite APIs
          config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] = "$(inherited) GRDB_SQLITE_ENABLE_PREUPDATE_HOOK=1"
        end
      end
    end

    Warning: make sure you use the right platform version! You will get runtime errors on devices with a lower version.

    Note: the GRDB_SQLITE_ENABLE_PREUPDATE_HOOK=1 option in GCC_PREPROCESSOR_DEFINITIONS defines some C function prototypes that are lacking from the system <sqlite3.h> header. When Xcode eventually ships with an SDK that includes a complete header, you may get a compiler error about duplicate function definitions. When this happens, just remove this GRDB_SQLITE_ENABLE_PREUPDATE_HOOK=1 option.

  2. Use a custom SQLite build and activate the SQLITE_ENABLE_PREUPDATE_HOOK compilation option.

Dealing with Undetected Changes

The changes and transactions that are not automatically notified to transaction observers are:

  • Read-only transactions.

  • Changes and transactions performed by external database connections.

  • Changes performed by SQLite statements that are not both compiled and executed through GRDB APIs.

  • Changes to the database schema, changes to internal system tables such as sqlite_master.

  • Changes to WITHOUT ROWID tables.

  • The deletion of duplicate rows triggered by ON CONFLICT REPLACE clauses (this last exception might change in a future release of SQLite).

To notify undetected changes to transaction observers, perform an explicit call to the notifyChanges(in:) Database method. The databaseDidChange callback will be called accordingly. For example:

try dbQueue.write { db in
    // Notify observers that some changes were performed in the database
    try db.notifyChanges(in: .fullDatabase)

    // Notify observers that some changes were performed in the player table
    try db.notifyChanges(in: Player.all())

    // Equivalent alternative
    try db.notifyChanges(in: Table("player"))
}

To notify a change in the database schema, notify a change to the sqlite_master table:

try dbQueue.write { db in
    // Notify all observers of the sqlite_master table
    try db.notifyChanges(in: Table("sqlite_master"))
}

Filtering Database Changes

Handling Database Changes

Handling Transactions