Files
UUVPN/iOS-SwiftUI-Code/GRDB.swift-6.29.3/GRDB/Core/TransactionObserver.swift
2025-01-28 12:28:03 +08:00

1633 lines
64 KiB
Swift

extension Database {
// MARK: - Database Observation
/// Adds a transaction observer on the database connection, so that it
/// gets notified of database changes and transactions.
///
/// This method has no effect on read-only database connections.
///
/// For example:
///
/// ```swift
/// let myObserver = MyObserver()
/// try dbQueue.write { db in
/// db.add(transactionObserver: myObserver)
/// }
/// ```
///
/// - parameter transactionObserver: A transaction observer.
/// - parameter extent: The duration of the observation. The default is
/// the observer lifetime (observation lasts until observer
/// is deallocated).
public func add(
transactionObserver: some TransactionObserver,
extent: TransactionObservationExtent = .observerLifetime)
{
SchedulingWatchdog.preconditionValidQueue(self)
guard let observationBroker else { return }
// Drop cached statements that delete, because the addition of an
// observer may change the need for truncate optimization prevention.
publicStatementCache.removeAll { $0.canDeleteRows }
internalStatementCache.removeAll { $0.canDeleteRows }
observationBroker.add(transactionObserver: transactionObserver, extent: extent)
}
/// Removes a transaction observer from the database connection.
///
/// For example:
///
/// ```swift
/// let myObserver = MyObserver()
/// try dbQueue.write { db in
/// db.remove(transactionObserver: myObserver)
/// }
/// ```
public func remove(transactionObserver: some TransactionObserver) {
SchedulingWatchdog.preconditionValidQueue(self)
guard let observationBroker else { return }
// Drop cached statements that delete, because the removal of an
// observer may change the need for truncate optimization prevention.
publicStatementCache.removeAll { $0.canDeleteRows }
internalStatementCache.removeAll { $0.canDeleteRows }
observationBroker.remove(transactionObserver: transactionObserver)
}
/// Registers closures to be executed after the next or current
/// transaction completes.
///
/// This method helps synchronizing the database with other resources,
/// such as files, or system services.
///
/// In the example below, a `CLLocationManager` starts monitoring a
/// `CLRegion` if and only if it has successfully been stored in
/// the database:
///
/// ```swift
/// /// Inserts a region in the database, and start monitoring upon
/// /// successful insertion.
/// func startMonitoring(_ db: Database, region: CLRegion) throws {
/// // Make sure database is inside a transaction
/// try db.inSavepoint {
///
/// // Save the region in the database
/// try insert(...)
///
/// // Start monitoring if and only if the insertion is
/// // eventually committed to disk
/// db.afterNextTransaction { _ in
/// // locationManager prefers the main queue:
/// DispatchQueue.main.async {
/// locationManager.startMonitoring(for: region)
/// }
/// }
///
/// return .commit
/// }
/// }
/// ```
///
/// The method above won't trigger the location manager if the transaction
/// is eventually rollbacked (explicitly, or because of an error).
///
/// The `onCommit` and `onRollback` closures are executed in the writer
/// dispatch queue, serialized will all database updates.
///
/// - precondition: Database connection is not read-only.
/// - parameter onCommit: A closure executed on transaction commit.
/// - parameter onRollback: A closure executed on transaction rollback.
public func afterNextTransaction(
onCommit: @escaping (Database) -> Void,
onRollback: @escaping (Database) -> Void = { _ in })
{
class TransactionHandler: TransactionObserver {
let onCommit: (Database) -> Void
let onRollback: (Database) -> Void
init(onCommit: @escaping (Database) -> Void, onRollback: @escaping (Database) -> Void) {
self.onCommit = onCommit
self.onRollback = onRollback
}
// Ignore changes
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false }
func databaseDidChange(with event: DatabaseEvent) { }
func databaseDidCommit(_ db: Database) {
onCommit(db)
}
func databaseDidRollback(_ db: Database) {
onRollback(db)
}
}
// We don't notify read-only transactions to transaction observers
GRDBPrecondition(!isReadOnly, "Read-only transactions are not notified")
add(
transactionObserver: TransactionHandler(onCommit: onCommit, onRollback: onRollback),
extent: .nextTransaction)
}
/// The extent of the observation performed by a ``TransactionObserver``.
public enum TransactionObservationExtent: Sendable {
/// Observation lasts until observer is deallocated.
case observerLifetime
/// Observation lasts until the next transaction.
case nextTransaction
/// Observation lasts until the database is closed.
case databaseLifetime
}
}
// MARK: - DatabaseObservationBroker
/// This class provides support for transaction observers.
///
/// Let's have a detailed look at how a transaction observer is notified:
///
/// class MyObserver: TransactionObserver {
/// func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool
/// func databaseDidChange(with event: DatabaseEvent)
/// func databaseWillCommit() throws
/// func databaseDidCommit(_ db: Database)
/// func databaseDidRollback(_ db: Database)
/// }
///
/// First observer is added, and a transaction is started. At this point,
/// there's not much to say:
///
/// let observer = MyObserver()
/// dbQueue.add(transactionObserver: observer)
/// dbQueue.inDatabase { db in
/// try db.execute(sql: "BEGIN TRANSACTION")
///
/// Then a statement is executed:
///
/// try db.execute(sql: "INSERT INTO document ...")
///
/// The observation process starts when the statement is *compiled*:
/// sqlite3_set_authorizer tells that the statement performs insertion into the
/// `document` table. Generally speaking, statements may have many effects, by
/// the mean of foreign key actions and SQL triggers. SQLite takes care of
/// exposing all those effects to sqlite3_set_authorizer.
///
/// When the statement is *about to be executed*, the broker queries the
/// observer.observes(eventsOfKind:) method. If it returns true, the observer is
/// *activated*.
///
/// During the statement *execution*, SQLite tells that a row has been inserted
/// through sqlite3_update_hook: the broker calls the observer.databaseDidChange(with:)
/// method, if and only if the observer has been activated at the previous step.
///
/// Now a savepoint is started:
///
/// try db.execute(sql: "SAVEPOINT foo")
///
/// Statement compilation has sqlite3_set_authorizer tell that this statement
/// begins a "foo" savepoint.
///
/// After the statement *has been executed*, the broker knows that the SQLite
/// [savepoint stack](https://www.sqlite.org/lang_savepoint.html) contains the
/// "foo" savepoint.
///
/// Then another statement is executed:
///
/// try db.execute(sql: "INSERT INTO document ...")
///
/// This time, when the statement is *executed* and SQLite tells that a row has
/// been inserted, the broker buffers the change event instead of immediately
/// notifying the activated observers. That is because the savepoint can be
/// rollbacked, and GRDB guarantees observers that they are only notified of
/// changes that have an opportunity to be committed.
///
/// The savepoint is released:
///
/// try db.execute(sql: "RELEASE SAVEPOINT foo")
///
/// Statement compilation has sqlite3_set_authorizer tell that this statement
/// releases the "foo" savepoint.
///
/// After the statement *has been executed*, the broker knows that the SQLite
/// [savepoint stack](https://www.sqlite.org/lang_savepoint.html) is now empty,
/// and notifies the buffered changes to activated observers.
///
/// Finally the transaction is committed:
///
/// try db.execute(sql: "COMMIT")
///
/// During the statement *execution*, SQLite tells the broker that the
/// transaction is about to be committed through sqlite3_commit_hook. The broker
/// invokes observer.databaseWillCommit(). If the observer throws an error, the
/// broker asks SQLite to rollback the transaction. Otherwise, the broker lets
/// the transaction complete.
///
/// After the statement *has been executed*, the broker calls
/// observer.databaseDidCommit().
class DatabaseObservationBroker {
private unowned let database: Database
/// The savepoint stack allows us to hold database event notifications until
/// all savepoints are released. The goal is to only tell transaction
/// observers about database changes that have a chance to be committed
/// on disk.
private let savepointStack = SavepointStack()
/// Tracks the transaction completion, as reported by the
/// `sqlite3_commit_hook` and `sqlite3_rollback_hook` callbacks.
private var transactionCompletion = TransactionCompletion.none
/// The registered transaction observers.
private var transactionObservations: [TransactionObservation] = []
/// The observers for an individual statement execution.
private var statementObservations: [StatementObservation] = [] {
didSet {
let isEmpty = statementObservations.isEmpty
if isEmpty != oldValue.isEmpty {
if isEmpty {
// Avoid processing database changes if nobody is interested
uninstallUpdateHook()
} else {
installUpdateHook()
}
}
}
}
init(_ database: Database) {
self.database = database
}
// MARK: - Transaction observers
func add(transactionObserver: some TransactionObserver, extent: Database.TransactionObservationExtent) {
transactionObservations.append(TransactionObservation(observer: transactionObserver, extent: extent))
}
func remove(transactionObserver: some TransactionObserver) {
transactionObservations.removeFirst { $0.isWrapping(transactionObserver) }
}
/// Called from ``TransactionObserver/stopObservingDatabaseChangesUntilNextTransaction()``.
func disableUntilNextTransaction(transactionObserver: some TransactionObserver) {
if let observation = transactionObservations.first(where: { $0.isWrapping(transactionObserver) }) {
observation.isEnabled = false
statementObservations.removeFirst { $0.transactionObservation === observation }
}
}
func notifyChanges(withEventsOfKind eventKinds: [DatabaseEventKind]) throws {
// Support for stopObservingDatabaseChangesUntilNextTransaction()
SchedulingWatchdog.current!.databaseObservationBroker = self
defer {
SchedulingWatchdog.current!.databaseObservationBroker = nil
}
for observation in transactionObservations where observation.isEnabled {
if eventKinds.contains(where: { observation.observes(eventsOfKind: $0) }) {
observation.databaseDidChange()
}
}
}
// MARK: - Statement execution
/// Returns true if there exists some transaction observer interested in
/// the deletions in the given table.
func observesDeletions(on table: String) -> Bool {
transactionObservations.contains { observation in
observation.observes(eventsOfKind: .delete(tableName: table))
}
}
/// Prepares observation of changes that are about to be performed by the statement.
func statementWillExecute(_ statement: Statement) {
if !database.isReadOnly && !transactionObservations.isEmpty {
// As statement executes, it may trigger database changes that will
// be notified to transaction observers. As a consequence, observers
// may disable themselves with stopObservingDatabaseChangesUntilNextTransaction()
//
// This method takes no argument, and requires access to the "current
// broker", which is a per-thread global stored in
// SchedulingWatchdog.current:
SchedulingWatchdog.current!.databaseObservationBroker = self
// Fill statementObservations with observations that are interested
// in the kind of database events performed by the statement, as
// reported by `sqlite3_set_authorizer`.
//
// Those statementObservations will be notified of individual changes
// in databaseWillChange() and databaseDidChange().
let authorizerEventKinds = statement.authorizerEventKinds
switch authorizerEventKinds.count {
case 0:
// Statement has no effect on any database table.
//
// For example: PRAGMA foreign_keys = ON
statementObservations = []
case 1:
// We'll execute a simple statement without any side effect.
// Eventual database events will thus all have the same kind. All
// detabase events can be notified to interested observations.
//
// For example, if one observes all deletions in the table T, then
// all individual deletions of DELETE FROM T are notified:
let eventKind = authorizerEventKinds[0]
statementObservations = transactionObservations.compactMap { observation in
guard observation.observes(eventsOfKind: eventKind) else {
// observation is not interested
return nil
}
// Observation will be notified of all individual events
return StatementObservation(
transactionObservation: observation,
trackingEvents: .all)
}
default:
// We'll execute a complex statement with side effects performed by
// an SQL trigger or a foreign key action. Eventual database events
// may not all have the same kind: we need to filter them before
// notifying interested observations.
//
// For example, if DELETE FROM T1 generates deletions in T1 and T2
// by the mean of a foreign key action, then when one only observes
// deletions in T1, one must not be notified of deletions in T2:
statementObservations = transactionObservations.compactMap { observation in
let observedEventKinds = authorizerEventKinds.filter(observation.observes)
if observedEventKinds.isEmpty {
// observation is not interested
return nil
}
// Observation will only be notified of individual events
// it is interested into.
return StatementObservation(
transactionObservation: observation,
trackingEvents: .matching(
observedEventKinds: observedEventKinds,
authorizerEventKinds: authorizerEventKinds))
}
}
}
transactionCompletion = .none
}
/// May throw the user-provided cancelled commit error, if a transaction
/// observer has cancelled a transaction.
func statementDidFail(_ statement: Statement) throws {
// Undo statementWillExecute
statementObservations = []
SchedulingWatchdog.current!.databaseObservationBroker = nil
// Reset transactionCompletion before databaseDidRollback eventually
// executes other statements.
let transactionCompletion = self.transactionCompletion
self.transactionCompletion = .none
switch transactionCompletion {
case .rollback:
// Don't notify observers because we're in a failed implicit
// transaction here (like an INSERT which fails with
// SQLITE_CONSTRAINT error)
databaseDidRollback(notifyTransactionObservers: false)
case .cancelledCommit(let error):
databaseDidRollback(notifyTransactionObservers: !database.isReadOnly)
throw error
default:
break
}
}
/// May throw the user-provided cancelled commit error, if the statement
/// commits an empty transaction, and a transaction observer cancels this
/// empty transaction.
func statementDidExecute(_ statement: Statement) throws {
// Undo statementWillExecute
if transactionObservations.isEmpty == false {
statementObservations = []
SchedulingWatchdog.current!.databaseObservationBroker = nil
}
// Has statement any effect on transaction/savepoints?
if let transactionEffect = statement.transactionEffect {
switch transactionEffect {
case .beginTransaction:
break
case .commitTransaction: // 1. A COMMIT statement has been executed
if case .none = transactionCompletion { // 2. sqlite3_commit_hook was not called
// 1+2 mean that an empty deferred transaction has been completed:
//
// BEGIN DEFERRED TRANSACTION; COMMIT
//
// This special case has a dedicated handling:
try databaseDidCommitEmptyDeferredTransaction()
return
}
case .rollbackTransaction:
break
case .beginSavepoint(let name):
savepointStack.savepointDidBegin(name)
case .releaseSavepoint(let name): // 1. A RELEASE SAVEPOINT statement has been executed
savepointStack.savepointDidRelease(name)
if case .none = transactionCompletion, // 2. sqlite3_commit_hook was not called
!database.isInsideTransaction // 3. database is no longer inside a transaction
{
// 1+2+3 mean that an empty deferred transaction has been completed:
//
// SAVEPOINT foo; RELEASE SAVEPOINT foo
//
// This special case has a dedicated handling:
try databaseDidCommitEmptyDeferredTransaction()
return
}
if savepointStack.isEmpty {
notifyBufferedEvents()
}
case .rollbackSavepoint(let name):
savepointStack.savepointDidRollback(name)
}
}
// Reset transactionCompletion before databaseDidCommit or
// databaseDidRollback eventually execute other statements.
let transactionCompletion = self.transactionCompletion
self.transactionCompletion = .none
switch transactionCompletion {
case .commit:
databaseDidCommit()
case .rollback:
databaseDidRollback(notifyTransactionObservers: !database.isReadOnly)
default:
break
}
}
#if SQLITE_ENABLE_PREUPDATE_HOOK
// Called from sqlite3_preupdate_hook
private func databaseWillChange(with event: DatabasePreUpdateEvent) {
assert(!database.isReadOnly, "Read-only transactions are not notified")
if savepointStack.isEmpty {
// Notify now
for statementObservation in statementObservations where statementObservation.tracksEvent(event) {
statementObservation.transactionObservation.databaseWillChange(with: event)
}
} else {
// Buffer
savepointStack.eventsBuffer.append((event: event.copy(), statementObservations: statementObservations))
}
}
#endif
// Called from sqlite3_update_hook
private func databaseDidChange(with event: DatabaseEvent) {
assert(!database.isReadOnly, "Read-only transactions are not notified")
// We're about to call the databaseDidChange(with:) method of
// transaction observers. In this method, observers may disable
// themselves with stopObservingDatabaseChangesUntilNextTransaction()
//
// This method takes no argument, and requires access to the "current
// broker", which is a per-thread global stored in
// SchedulingWatchdog.current:
assert(SchedulingWatchdog.current?.databaseObservationBroker != nil)
if savepointStack.isEmpty {
// Notify now
for statementObservation in statementObservations where statementObservation.tracksEvent(event) {
statementObservation.transactionObservation.databaseDidChange(with: event)
}
} else {
// Buffer
savepointStack.eventsBuffer.append((event: event.copy(), statementObservations: statementObservations))
}
}
// MARK: - End of transaction
// Called from sqlite3_commit_hook and databaseDidCommitEmptyDeferredTransaction()
private func databaseWillCommit() throws {
notifyBufferedEvents()
if !database.isReadOnly {
for observation in transactionObservations {
try observation.databaseWillCommit()
}
}
}
// Called from statementDidExecute
private func databaseDidCommit() {
savepointStack.clear()
if !database.isReadOnly {
for observation in transactionObservations {
observation.databaseDidCommit(database)
}
}
databaseDidEndTransaction()
}
// Called from statementDidExecute
/// May throw a cancelled commit error, if a transaction observer cancels
/// the empty transaction.
private func databaseDidCommitEmptyDeferredTransaction() throws {
// A statement that ends a transaction has been executed. But for
// SQLite, no transaction at all has started, and sqlite3_commit_hook
// was not triggered:
//
// try db.execute(sql: "BEGIN DEFERRED TRANSACTION")
// try db.execute(sql: "COMMIT") // <- no sqlite3_commit_hook callback invocation
//
// Should we tell transaction observers of this transaction, or not?
// The code says that a transaction was open, but SQLite says the
// opposite. How do we lift this ambiguity? Should we notify of
// *transactions expressed in the code*, or *SQLite transactions* only?
//
// If we would notify of SQLite transactions only, then we'd notify of
// all transactions expressed in the code, but empty deferred
// transaction. This means that we'd make an exception. And exceptions
// are the recipe for both surprise and confusion.
//
// For example, is the code below expected to print "did commit"?
//
// db.afterNextTransaction { _ in print("did commit") }
// try db.inTransaction {
// performSomeTask(db)
// return .commit
// }
//
// Yes it is. And the only way to make it reliably print "did commit" is
// to behave consistently, regardless of the implementation of the
// `performSomeTask` function. Even if the `performSomeTask` is empty,
// even if we actually execute an empty deferred transaction.
//
// For better or for worse, let's simulate a transaction:
//
// 2023-11-26: I'm glad we did, because that's how we support calls
// to `Database.notifyChanges(in:)` from an empty transaction, as a
// way to tell transaction observers about changes performed by some
// external connection.
do {
try databaseWillCommit()
databaseDidCommit()
} catch {
databaseDidRollback(notifyTransactionObservers: !database.isReadOnly)
throw error
}
}
// Called from statementDidExecute or statementDidFail
private func databaseDidRollback(notifyTransactionObservers: Bool) {
savepointStack.clear()
if notifyTransactionObservers {
assert(!database.isReadOnly, "Read-only transactions are not notified")
for observation in transactionObservations {
observation.databaseDidRollback(database)
}
}
databaseDidEndTransaction()
}
// Called from both databaseDidCommit() and databaseDidRollback()
private func databaseDidEndTransaction() {
assert(!database.isInsideTransaction)
// Remove transaction observations that are no longer observing, because
// a transaction observer registered with the `.observerLifetime` extent
// was deallocated, or because a transaction observer was registered
// with the `.nextTransaction` extent.
transactionObservations = transactionObservations.filter(\.isObserving)
// Undo disableUntilNextTransaction(transactionObserver:)
for observation in transactionObservations {
observation.isEnabled = true
}
}
private func notifyBufferedEvents() {
// We're about to call the databaseDidChange(with:) method of
// transaction observers. In this method, observers may disable
// themselves with stopObservingDatabaseChangesUntilNextTransaction()
//
// This method takes no argument, and requires access to the "current
// broker", which is a per-thread global stored in
// SchedulingWatchdog.current.
//
// Normally, notifyBufferedEvents() is called as part of statement
// execution, and the current broker has been set in
// statementWillExecute(). An assertion should be enough:
//
// assert(SchedulingWatchdog.current?.databaseObservationBroker != nil)
//
// But we have to deal with a particular case:
//
// let journalMode = String.fetchOne(db, sql: "PRAGMA journal_mode = wal")
//
// It triggers the commit hook when the "PRAGMA journal_mode = wal"
// statement is finalized, long after it has executed:
//
// 1. Statement.deinit()
// 2. sqlite3_finalize()
// 3. commit hook
// 4. DatabaseObservationBroker.databaseWillCommit()
// 5. DatabaseObservationBroker.notifyBufferedEvents()
//
// I don't know if this behavior is something that can be relied
// upon. One would naively expect, for example, that changing the
// journal mode would trigger the commit hook in sqlite3_step(),
// not in sqlite3_finalize().
//
// Anyway: in this scenario, statementWillExecute() has not been
// called, and the current broker is nil.
//
// Let's not try to outsmart SQLite, and build a complex state machine.
// Instead, let's just make sure that the current broker is set to self
// when this method is called.
let watchDog = SchedulingWatchdog.current!
watchDog.databaseObservationBroker = self
defer {
watchDog.databaseObservationBroker = nil
}
// Now we can safely notify:
let eventsBuffer = savepointStack.eventsBuffer
savepointStack.clear()
for (event, statementObservations) in eventsBuffer {
assert(statementObservations.isEmpty || !database.isReadOnly, "Read-only transactions are not notified")
for statementObservation in statementObservations where statementObservation.tracksEvent(event) {
event.send(to: statementObservation.transactionObservation)
}
}
}
// MARK: - SQLite hooks
func installCommitAndRollbackHooks() {
let brokerPointer = Unmanaged.passUnretained(self).toOpaque()
sqlite3_commit_hook(database.sqliteConnection, { brokerPointer in
let broker = Unmanaged<DatabaseObservationBroker>.fromOpaque(brokerPointer!).takeUnretainedValue()
do {
try broker.databaseWillCommit()
broker.transactionCompletion = .commit
// Next step: statementDidExecute(_:)
return 0
} catch {
broker.transactionCompletion = .cancelledCommit(error)
// Next step: sqlite3_rollback_hook callback
return 1
}
}, brokerPointer)
sqlite3_rollback_hook(database.sqliteConnection, { brokerPointer in
let broker = Unmanaged<DatabaseObservationBroker>.fromOpaque(brokerPointer!).takeUnretainedValue()
switch broker.transactionCompletion {
case .cancelledCommit:
// Next step: statementDidFail(_:)
break
default:
broker.transactionCompletion = .rollback
// Next step: statementDidExecute(_:)
}
}, brokerPointer)
}
private func installUpdateHook() {
let brokerPointer = Unmanaged.passUnretained(self).toOpaque()
sqlite3_update_hook(
database.sqliteConnection,
{ (brokerPointer, updateKind, databaseNameCString, tableNameCString, rowID) in
let broker = Unmanaged<DatabaseObservationBroker>.fromOpaque(brokerPointer!).takeUnretainedValue()
broker.databaseDidChange(
with: DatabaseEvent(
kind: DatabaseEvent.Kind(rawValue: updateKind)!,
rowID: rowID,
databaseNameCString: databaseNameCString,
tableNameCString: tableNameCString))
},
brokerPointer)
#if SQLITE_ENABLE_PREUPDATE_HOOK
sqlite3_preupdate_hook(
database.sqliteConnection,
// swiftlint:disable:next line_length
{ (brokerPointer, databaseConnection, updateKind, databaseNameCString, tableNameCString, initialRowID, finalRowID) in
let broker = Unmanaged<DatabaseObservationBroker>.fromOpaque(brokerPointer!).takeUnretainedValue()
broker.databaseWillChange(
with: DatabasePreUpdateEvent(
connection: databaseConnection!,
kind: DatabasePreUpdateEvent.Kind(rawValue: updateKind)!,
initialRowID: initialRowID,
finalRowID: finalRowID,
databaseNameCString: databaseNameCString,
tableNameCString: tableNameCString))
},
brokerPointer)
#endif
}
private func uninstallUpdateHook() {
sqlite3_update_hook(database.sqliteConnection, nil, nil)
#if SQLITE_ENABLE_PREUPDATE_HOOK
sqlite3_preupdate_hook(database.sqliteConnection, nil, nil)
#endif
}
/// The various SQLite transactions completions, as reported by the
/// `sqlite3_commit_hook` and `sqlite3_rollback_hook` callbacks.
fileprivate enum TransactionCompletion {
/// Transaction state is unchanged.
case none
/// Transaction turns committed.
case commit
/// Transaction turns rollbacked.
case rollback
/// Transaction turns rollbacked because a transaction observer has
/// cancelled a commit by throwing an error.
case cancelledCommit(Error)
}
}
// MARK: - TransactionObserver
public protocol TransactionObserver: AnyObject {
/// Returns whether specific kinds of database changes should be notified
/// to the observer.
///
/// When this method returns false, database events of this kind are not
/// notified to the ``databaseDidChange(with:)`` method.
///
/// For example:
///
/// ```swift
/// // An observer that is only interested in the "player" table
/// class PlayerObserver: TransactionObserver {
/// func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
/// return eventKind.tableName == "player"
/// }
/// }
/// ```
///
/// When this method returns true for deletion events, the observer
/// prevents the
/// [truncate optimization](https://www.sqlite.org/lang_delete.html#the_truncate_optimization)
/// from being applied on the observed tables.
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool
/// Called when the database was modified in some unspecified way.
///
/// This method allows a transaction observer to handle changes that are
/// not automatically detected. See <doc:GRDB/TransactionObserver#Dealing-with-Undetected-Changes>
/// and ``Database/notifyChanges(in:)`` for more information.
///
/// The exact nature of changes is unknown, but they comply to the
/// ``observes(eventsOfKind:)`` test.
func databaseDidChange()
/// Called when the database is changed by an insert, update, or
/// delete event.
///
/// The change is pending until the current transaction ends. See
/// ``databaseWillCommit()-7mksu``, ``databaseDidCommit(_:)`` and
/// ``databaseDidRollback(_:)``.
///
/// The observer has an opportunity to stop receiving further change events
/// from the current transaction by calling the
/// ``stopObservingDatabaseChangesUntilNextTransaction()`` method.
///
/// - note: The event is only valid for the duration of this method call.
/// If you need to keep it longer, store a copy: `event.copy()`.
///
/// - precondition: This method must not access the observed writer
/// database connection.
func databaseDidChange(with event: DatabaseEvent)
/// Called when a transaction is about to be committed.
///
/// The transaction observer has an opportunity to rollback pending changes
/// by throwing an error from this method.
///
/// - precondition: This method must not access the observed writer
/// database connection.
/// - throws: The eventual error that rollbacks pending changes.
func databaseWillCommit() throws
/// Called when a transaction has been committed on disk.
func databaseDidCommit(_ db: Database)
/// Called when a transaction has been rollbacked.
func databaseDidRollback(_ db: Database)
#if SQLITE_ENABLE_PREUPDATE_HOOK
/// Called when the database is changed by an insert, update, or
/// delete event.
///
/// Notifies before a database change (insert, update, or delete)
/// with change information (initial / final values for the row's
/// columns). (Called *before* ``databaseDidChange(with:)``.)
///
/// The change is pending until the end of the current transaction,
/// and you always get a second chance to get basic event information in
/// the ``databaseDidChange(with:)`` callback.
///
/// This callback is mostly useful for calculating detailed change
/// information for a row, and provides the initial / final values.
///
/// The event is only valid for the duration of this method call. If you
/// need to keep it longer, store a copy: `event.copy()`
///
/// - warning: this method must not access the database.
///
/// **Availability Info**
///
/// Requires SQLite compiled with option SQLITE_ENABLE_PREUPDATE_HOOK.
///
/// As of macOS 10.11.5, and iOS 9.3.2, the built-in SQLite library
/// does not have this enabled, so you'll need to compile your own
/// version of SQLite:
/// See <https://github.com/groue/GRDB.swift/blob/master/Documentation/CustomSQLiteBuilds.md>
func databaseWillChange(with event: DatabasePreUpdateEvent)
#endif
}
extension TransactionObserver {
/// The default implementation does nothing.
public func databaseWillCommit() throws { }
#if SQLITE_ENABLE_PREUPDATE_HOOK
/// The default implementation does nothing.
public func databaseWillChange(with event: DatabasePreUpdateEvent) { }
#endif
/// The default implementation does nothing.
public func databaseDidChange() { }
/// Prevents the observer from receiving further change notifications
/// until the next transaction.
///
/// After this method has been called, the ``databaseDidChange(with:)``
/// and ``databaseDidChange()-7olv7`` methods won't be called until the
/// next transaction.
///
/// For example:
///
/// ```swift
/// // An observer that is only interested in the "player" table
/// class PlayerObserver: TransactionObserver {
/// var playerTableWasModified = false
///
/// func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
/// return eventKind.tableName == "player"
/// }
///
/// func databaseDidChange() {
/// playerTableWasModified = true
///
/// // It is pointless to keep on tracking further changes:
/// stopObservingDatabaseChangesUntilNextTransaction()
/// }
///
/// func databaseDidChange(with event: DatabaseEvent) {
/// playerTableWasModified = true
///
/// // It is pointless to keep on tracking further changes:
/// stopObservingDatabaseChangesUntilNextTransaction()
/// }
/// }
/// ```
///
/// - precondition: This method must be called from
/// ``databaseDidChange(with:)`` or ``databaseDidChange()-7olv7``.
public func stopObservingDatabaseChangesUntilNextTransaction() {
guard let broker = SchedulingWatchdog.current?.databaseObservationBroker else {
fatalError("""
stopObservingDatabaseChangesUntilNextTransaction must be called \
from the `databaseDidChange()` or `databaseDidChange(with:)` methods
""")
}
broker.disableUntilNextTransaction(transactionObserver: self)
}
}
// MARK: - TransactionObservation
/// This class manages the observation extent of a transaction observer
final class TransactionObservation {
let extent: Database.TransactionObservationExtent
/// A disabled observation is not interested in individual database changes.
/// It is still interested in transactions commits & rollbacks.
var isEnabled = true
private weak var weakObserver: (any TransactionObserver)?
private var strongObserver: (any TransactionObserver)?
private var observer: (any TransactionObserver)? { strongObserver ?? weakObserver }
fileprivate var isObserving: Bool {
observer != nil
}
init(observer: some TransactionObserver, extent: Database.TransactionObservationExtent) {
self.extent = extent
switch extent {
case .observerLifetime:
weakObserver = observer
case .nextTransaction:
// This strong reference will be released in databaseDidCommit() and databaseDidRollback()
strongObserver = observer
case .databaseLifetime:
strongObserver = observer
}
}
func isWrapping(_ observer: some TransactionObserver) -> Bool {
self.observer === observer
}
func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool {
observer?.observes(eventsOfKind: eventKind) ?? false
}
#if SQLITE_ENABLE_PREUPDATE_HOOK
func databaseWillChange(with event: DatabasePreUpdateEvent) {
guard isEnabled else { return }
observer?.databaseWillChange(with: event)
}
#endif
func databaseDidChange() {
guard isEnabled else { return }
observer?.databaseDidChange()
}
func databaseDidChange(with event: DatabaseEvent) {
guard isEnabled else { return }
observer?.databaseDidChange(with: event)
}
func databaseWillCommit() throws {
try observer?.databaseWillCommit()
}
func databaseDidCommit(_ db: Database) {
switch extent {
case .observerLifetime, .databaseLifetime:
observer?.databaseDidCommit(db)
case .nextTransaction:
if let observer = self.observer {
// Observer must not get any further notification.
// So we "forget" the observer before its `databaseDidCommit`
// implementation eventually triggers another database change.
assert(weakObserver == nil, "expected observer to be stored in strongObserver")
strongObserver = nil
observer.databaseDidCommit(db)
}
}
}
func databaseDidRollback(_ db: Database) {
switch extent {
case .observerLifetime, .databaseLifetime:
observer?.databaseDidRollback(db)
case .nextTransaction:
if let observer = self.observer {
// Observer must not get any further notification.
// So we "forget" the observer before its `databaseDidRollback`
// implementation eventually triggers another database change.
assert(weakObserver == nil, "expected observer to be stored in strongObserver")
strongObserver = nil
observer.databaseDidRollback(db)
}
}
}
}
/// The observation of one particular statement, by a particular
/// transaction observer.
struct StatementObservation {
var transactionObservation: TransactionObservation
/// A predicate that filters database events that should be notified.
///
/// Call this predicate as a method:
///
/// ```
/// if observation.tracksEvent(event) { ... }
/// ```
var tracksEvent: DatabaseEventPredicate
init(transactionObservation: TransactionObservation, trackingEvents predicate: DatabaseEventPredicate) {
self.transactionObservation = transactionObservation
self.tracksEvent = predicate
}
}
// MARK: - Database events
/// A kind of database event.
///
/// See the ``TransactionObserver/observes(eventsOfKind:)`` method in the
/// ``TransactionObserver`` protocol for more information.
@frozen
public enum DatabaseEventKind: Sendable {
/// The insertion of a row in a database table.
case insert(tableName: String)
/// The deletion of a row in a database table.
case delete(tableName: String)
/// The update of a set of columns in a database table.
case update(tableName: String, columnNames: Set<String>)
var modifiedRegion: DatabaseRegion {
switch self {
case let .delete(tableName):
return DatabaseRegion(table: tableName)
case let .insert(tableName):
return DatabaseRegion(table: tableName)
case let .update(tableName, updatedColumnNames):
return DatabaseRegion(table: tableName, columns: updatedColumnNames)
}
}
/// Returns whether this is a delete event.
var isDelete: Bool {
if case .delete = self {
return true
} else {
return false
}
}
}
extension DatabaseEventKind {
/// The name of the impacted database table.
public var tableName: String {
switch self {
case let .insert(tableName: tableName): return tableName
case let .delete(tableName: tableName): return tableName
case let .update(tableName: tableName, columnNames: _): return tableName
}
}
}
protocol DatabaseEventProtocol {
func send(to observer: TransactionObservation)
func matchesKind(_ databaseEventKind: DatabaseEventKind) -> Bool
}
/// A database event.
///
/// See the ``TransactionObserver/databaseDidChange(with:)`` method in the
/// ``TransactionObserver`` protocol for more information.
public struct DatabaseEvent {
/// An event kind.
public enum Kind: CInt, Sendable {
/// An insertion event
case insert = 18 // SQLITE_INSERT
/// A deletion event
case delete = 9 // SQLITE_DELETE
/// An update event
case update = 23 // SQLITE_UPDATE
}
private let impl: any DatabaseEventImpl
/// The event kind (insert, delete, or update).
public let kind: Kind
/// The name of the changed database.
public var databaseName: String { impl.databaseName }
/// The name of the changed database table.
public var tableName: String { impl.tableName }
/// The rowID of the changed row.
public let rowID: Int64
/// Returns a copy of the event.
///
/// An event is only valid for the duration of the
/// ``TransactionObserver/databaseDidChange(with:)`` method. You must copy
/// the event when you want to store it for later:
///
/// ```swift
/// class MyObserver: TransactionObserver {
/// var events: [DatabaseEvent]
/// func databaseDidChange(with event: DatabaseEvent) {
/// events.append(event.copy())
/// }
/// }
/// ```
public func copy() -> DatabaseEvent {
impl.copy(self)
}
fileprivate init(kind: Kind, rowID: Int64, impl: any DatabaseEventImpl) {
self.kind = kind
self.rowID = rowID
self.impl = impl
}
init(
kind: Kind,
rowID: Int64,
databaseNameCString: UnsafePointer<CChar>?,
tableNameCString: UnsafePointer<CChar>?)
{
self.init(
kind: kind,
rowID: rowID,
impl: MetalDatabaseEventImpl(
databaseNameCString: databaseNameCString,
tableNameCString: tableNameCString))
}
}
// Explicit non-conformance to Sendable: this type can't be made Sendable
// until GRDB7 where we can distinguish between a transient event and its copy.
@available(*, unavailable)
extension DatabaseEvent: Sendable { }
extension DatabaseEvent: DatabaseEventProtocol {
func send(to observer: TransactionObservation) {
observer.databaseDidChange(with: self)
}
func matchesKind(_ databaseEventKind: DatabaseEventKind) -> Bool {
switch (kind, databaseEventKind) {
case (.insert, .insert(let tableName)): return self.tableName == tableName
case (.delete, .delete(let tableName)): return self.tableName == tableName
case (.update, .update(let tableName, _)): return self.tableName == tableName
default:
return false
}
}
}
/// Protocol for internal implementation of DatabaseEvent
private protocol DatabaseEventImpl {
var databaseName: String { get }
var tableName: String { get }
func copy(_ event: DatabaseEvent) -> DatabaseEvent
}
/// Optimization: MetalDatabaseEventImpl does not create Swift strings from raw
/// SQLite char* until actually asked for databaseName or tableName.
private struct MetalDatabaseEventImpl: DatabaseEventImpl {
let databaseNameCString: UnsafePointer<CChar>?
let tableNameCString: UnsafePointer<CChar>?
var databaseName: String { String(cString: databaseNameCString!) }
var tableName: String { String(cString: tableNameCString!) }
func copy(_ event: DatabaseEvent) -> DatabaseEvent {
DatabaseEvent(
kind: event.kind,
rowID: event.rowID,
impl: CopiedDatabaseEventImpl(
databaseName: databaseName,
tableName: tableName))
}
}
/// Impl for DatabaseEvent that contains copies of event strings.
private struct CopiedDatabaseEventImpl: DatabaseEventImpl {
let databaseName: String
let tableName: String
func copy(_ event: DatabaseEvent) -> DatabaseEvent { event }
}
#if SQLITE_ENABLE_PREUPDATE_HOOK
public struct DatabasePreUpdateEvent {
/// An event kind
public enum Kind: CInt {
/// SQLITE_INSERT
case insert = 18
/// SQLITE_DELETE
case delete = 9
/// SQLITE_UPDATE
case update = 23
}
/// The event kind
public let kind: Kind
/// The database name
public var databaseName: String { impl.databaseName }
/// The table name
public var tableName: String { impl.tableName }
/// The number of columns in the row that is being inserted, updated, or deleted.
public var count: Int { Int(impl.columnCount) }
/// The triggering depth of the row update
/// Returns:
/// 0 if the preupdate callback was invoked as a result of a direct insert,
/// update, or delete operation;
/// 1 for inserts, updates, or deletes invoked by top-level triggers;
/// 2 for changes resulting from triggers called by top-level triggers;
/// ... and so forth
public var depth: CInt { impl.depth }
/// The initial rowID of the row being changed for .Update and .Delete changes,
/// and nil for .Insert changes.
public let initialRowID: Int64?
/// The final rowID of the row being changed for .Update and .Insert changes,
/// and nil for .Delete changes.
public let finalRowID: Int64?
/// The initial database values in the row.
///
/// Values appear in the same order as the columns in the table.
///
/// The result is nil if the event is an .Insert event.
public var initialDatabaseValues: [DatabaseValue]? {
guard kind == .update || kind == .delete else { return nil }
return impl.initialDatabaseValues
}
/// Returns the initial `DatabaseValue` at given index.
///
/// Indexes span from 0 for the leftmost column to (row.count - 1) for the
/// rightmost column.
///
/// The result is nil if the event is an .Insert event.
public func initialDatabaseValue(atIndex index: Int) -> DatabaseValue? {
GRDBPrecondition(index >= 0 && index < count, "row index out of range")
guard kind == .update || kind == .delete else { return nil }
return impl.initialDatabaseValue(atIndex: index)
}
/// The final database values in the row.
///
/// Values appear in the same order as the columns in the table.
///
/// The result is nil if the event is a .Delete event.
public var finalDatabaseValues: [DatabaseValue]? {
guard kind == .update || kind == .insert else { return nil }
return impl.finalDatabaseValues
}
/// Returns the final `DatabaseValue` at given index.
///
/// Indexes span from 0 for the leftmost column to (row.count - 1) for the
/// rightmost column.
///
/// The result is nil if the event is a .Delete event.
public func finalDatabaseValue(atIndex index: Int) -> DatabaseValue? {
GRDBPrecondition(index >= 0 && index < count, "row index out of range")
guard kind == .update || kind == .insert else { return nil }
return impl.finalDatabaseValue(atIndex: index)
}
/// Returns an event that can be stored:
///
/// class MyObserver: TransactionObserver {
/// var events: [DatabasePreUpdateEvent]
/// func databaseWillChange(with event: DatabasePreUpdateEvent) {
/// events.append(event.copy())
/// }
/// }
public func copy() -> DatabasePreUpdateEvent {
impl.copy(self)
}
fileprivate init(kind: Kind, initialRowID: Int64?, finalRowID: Int64?, impl: any DatabasePreUpdateEventImpl) {
self.kind = kind
self.initialRowID = (kind == .update || kind == .delete ) ? initialRowID : nil
self.finalRowID = (kind == .update || kind == .insert ) ? finalRowID : nil
self.impl = impl
}
init(
connection: SQLiteConnection,
kind: Kind,
initialRowID: Int64,
finalRowID: Int64,
databaseNameCString: UnsafePointer<CChar>?,
tableNameCString: UnsafePointer<CChar>?)
{
self.init(
kind: kind,
initialRowID: (kind == .update || kind == .delete ) ? finalRowID : nil,
finalRowID: (kind == .update || kind == .insert ) ? finalRowID : nil,
impl: MetalDatabasePreUpdateEventImpl(
connection: connection,
kind: kind,
databaseNameCString: databaseNameCString,
tableNameCString: tableNameCString))
}
private let impl: any DatabasePreUpdateEventImpl
}
extension DatabasePreUpdateEvent: DatabaseEventProtocol {
func send(to observer: TransactionObservation) {
observer.databaseWillChange(with: self)
}
func matchesKind(_ databaseEventKind: DatabaseEventKind) -> Bool {
switch (kind, databaseEventKind) {
case (.insert, .insert(let tableName)): return self.tableName == tableName
case (.delete, .delete(let tableName)): return self.tableName == tableName
case (.update, .update(let tableName, _)): return self.tableName == tableName
default:
return false
}
}
}
/// Protocol for internal implementation of DatabaseEvent
private protocol DatabasePreUpdateEventImpl {
var databaseName: String { get }
var tableName: String { get }
var columnCount: CInt { get }
var depth: CInt { get }
var initialDatabaseValues: [DatabaseValue]? { get }
var finalDatabaseValues: [DatabaseValue]? { get }
func initialDatabaseValue(atIndex index: Int) -> DatabaseValue?
func finalDatabaseValue(atIndex index: Int) -> DatabaseValue?
func copy(_ event: DatabasePreUpdateEvent) -> DatabasePreUpdateEvent
}
/// Optimization: MetalDatabasePreUpdateEventImpl does not create Swift strings from raw
/// SQLite char* until actually asked for databaseName or tableName,
/// nor does it request other data via the sqlite3_preupdate_* APIs
/// until asked.
private struct MetalDatabasePreUpdateEventImpl: DatabasePreUpdateEventImpl {
let connection: SQLiteConnection
let kind: DatabasePreUpdateEvent.Kind
let databaseNameCString: UnsafePointer<CChar>?
let tableNameCString: UnsafePointer<CChar>?
var databaseName: String { String(cString: databaseNameCString!) }
var tableName: String { String(cString: tableNameCString!) }
var columnCount: CInt { sqlite3_preupdate_count(connection) }
var depth: CInt { sqlite3_preupdate_depth(connection) }
var initialDatabaseValues: [DatabaseValue]? {
guard kind == .update || kind == .delete else { return nil }
return preupdate_getValues_old(connection)
}
var finalDatabaseValues: [DatabaseValue]? {
guard kind == .update || kind == .insert else { return nil }
return preupdate_getValues_new(connection)
}
func initialDatabaseValue(atIndex index: Int) -> DatabaseValue? {
precondition(index >= 0 && index < Int(columnCount), "row index out of range")
return getValue(
connection,
column: CInt(index),
sqlite_func: { (connection: SQLiteConnection, column: CInt, value: inout SQLiteValue? ) in
sqlite3_preupdate_old(connection, column, &value)
})
}
func finalDatabaseValue(atIndex index: Int) -> DatabaseValue? {
precondition(index >= 0 && index < Int(columnCount), "row index out of range")
return getValue(
connection,
column: CInt(index),
sqlite_func: { (connection: SQLiteConnection, column: CInt, value: inout SQLiteValue? ) in
sqlite3_preupdate_new(connection, column, &value)
})
}
func copy(_ event: DatabasePreUpdateEvent) -> DatabasePreUpdateEvent {
DatabasePreUpdateEvent(
kind: event.kind,
initialRowID: event.initialRowID,
finalRowID: event.finalRowID,
impl: CopiedDatabasePreUpdateEventImpl(
databaseName: databaseName,
tableName: tableName,
columnCount: columnCount,
depth: depth,
initialDatabaseValues: initialDatabaseValues,
finalDatabaseValues: finalDatabaseValues))
}
private func preupdate_getValues(
_ connection: SQLiteConnection,
sqlite_func: (_ connection: SQLiteConnection, _ column: CInt, _ value: inout SQLiteValue? ) -> CInt)
-> [DatabaseValue]?
{
let columnCount = self.columnCount
guard columnCount > 0 else { return nil }
var columnValues = [DatabaseValue]()
for i in 0..<columnCount {
let value = getValue(connection, column: i, sqlite_func: sqlite_func)!
columnValues.append(value)
}
return columnValues
}
private func getValue(
_ connection: SQLiteConnection,
column: CInt,
sqlite_func: (_ connection: SQLiteConnection, _ column: CInt, _ value: inout SQLiteValue? ) -> CInt)
-> DatabaseValue?
{
var value: SQLiteValue? = nil
guard sqlite_func(connection, column, &value) == SQLITE_OK else { return nil }
if let value {
return DatabaseValue(sqliteValue: value)
}
return nil
}
private func preupdate_getValues_old(_ connection: SQLiteConnection) -> [DatabaseValue]? {
preupdate_getValues(
connection,
sqlite_func: { (connection: SQLiteConnection, column: CInt, value: inout SQLiteValue? ) in
sqlite3_preupdate_old(connection, column, &value)
})
}
private func preupdate_getValues_new(_ connection: SQLiteConnection) -> [DatabaseValue]? {
preupdate_getValues(
connection,
sqlite_func: { (connection: SQLiteConnection, column: CInt, value: inout SQLiteValue? ) in
sqlite3_preupdate_new(connection, column, &value)
})
}
}
/// Impl for DatabasePreUpdateEvent that contains copies of all event data.
private struct CopiedDatabasePreUpdateEventImpl: DatabasePreUpdateEventImpl {
let databaseName: String
let tableName: String
let columnCount: CInt
let depth: CInt
let initialDatabaseValues: [DatabaseValue]?
let finalDatabaseValues: [DatabaseValue]?
func initialDatabaseValue(atIndex index: Int) -> DatabaseValue? { initialDatabaseValues?[index] }
func finalDatabaseValue(atIndex index: Int) -> DatabaseValue? { finalDatabaseValues?[index] }
func copy(_ event: DatabasePreUpdateEvent) -> DatabasePreUpdateEvent { event }
}
#endif
/// A predicate that filters database events reported by `sqlite3_update_hook`.
enum DatabaseEventPredicate {
/// All events.
case all
/// Filters events that match `observedEventKinds`, or are not
///
/// - parameter observedEventKinds: Event kinds observed by
/// the TransactionObserver.
/// - parameter authorizerEventKinds: Event kinds reported by the
/// statement authorizer.
case matching(observedEventKinds: [DatabaseEventKind], authorizerEventKinds: [DatabaseEventKind])
func callAsFunction(_ event: some DatabaseEventProtocol) -> Bool {
switch self {
case .all:
return true
case let .matching(observedEventKinds: observedEventKinds, authorizerEventKinds: authorizerEventKinds):
if observedEventKinds.contains(where: { event.matchesKind($0) }) {
return true
}
if !authorizerEventKinds.contains(where: { event.matchesKind($0) }) {
// Here, `sqlite3_update_hook` emits an unexpected event, that
// was not advertised by `sqlite3_set_authorizer` when the
// statement was compiled:
//
// 1. Compile "INSERT INTO document ...": the authorizer
// reports an insertion in the `document` table.
// 2. Execute "INSERT INTO document ...": the update hook
// reports an insertion in another table!
//
// Well, FTS4 (and maybe other virtual tables) perform such
// unadvertised changes. Executing the "INSERT INTO document ..."
// statement reports changes in the `document_content` shadow
// table, not the `document` table reported when the
// statement was compiled.
//
// When such a non-advertised event happens, we notify the
// event to the transaction observer.
//
// See https://github.com/groue/GRDB.swift/issues/620
return true
}
return false
}
}
}
// MARK: - SavepointStack
/// The SQLite savepoint stack is described at
/// <https://www.sqlite.org/lang_savepoint.html>
///
/// This class reimplements the SQLite stack, so that we can:
///
/// - know if there are currently active savepoints (isEmpty)
/// - buffer database events when a savepoint is active, in order to avoid
/// notifying transaction observers of database events that could be
/// rollbacked.
class SavepointStack {
/// The buffered events (see DatabaseObservationBroker.databaseDidChange(with:))
var eventsBuffer: [(event: any DatabaseEventProtocol, statementObservations: [StatementObservation])] = []
/// The savepoint stack, as an array of tuples (savepointName, index in the eventsBuffer array).
/// Indexes let us drop rollbacked events from the event buffer.
private var savepoints: [(name: String, index: Int)] = []
/// If true, there is no current save point.
var isEmpty: Bool { savepoints.isEmpty }
func clear() {
eventsBuffer.removeAll()
savepoints.removeAll()
}
func savepointDidBegin(_ name: String) {
savepoints.append((name: name.lowercased(), index: eventsBuffer.count))
}
// https://www.sqlite.org/lang_savepoint.html
// > The ROLLBACK command with a TO clause rolls back transactions going
// > backwards in time back to the most recent SAVEPOINT with a matching
// > name. The SAVEPOINT with the matching name remains on the transaction
// > stack, but all database changes that occurred after that SAVEPOINT was
// > created are rolled back. If the savepoint-name in a ROLLBACK TO
// > command does not match any SAVEPOINT on the stack, then the ROLLBACK
// > command fails with an error and leaves the state of the
// > database unchanged.
func savepointDidRollback(_ name: String) {
let name = name.lowercased()
while let pair = savepoints.last, pair.name != name {
savepoints.removeLast()
}
if let savepoint = savepoints.last {
eventsBuffer.removeLast(eventsBuffer.count - savepoint.index)
}
assert(!savepoints.isEmpty || eventsBuffer.isEmpty)
}
// https://www.sqlite.org/lang_savepoint.html
// > The RELEASE command starts with the most recent addition to the
// > transaction stack and releases savepoints backwards in time until it
// > releases a savepoint with a matching savepoint-name. Prior savepoints,
// > even savepoints with matching savepoint-names, are unchanged.
func savepointDidRelease(_ name: String) {
let name = name.lowercased()
while let pair = savepoints.last, pair.name != name {
savepoints.removeLast()
}
if !savepoints.isEmpty {
savepoints.removeLast()
}
}
}