diff --git a/GRDB/Core/Database+Schema.swift b/GRDB/Core/Database+Schema.swift index e114c851c9..a8d92fae88 100644 --- a/GRDB/Core/Database+Schema.swift +++ b/GRDB/Core/Database+Schema.swift @@ -57,6 +57,14 @@ extension Database { case let .attached(name): return "\(name).sqlite_master" } } + + /// The name of the master sqlite table, without the schema name. + var unqualifiedMasterTableName: String { // swiftlint:disable:this inclusive_language + switch self { + case .main, .attached: return "sqlite_master" + case .temp: return "sqlite_temp_master" + } + } } /// The identifier of a database table or view. @@ -658,9 +666,17 @@ extension Database { /// attached database. func canonicalTableName(_ tableName: String) throws -> String? { for schemaIdentifier in try schemaIdentifiers() { + // Regular tables if let result = try schema(schemaIdentifier).canonicalName(tableName, ofType: .table) { return result } + + // Master table (sqlite_master, sqlite_temp_master) + // swiftlint:disable:next inclusive_language + let masterTableName = schemaIdentifier.unqualifiedMasterTableName + if tableName.lowercased() == masterTableName.lowercased() { + return masterTableName + } } return nil } @@ -1367,7 +1383,7 @@ struct SchemaObject: Hashable, FetchableRecord { /// All objects in a database schema (tables, views, indexes, triggers). struct SchemaInfo: Equatable { - private var objects: Set + let objects: Set /// Returns whether there exists a object of given type with this name /// (case-insensitive). diff --git a/GRDB/Core/Database.swift b/GRDB/Core/Database.swift index 9826711728..46fca6c062 100644 --- a/GRDB/Core/Database.swift +++ b/GRDB/Core/Database.swift @@ -79,6 +79,7 @@ let SQLITE_TRANSIENT = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_ /// - ``add(transactionObserver:extent:)`` /// - ``remove(transactionObserver:)`` /// - ``afterNextTransaction(onCommit:onRollback:)`` +/// - ``notifyChanges(in:)`` /// - ``registerAccess(to:)`` /// /// ### Collations @@ -820,6 +821,65 @@ public final class Database: CustomStringConvertible, CustomDebugStringConvertib } } + /// Notifies that some changes were performed in the provided + /// database region. + /// + /// This method makes it possible to notify undetected changes, such as + /// changes performed by another process, changes performed by + /// direct calls to SQLite C functions, or changes to the + /// database schema. + /// See + /// for a detailed list of undetected database modifications. + /// + /// It triggers active transaction observers (``TransactionObserver``). + /// In particular, ``ValueObservation`` that observe the input `region` + /// will fetch and notify a fresh value. + /// + /// For example: + /// + /// ```swift + /// 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")) + /// } + /// ``` + /// + /// This method has no effect when called from a read-only + /// database access. + /// + /// > Caveat: Individual rowids in the input region are ignored. + /// > Notifying a change to a specific rowid is the same as notifying a + /// > change in the whole table: + /// > + /// > ```swift + /// > try dbQueue.write { db in + /// > // Equivalent + /// > try db.notifyChanges(in: Player.all()) + /// > try db.notifyChanges(in: Player.filter(id: 1)) + /// > } + /// > ``` + public func notifyChanges(in region: some DatabaseRegionConvertible) throws { + // Don't do anything when read-only, because read-only transactions + // are not notified. We don't want to notify transactions observers + // of changes, and have them wait for a commit notification that + // will never come. + if !isReadOnly, let observationBroker { + let eventKinds = try region + .databaseRegion(self) + // Use canonical table names for case insensitivity of the input. + .canonicalTables(self) + .impactfulEventKinds(self) + + try observationBroker.notifyChanges(withEventsOfKind: eventKinds) + } + } + /// Extends the `region` argument with the database region selected by all /// statements executed by the closure, and all regions explicitly tracked /// with the ``registerAccess(to:)`` method. diff --git a/GRDB/Core/DatabaseRegion.swift b/GRDB/Core/DatabaseRegion.swift index 232cd472d7..0099ddb028 100644 --- a/GRDB/Core/DatabaseRegion.swift +++ b/GRDB/Core/DatabaseRegion.swift @@ -169,16 +169,16 @@ public struct DatabaseRegion { // the observed region, we optimize database observation. // // And by canonicalizing table names, we remove views, and help the - // `isModified` methods. + // `isModified` methods. (TODO: is this comment still accurate? + // Isn't it about providing TransactionObserver.observes() with + // real tables names, instead?) try ignoringInternalSQLiteTables().canonicalTables(db) } /// Returns a region only made of actual tables with their canonical names. - /// Canonical names help the `isModified` methods. /// - /// This method removes views (assuming no table exists with the same name - /// as a view). - private func canonicalTables(_ db: Database) throws -> DatabaseRegion { + /// This method removes views. + func canonicalTables(_ db: Database) throws -> DatabaseRegion { guard let tableRegions else { return .fullDatabase } var region = DatabaseRegion() for (table, tableRegion) in tableRegions { @@ -233,6 +233,44 @@ extension DatabaseRegion { } return tableRegion.contains(rowID: event.rowID) } + + /// Returns an array of all event kinds that can impact this region. + /// + /// - precondition: the region is canonical. + func impactfulEventKinds(_ db: Database) throws -> [DatabaseEventKind] { + if let tableRegions { + return try tableRegions.flatMap { (table, tableRegion) -> [DatabaseEventKind] in + let tableName = table.rawValue // canonical table name + let columnNames: Set + if let columns = tableRegion.columns { + columnNames = Set(columns.map(\.rawValue)) + } else { + columnNames = try Set(db.columns(in: tableName).map(\.name)) + } + + return [ + DatabaseEventKind.delete(tableName: tableName), + DatabaseEventKind.insert(tableName: tableName), + DatabaseEventKind.update(tableName: tableName, columnNames: columnNames), + ] + } + } else { + // full database + return try db.schemaIdentifiers().flatMap { schemaIdentifier in + let schema = try db.schema(schemaIdentifier) + return try schema.objects + .filter { $0.type == .table } + .flatMap { table in + let columnNames = try Set(db.columns(in: table.name).map(\.name)) + return [ + DatabaseEventKind.delete(tableName: table.name), + DatabaseEventKind.insert(tableName: table.name), + DatabaseEventKind.update(tableName: table.name, columnNames: columnNames), + ] + } + } + } + } } extension DatabaseRegion: Equatable { diff --git a/GRDB/Core/DatabaseRegionObservation.swift b/GRDB/Core/DatabaseRegionObservation.swift index a831e063b0..4f1e1473c0 100644 --- a/GRDB/Core/DatabaseRegionObservation.swift +++ b/GRDB/Core/DatabaseRegionObservation.swift @@ -161,6 +161,11 @@ private class DatabaseRegionObserver: TransactionObserver { region.isModified(byEventsOfKind: eventKind) } + func databaseDidChange() { + isChanged = true + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if region.isModified(by: event) { isChanged = true diff --git a/GRDB/Core/TransactionObserver.swift b/GRDB/Core/TransactionObserver.swift index 5330dfb5d5..54459cc379 100644 --- a/GRDB/Core/TransactionObserver.swift +++ b/GRDB/Core/TransactionObserver.swift @@ -282,6 +282,20 @@ class DatabaseObservationBroker { } } + 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 @@ -782,6 +796,13 @@ public protocol TransactionObserver: AnyObject { /// 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 + /// and ``Database/notifyChanges(in:)`` for more information. + func databaseDidChange() + /// Called when the database is changed by an insert, update, or /// delete event. /// @@ -857,6 +878,9 @@ extension TransactionObserver { 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. /// @@ -889,7 +913,7 @@ extension TransactionObserver { guard let broker = SchedulingWatchdog.current?.databaseObservationBroker else { fatalError(""" stopObservingDatabaseChangesUntilNextTransaction must be called \ - from the databaseDidChange method + from the `databaseDidChange()` or `databaseDidChange(with:)` methods """) } broker.disableUntilNextTransaction(transactionObserver: self) @@ -942,6 +966,11 @@ final class TransactionObservation { } #endif + func databaseDidChange() { + guard isEnabled else { return } + observer?.databaseDidChange() + } + func databaseDidChange(with event: DatabaseEvent) { guard isEnabled else { return } observer?.databaseDidChange(with: event) diff --git a/GRDB/Documentation.docc/DatabaseSharing.md b/GRDB/Documentation.docc/DatabaseSharing.md index 3cc64120ec..1f2c3928ec 100644 --- a/GRDB/Documentation.docc/DatabaseSharing.md +++ b/GRDB/Documentation.docc/DatabaseSharing.md @@ -228,9 +228,7 @@ In applications that use the background modes supported by iOS, post `resumeNoti features are not able to detect database changes performed by other processes. -Whenever you need to notify other processes that the database has been changed, you will have to use a cross-process notification mechanism such as [NSFileCoordinator] or [CFNotificationCenterGetDarwinNotifyCenter]. - -You can trigger those notifications automatically with ``DatabaseRegionObservation``: +Whenever you need to notify other processes that the database has been changed, you will have to use a cross-process notification mechanism such as [NSFileCoordinator] or [CFNotificationCenterGetDarwinNotifyCenter]. You can trigger those notifications automatically with ``DatabaseRegionObservation``: ```swift // Notify all changes made to the database @@ -246,6 +244,8 @@ let observer = try observation.start(in: dbPool) { db in } ``` +The processes that observe the database can catch those notifications, and deal with the notified changes. See for some related techniques. + [NSFileCoordinator]: https://developer.apple.com/documentation/foundation/nsfilecoordinator [CFNotificationCenterGetDarwinNotifyCenter]: https://developer.apple.com/documentation/corefoundation/1542572-cfnotificationcentergetdarwinnot [WAL mode]: https://www.sqlite.org/wal.html diff --git a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md index 01c1e1f066..a07bb89708 100644 --- a/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md +++ b/GRDB/Documentation.docc/Extension/DatabaseRegionObservation.md @@ -9,6 +9,7 @@ The only changes that are not notified are: - Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. - Changes to the database schema, changes to internal system tables such as `sqlite_master`. - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. diff --git a/GRDB/Documentation.docc/Extension/TransactionObserver.md b/GRDB/Documentation.docc/Extension/TransactionObserver.md index 026833eac8..7d97612860 100644 --- a/GRDB/Documentation.docc/Extension/TransactionObserver.md +++ b/GRDB/Documentation.docc/Extension/TransactionObserver.md @@ -25,13 +25,7 @@ Database changes are notified to the ``databaseDidChange(with:)`` callback. This Transaction completions are notified to the ``databaseWillCommit()-7mksu``, ``databaseDidCommit(_:)`` and ``databaseDidRollback(_:)`` callbacks. -The only changes and transactions that are not notified are: - -- Read-only transactions. -- Changes and transactions performed by external database connections. -- Changes to the database schema, changes to internal system tables such as `sqlite_master`. -- Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. -- The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). +> Important: Some changes and transactions are not automatically notified. See below. 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: @@ -189,7 +183,7 @@ class PlayerObserver: TransactionObserver { } ``` -### Support for SQLite Pre-Update Hooks +## 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: @@ -235,6 +229,45 @@ This extra API can be activated in two ways: 2. Use a [custom SQLite build](http://github.com/groue/GRDB.swift/blob/master/Documentation/CustomSQLiteBuilds.md) and activate the `SQLITE_ENABLE_PREUPDATE_HOOK` compilation option. +## Dealing with Undetected Changes + +Some changes and transactions are not automatically notified to transaction observers: + +- 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`](https://www.sqlite.org/withoutrowid.html) tables. +- The deletion of duplicate rows triggered by [`ON CONFLICT REPLACE`](https://www.sqlite.org/lang_conflict.html) clauses (this last exception might change in a future release of SQLite). + +Undetected changes are notified to the ``databaseDidChange()-7olv7`` callback when you perform an explicit call to the ``Database/notifyChanges(in:)`` `Database` method. + +Event filtering described in still applies: the `databaseDidChange()` callback is not called for changes that are not observed. + +For example: + +```swift +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: + +```swift +try dbQueue.write { db in + // Notify all observers of the sqlite_master table + try db.notifyChanges(in: Table("sqlite_master")) +} +``` + ## Topics ### Filtering Database Changes @@ -244,6 +277,7 @@ This extra API can be activated in two ways: ### Handling Database Changes +- ``databaseDidChange()-7olv7`` - ``databaseDidChange(with:)`` - ``stopObservingDatabaseChangesUntilNextTransaction()`` - ``DatabaseEvent`` diff --git a/GRDB/Documentation.docc/Extension/ValueObservation.md b/GRDB/Documentation.docc/Extension/ValueObservation.md index 4b61af0959..f1891e9d2d 100644 --- a/GRDB/Documentation.docc/Extension/ValueObservation.md +++ b/GRDB/Documentation.docc/Extension/ValueObservation.md @@ -8,7 +8,8 @@ The only changes that are not notified are: -- Changes performed by external database connections. See for more information. +- Changes performed by external database connections. +- Changes performed by SQLite statements that are not compiled and executed by GRDB. - Changes to the database schema, changes to internal system tables such as `sqlite_master`. - Changes to [`WITHOUT ROWID`](https://www.sqlite.org/withoutrowid.html) tables. diff --git a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift index d9a81fd509..78cc9352cb 100644 --- a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift @@ -710,6 +710,13 @@ extension ValueConcurrentObserver: TransactionObserver { } } + func databaseDidChange() { + // Database was modified! + observationState.isModified = true + // We can stop observing the current transaction + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if let region = observationState.region, region.isModified(by: event) { // Database was modified! diff --git a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift index c81ac1bcc0..ea084cce88 100644 --- a/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueWriteOnlyObserver.swift @@ -345,6 +345,13 @@ extension ValueWriteOnlyObserver: TransactionObserver { } } + func databaseDidChange() { + // Database was modified! + observationState.isModified = true + // We can stop observing the current transaction + stopObservingDatabaseChangesUntilNextTransaction() + } + func databaseDidChange(with event: DatabaseEvent) { if let region = observationState.region, region.isModified(by: event) { // Database was modified! diff --git a/Tests/GRDBTests/DatabaseRegionObservationTests.swift b/Tests/GRDBTests/DatabaseRegionObservationTests.swift index bec4f9cf9f..dd4218a00a 100644 --- a/Tests/GRDBTests/DatabaseRegionObservationTests.swift +++ b/Tests/GRDBTests/DatabaseRegionObservationTests.swift @@ -224,6 +224,42 @@ class DatabaseRegionObservationTests: GRDBTestCase { XCTAssertEqual(count, 1) } } + + func test_DatabaseRegionObservation_is_triggered_by_explicit_change_notification() throws { + let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue1.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let undetectedExpectation = expectation(description: "undetected") + undetectedExpectation.isInverted = true + + let detectedExpectation = expectation(description: "detected") + + let observation = DatabaseRegionObservation(tracking: Table("test")) + let cancellable = observation.start( + in: dbQueue1, + onError: { error in XCTFail("Unexpected error: \(error)") }, + onChange: { _ in + undetectedExpectation.fulfill() + detectedExpectation.fulfill() + }) + + try withExtendedLifetime(cancellable) { + // Change performed from external connection is not detected... + let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue2.write { db in + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + } + wait(for: [undetectedExpectation], timeout: 2) + + // ... until we perform an explicit change notification + try dbQueue1.write { db in + try db.notifyChanges(in: Table("test")) + } + wait(for: [detectedExpectation], timeout: 2) + } + } // Regression test for https://github.com/groue/GRDB.swift/issues/514 // TODO: uncomment and make this test pass. diff --git a/Tests/GRDBTests/TransactionObserverTests.swift b/Tests/GRDBTests/TransactionObserverTests.swift index 730edc1030..0730f42f29 100644 --- a/Tests/GRDBTests/TransactionObserverTests.swift +++ b/Tests/GRDBTests/TransactionObserverTests.swift @@ -22,12 +22,14 @@ private class Observer : TransactionObserver { } var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { didChangeCount = 0 + didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 didRollbackCount = 0 @@ -50,8 +52,12 @@ private class Observer : TransactionObserver { observesBlock(eventKind) } - func databaseDidChange(with event: DatabaseEvent) { + func databaseDidChange() { didChangeCount += 1 + } + + func databaseDidChange(with event: DatabaseEvent) { + didChangeWithEventCount += 1 events.append(event.copy()) } @@ -796,7 +802,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -826,7 +833,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif - XCTAssertEqual(observer.didChangeCount, 3) // 3 deletes + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -894,7 +902,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -907,7 +916,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -917,7 +927,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -929,6 +940,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1003,7 +1015,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) // 3 deletes #endif - XCTAssertEqual(observer.didChangeCount, 3) // 3 deletes + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 3) // 3 deletes XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1015,6 +1028,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1133,6 +1147,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1155,6 +1170,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1168,6 +1184,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1192,6 +1209,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1206,6 +1224,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1229,7 +1248,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1260,7 +1280,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1285,6 +1306,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1298,6 +1320,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1323,6 +1346,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1337,6 +1361,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 1) @@ -1369,7 +1394,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1479,6 +1505,7 @@ class TransactionObserverTests: GRDBTestCase { XCTAssertEqual(observer.willChangeCount, 0) #endif XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1504,6 +1531,7 @@ class TransactionObserverTests: GRDBTestCase { } XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1522,7 +1550,8 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 3) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1550,7 +1579,8 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1578,7 +1608,8 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1613,7 +1644,8 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1646,7 +1678,8 @@ class TransactionObserverTests: GRDBTestCase { return .commit } - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1660,12 +1693,14 @@ class TransactionObserverTests: GRDBTestCase { class Observer: TransactionObserver { var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 var willCommitCount: Int = 0 var didCommitCount: Int = 0 var didRollbackCount: Int = 0 func resetCounts() { didChangeCount = 0 + didChangeWithEventCount = 0 willCommitCount = 0 didCommitCount = 0 didRollbackCount = 0 @@ -1681,8 +1716,12 @@ class TransactionObserverTests: GRDBTestCase { func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } - func databaseDidChange(with event: DatabaseEvent) { + func databaseDidChange() { didChangeCount += 1 + } + + func databaseDidChange(with event: DatabaseEvent) { + didChangeWithEventCount += 1 if event.tableName == "ignore" { stopObservingDatabaseChangesUntilNextTransaction() } @@ -1720,7 +1759,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1739,7 +1779,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 1) #endif - XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 1) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1758,7 +1799,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 2) #endif - XCTAssertEqual(observer.didChangeCount, 2) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 2) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1777,7 +1819,8 @@ class TransactionObserverTests: GRDBTestCase { #if SQLITE_ENABLE_PREUPDATE_HOOK XCTAssertEqual(observer.willChangeCount, 3) #endif - XCTAssertEqual(observer.didChangeCount, 3) + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 3) XCTAssertEqual(observer.willCommitCount, 1) XCTAssertEqual(observer.didCommitCount, 1) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1785,6 +1828,687 @@ class TransactionObserverTests: GRDBTestCase { } } + // MARK: - Unspecified changes + + func testUnspecifiedChangeInFullDatabase() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + do { + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { _ in false }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return true + case .update: + return false + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return true + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return false + case .delete: + return true + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "non_existing" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeInEmptyRegion() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: DatabaseRegion()) + } + + // No change detected because changed region is empty + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + func testUnspecifiedChangeInEmptyDatabase() throws { + let dbQueue = try makeDatabaseQueue() + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + // No change detected because there is no table + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + func testUnspecifiedChange_sqlite_master() throws { + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + // Undetected because the full database region does not include sqlite_master + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("sqlite_master")) + } + + // Detected because explicit sqlite_master region + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChange_sqlite_temp_master() throws { + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_temp_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("sqlite_temp_master")) + } + + // Undetected because there is no temp schema + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let dbQueue = try makeDatabaseQueue() + let observer = Observer(observes: { eventKind in + eventKind.tableName == "sqlite_temp_master" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Create temp schema + try db.execute(sql: "CREATE TEMPORARY TABLE t(a)") + + // Explicit sqlite_temp_master + try db.notifyChanges(in: Table("sqlite_temp_master")) + } + + // Detected because the temp schema exists. + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToTable() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + do { + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { _ in false }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return true + case .update: + return false + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return true + case .delete: + return false + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + switch eventKind { + case .insert: + return false + case .update: + return false + case .delete: + return true + } + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artists" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Case insensitivity (observer has to use the canonical name). + try db.notifyChanges(in: Table("ARTISTS")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "artworks" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("artists")) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToColumn() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.create(table: "test") { t in + t.autoIncrementedPrimaryKey("id") + t.column("a") + t.column("b") + } + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test").select(Column("a"))) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + // Case insensitivity + try db.notifyChanges(in: Table("TEST").select(Column("A"))) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + if case .update("test", let columns) = eventKind, columns.contains("a") { + return true + } + return false + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test").select(Column("b"))) + } + + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeToTemporaryTable() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.create(table: "test", options: .temporary) { t in + t.autoIncrementedPrimaryKey("id") + } + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "test" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: .fullDatabase) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + do { + let observer = Observer(observes: { eventKind in + eventKind.tableName == "test" + }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.write { db in + try db.notifyChanges(in: Table("test")) + } + + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + } + + func testUnspecifiedChangeFromReadOnlyAccess() throws { + let dbQueue = try makeDatabaseQueue() + try setupArtistDatabase(in: dbQueue) + + let observer = Observer(observes: { _ in true }) + dbQueue.add(transactionObserver: observer) + + try dbQueue.read { db in + try db.notifyChanges(in: .fullDatabase) + } + + // No change detected from read-only access + XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) + XCTAssertEqual(observer.willCommitCount, 0) + XCTAssertEqual(observer.didCommitCount, 0) + XCTAssertEqual(observer.didRollbackCount, 0) + XCTAssertEqual(observer.lastCommittedEvents.count, 0) + } + + func test_stopObservingDatabaseChangesUntilNextTransaction_from_databaseDidChange() throws { + class Observer: TransactionObserver { + var didChangeCount: Int = 0 + var didChangeWithEventCount: Int = 0 + var willCommitCount: Int = 0 + var didCommitCount: Int = 0 + var didRollbackCount: Int = 0 + + #if SQLITE_ENABLE_PREUPDATE_HOOK + var willChangeCount: Int = 0 + func databaseWillChange(with event: DatabasePreUpdateEvent) { willChangeCount += 1 } + #endif + + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } + + func databaseDidChange() { + didChangeCount += 1 + stopObservingDatabaseChangesUntilNextTransaction() + } + + func databaseDidChange(with event: DatabaseEvent) { + didChangeWithEventCount += 1 + } + + func databaseWillCommit() throws { willCommitCount += 1 } + func databaseDidCommit(_ db: Database) { didCommitCount += 1 } + func databaseDidRollback(_ db: Database) { didRollbackCount += 1 } + } + + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let observer = Observer() + dbQueue.add(transactionObserver: observer, extent: .databaseLifetime) + + try dbQueue.write { db in + // detected + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + try db.notifyChanges(in: .fullDatabase) + // ignored + try db.execute(sql: "INSERT INTO test (a) VALUES (2)") + } + + #if SQLITE_ENABLE_PREUPDATE_HOOK + XCTAssertEqual(observer.willChangeCount, 1) + #endif + XCTAssertEqual(observer.didChangeCount, 1) + XCTAssertEqual(observer.didChangeWithEventCount, 1) + XCTAssertEqual(observer.willCommitCount, 1) + XCTAssertEqual(observer.didCommitCount, 1) + XCTAssertEqual(observer.didRollbackCount, 0) + } + // MARK: - Read-Only Connection func testReadOnlyConnection() throws { @@ -1804,6 +2528,7 @@ class TransactionObserverTests: GRDBTestCase { COMMIT; """) XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1819,6 +2544,7 @@ class TransactionObserverTests: GRDBTestCase { COMMIT; """) XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1844,6 +2570,7 @@ class TransactionObserverTests: GRDBTestCase { COMMIT; """) XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) @@ -1859,6 +2586,7 @@ class TransactionObserverTests: GRDBTestCase { COMMIT; """) XCTAssertEqual(observer.didChangeCount, 0) + XCTAssertEqual(observer.didChangeWithEventCount, 0) XCTAssertEqual(observer.willCommitCount, 0) XCTAssertEqual(observer.didCommitCount, 0) XCTAssertEqual(observer.didRollbackCount, 0) diff --git a/Tests/GRDBTests/ValueObservationTests.swift b/Tests/GRDBTests/ValueObservationTests.swift index 31510bee78..a0d04fb5ba 100644 --- a/Tests/GRDBTests/ValueObservationTests.swift +++ b/Tests/GRDBTests/ValueObservationTests.swift @@ -523,6 +523,49 @@ class ValueObservationTests: GRDBTestCase { } #endif + // MARK: - Unspecified Changes + + func test_ValueObservation_is_triggered_by_explicit_change_notification() throws { + let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue1.write { db in + try db.execute(sql: "CREATE TABLE test(a)") + } + + let undetectedExpectation = expectation(description: "undetected") + undetectedExpectation.expectedFulfillmentCount = 2 // initial value and change + undetectedExpectation.isInverted = true + + let detectedExpectation = expectation(description: "detected") + detectedExpectation.expectedFulfillmentCount = 2 // initial value and change + + let observation = ValueObservation.tracking { db in + try Table("test").fetchCount(db) + } + let cancellable = observation.start( + in: dbQueue1, + scheduling: .immediate, + onError: { error in XCTFail("Unexpected error: \(error)") }, + onChange: { _ in + undetectedExpectation.fulfill() + detectedExpectation.fulfill() + }) + + try withExtendedLifetime(cancellable) { + // Change performed from external connection is not detected... + let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite") + try dbQueue2.write { db in + try db.execute(sql: "INSERT INTO test (a) VALUES (1)") + } + wait(for: [undetectedExpectation], timeout: 2) + + // ... until we perform an explicit change notification + try dbQueue1.write { db in + try db.notifyChanges(in: Table("test")) + } + wait(for: [detectedExpectation], timeout: 2) + } + } + // MARK: - Cancellation func testCancellableLifetime() throws {