import XCTest import Dispatch @testable import GRDB class ValueObservationTests: GRDBTestCase { // Test passes if it compiles. // See func testStartFromAnyDatabaseReader(reader: any DatabaseReader) { _ = ValueObservation .trackingConstantRegion { _ in } .start(in: reader, onError: { _ in }, onChange: { }) } // Test passes if it compiles. // See func testStartFromAnyDatabaseWriter(writer: any DatabaseWriter) { _ = ValueObservation .trackingConstantRegion { _ in } .start(in: writer, onError: { _ in }, onChange: { }) } // Test passes if it compiles. // See @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) func testValuesFromAnyDatabaseWriter(writer: any DatabaseWriter) { func observe( fetch: @Sendable @escaping (Database) throws -> T ) throws -> AsyncValueObservation { ValueObservation.tracking(fetch).values(in: writer) } } func testImmediateError() throws { struct TestError: Error { } func test(_ dbWriter: some DatabaseWriter) throws { // Create an observation let observation = ValueObservation.trackingConstantRegion { _ in throw TestError() } // Start observation var error: TestError? _ = observation.start( in: dbWriter, scheduling: .immediate, onError: { error = $0 as? TestError }, onChange: { _ in }) XCTAssertNotNil(error) } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func testErrorCompletesTheObservation() throws { struct TestError: Error { } func test(_ dbWriter: some DatabaseWriter) throws { // We need something to change try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Track reducer process let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 4 notificationExpectation.isInverted = true var nextError: Error? = nil // If not null, observation throws an error let observation = ValueObservation.trackingConstantRegion { _ = try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t") if let error = nextError { throw error } } // Start observation var errorCaught = false let cancellable = observation.start( in: dbWriter, onError: { _ in errorCaught = true notificationExpectation.fulfill() }, onChange: { XCTAssertFalse(errorCaught) nextError = TestError() notificationExpectation.fulfill() // Trigger another change try! dbWriter.writeWithoutTransaction { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertTrue(errorCaught) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func testViewOptimization() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); CREATE VIEW v AS SELECT * FROM t """) } // Test that view v is included in the request region let request = SQLRequest(sql: "SELECT name FROM v ORDER BY id") try dbQueue.inDatabase { db in let region = try request.databaseRegion(db) XCTAssertEqual(region.description, "t(id,name),v(id,name)") } // Test that view v is not included in the observed region. // This optimization helps observation of views that feed from a // single table. var region: DatabaseRegion? let expectation = self.expectation(description: "") let observation = ValueObservation .trackingConstantRegion(request.fetchAll) .handleEvents(willTrackRegion: { region = $0 expectation.fulfill() }) let observer = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(region!.description, "t(id,name)") // view is NOT tracked } } func testPragmaTableOptimization() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); """) } struct T: TableRecord { } // A request that requires a pragma introspection query let request = T.filter(key: 1).asRequest(of: Row.self) // Test that no pragma table is included in the observed region. // This optimization helps observation that feed from a single table. var region: DatabaseRegion? let expectation = self.expectation(description: "") let observation = ValueObservation .trackingConstantRegion(request.fetchAll) .handleEvents(willTrackRegion: { region = $0 expectation.fulfill() }) let observer = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(region!.description, "t(id,name)[1]") // pragma_table_xinfo is NOT tracked } } // MARK: - Constant Explicit Region func testTrackingExplicitRegion() throws { class TestStream: TextOutputStream { @LockedBox var strings: [String] = [] func write(_ string: String) { strings.append(string) } } // Test behavior with DatabaseQueue, DatabasePool, etc do { try assertValueObservation( ValueObservation .tracking(region: DatabaseRegion.fullDatabase, fetch: Table("t").fetchCount), records: [0, 1, 1, 2, 3, 4], setup: { db in try db.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT); """) }, recordedUpdates: { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") try db.execute(sql: "UPDATE t SET id = id") try db.execute(sql: "INSERT INTO t DEFAULT VALUES") try db.inTransaction { try db.execute(sql: "INSERT INTO t DEFAULT VALUES") try db.execute(sql: "INSERT INTO t DEFAULT VALUES") try db.execute(sql: "DELETE FROM t WHERE id = 1") return .commit } try db.execute(sql: "INSERT INTO t DEFAULT VALUES") }) } // Track only table "t" do { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT); CREATE TABLE other(id INTEGER PRIMARY KEY AUTOINCREMENT); """) } let logger = TestStream() let observation = ValueObservation.tracking( region: Table("t"), fetch: Table("t").fetchCount) .print(to: logger) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in expectation.fulfill() }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in try db.execute(sql: """ INSERT INTO other DEFAULT VALUES; INSERT INTO t DEFAULT VALUES; """) } wait(for: [expectation], timeout: 4) XCTAssertEqual(logger.strings, [ "start", "fetch", "tracked region: t(*)", "value: 0", "database did change", "fetch", "value: 1", ]) } } // Track only table "other" do { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT); CREATE TABLE other(id INTEGER PRIMARY KEY AUTOINCREMENT); """) } let logger = TestStream() let observation = ValueObservation.tracking( region: Table("other"), fetch: Table("t").fetchCount) .print(to: logger) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in expectation.fulfill() }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in try db.execute(sql: """ INSERT INTO other DEFAULT VALUES; INSERT INTO t DEFAULT VALUES; """) } wait(for: [expectation], timeout: 4) XCTAssertEqual(logger.strings, [ "start", "fetch", "tracked region: other(*)", "value: 0", "database did change", "fetch", "value: 0", ]) } } // Track both "t" and "other" do { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: """ CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT); CREATE TABLE other(id INTEGER PRIMARY KEY AUTOINCREMENT); """) } let logger = TestStream() let observation = ValueObservation.tracking( region: Table("t"), Table("other"), fetch: Table("t").fetchCount) .print(to: logger) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 3 let cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in expectation.fulfill() }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in try db.execute(sql: """ INSERT INTO other DEFAULT VALUES; INSERT INTO t DEFAULT VALUES; """) } wait(for: [expectation], timeout: 4) XCTAssertEqual(logger.strings, [ "start", "fetch", "tracked region: other(*),t(*)", "value: 0", "database did change", "fetch", "value: 0", "database did change", "fetch", "value: 1", ]) } } } // MARK: - Snapshot Optimization func testDisallowedSnapshotOptimizationWithAsyncScheduler() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. var needsChange = true let observation = ValueObservation.trackingConstantRegion { db -> Int in if needsChange { needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; DELETE FROM t; """) } } return try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")! } let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 var observedCounts: [Int] = [] let cancellable = observation.start( in: dbPool, scheduling: .async(onQueue: .main), onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in observedCounts.append(count) expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(observedCounts, [0, 0]) } } func testDisallowedSnapshotOptimizationWithImmediateScheduler() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. var needsChange = true let observation = ValueObservation.trackingConstantRegion { db -> Int in if needsChange { needsChange = false try dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; DELETE FROM t; """) } } return try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")! } let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 var observedCounts: [Int] = [] let cancellable = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in observedCounts.append(count) expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(observedCounts, [0, 0]) } } func testAllowedSnapshotOptimizationWithAsyncScheduler() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Allow pool to perform a single initial fetch, because between // its first read access, and its write access that installs the // transaction observer, no write did happen. var needsChange = true let observation = ValueObservation.trackingConstantRegion { db -> Int in if needsChange { needsChange = false DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; """) } } } return try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")! } let expectedCounts: [Int] #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) // Optimization available expectedCounts = [0, 1] #else // Optimization not available expectedCounts = [0, 0, 1] #endif let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count var observedCounts: [Int] = [] let cancellable = observation.start( in: dbPool, scheduling: .async(onQueue: .main), onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in observedCounts.append(count) expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(observedCounts, expectedCounts) } } func testAllowedSnapshotOptimizationWithImmediateScheduler() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Allow pool to perform a single initial fetch, because between // its first read access, and its write access that installs the // transaction observer, no write did happen. var needsChange = true let observation = ValueObservation.trackingConstantRegion { db -> Int in if needsChange { needsChange = false DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in try db.execute(sql: """ INSERT INTO t DEFAULT VALUES; """) } } } return try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")! } let expectedCounts: [Int] #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) // Optimization available expectedCounts = [0, 1] #else // Optimization not available expectedCounts = [0, 0, 1] #endif let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count var observedCounts: [Int] = [] let cancellable = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in observedCounts.append(count) expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(observedCounts, expectedCounts) } } // MARK: - Snapshot Observation #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) func testDatabaseSnapshotPoolObservation() throws { let dbPool = try makeDatabasePool() try dbPool.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let expectation = XCTestExpectation() expectation.assertForOverFulfill = false let observation = ValueObservation.trackingConstantRegion { db in try db.registerAccess(to: Table("t")) expectation.fulfill() return try DatabaseSnapshotPool(db) } let recorder = observation.record(in: dbPool) wait(for: [expectation], timeout: 5) try dbPool.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } let results = try wait(for: recorder.next(2), timeout: 5) XCTAssertEqual(results.count, 2) try XCTAssertEqual(results[0].read { try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t")! }, 0) try XCTAssertEqual(results[1].read { try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t")! }, 1) } #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 { // We need something to change let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Track reducer process var changesCount = 0 let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 2 // Create an observation let observation = ValueObservation.trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT * FROM t") } // Start observation and deallocate cancellable after second change var cancellable: (any DatabaseCancellable)? cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in changesCount += 1 if changesCount == 2 { cancellable = nil } notificationExpectation.fulfill() }) // notified try dbQueue.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } // not notified try dbQueue.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } // Avoid "Variable 'cancellable' was written to, but never read" warning _ = cancellable waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(changesCount, 2) } func testCancellableExplicitCancellation() throws { // We need something to change let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Track reducer process var changesCount = 0 let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 2 // Create an observation let observation = ValueObservation.trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT * FROM t") } // Start observation and cancel cancellable after second change var cancellable: (any DatabaseCancellable)! cancellable = observation.start( in: dbQueue, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in changesCount += 1 if changesCount == 2 { cancellable.cancel() } notificationExpectation.fulfill() }) try withExtendedLifetime(cancellable) { // notified try dbQueue.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } // not notified try dbQueue.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } waitForExpectations(timeout: 2, handler: nil) XCTAssertEqual(changesCount, 2) } } func testCancellableInvalidation1() throws { // Test that observation stops when cancellable is deallocated func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let notificationExpectation = expectation(description: "notification") notificationExpectation.isInverted = true notificationExpectation.expectedFulfillmentCount = 2 do { var cancellable: (any DatabaseCancellable)? = nil _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning var shouldStopObservation = false let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, makeReducer: { AnyValueReducer( fetch: { _ in if shouldStopObservation { cancellable = nil /* deallocation */ } shouldStopObservation = true }, value: { _ in () }) }) cancellable = observation.start( in: dbWriter, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in notificationExpectation.fulfill() }) } try dbWriter.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } waitForExpectations(timeout: 2, handler: nil) } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func testCancellableInvalidation2() throws { // Test that observation stops when cancellable is deallocated func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let notificationExpectation = expectation(description: "notification") notificationExpectation.isInverted = true notificationExpectation.expectedFulfillmentCount = 2 do { var cancellable: (any DatabaseCancellable)? = nil _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning var shouldStopObservation = false let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, makeReducer: { AnyValueReducer( fetch: { _ in }, value: { _ in if shouldStopObservation { cancellable = nil /* deallocation right before notification */ } shouldStopObservation = true return () }) }) cancellable = observation.start( in: dbWriter, scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { _ in notificationExpectation.fulfill() }) } try dbWriter.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } waitForExpectations(timeout: 2, handler: nil) } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func testIssue1550() throws { func test(_ writer: some DatabaseWriter) throws { try writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // Start observing var counts: [Int] = [] let cancellable = ValueObservation .trackingConstantRegion { try Table("t").fetchCount($0) } .start(in: writer) { error in XCTFail("Unexpected error: \(error)") } onChange: { count in counts.append(count) } // Perform a write after cancellation, but before the // observation could schedule the removal of its transaction // observer from the writer dispatch queue. let semaphore = DispatchSemaphore(value: 0) writer.asyncWriteWithoutTransaction { db in semaphore.wait() do { try db.execute(sql: "INSERT INTO t(id) VALUES (NULL)") } catch { XCTFail("Unexpected error: \(error)") } } cancellable.cancel() semaphore.signal() // Wait until a second write could run, after the observation // has removed its transaction observer from the writer // dispatch queue. let secondWriteExpectation = expectation(description: "cancelled") writer.asyncWriteWithoutTransaction { _ in secondWriteExpectation.fulfill() } wait(for: [secondWriteExpectation], timeout: 5) // We should not have been notified of the first write, because // it was performed after cancellation. XCTAssertFalse(counts.contains(1)) } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func testIssue1209() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } // We'll start N observations let N = 100 // We'll wait for initial value notification before modifying the database let initialValueExpectation = expectation(description: "") initialValueExpectation.expectedFulfillmentCount = N // The test will pass if we get change notifications let changeExpectation = expectation(description: "") changeExpectation.expectedFulfillmentCount = N // Observe N times let cancellables = (0.. [Int] in var counts: [Int] = [] let observation = ValueObservation .trackingConstantRegion(Table("t").fetchCount) .handleEvents(didCancel: { cancellationExpectation.fulfill() }) for try await count in try observation.values(in: writer).prefix(while: { $0 <= 3 }) { counts.append(count) try await writer.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } } return counts } let counts = try await task.value XCTAssertTrue(counts.contains(0)) XCTAssertTrue(counts.contains(where: { $0 >= 2 })) XCTAssertEqual(counts.sorted(), counts) // Observation was ended #if compiler(>=5.8) await fulfillment(of: [cancellationExpectation], timeout: 2) #else wait(for: [cancellationExpectation], timeout: 2) #endif } try await AsyncTest(test).run { try DatabaseQueue() } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } } @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) func testAsyncAwait_values_prefix_immediate_scheduling() async throws { func test(_ writer: some DatabaseWriter) async throws { // We need something to change try await writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let cancellationExpectation = expectation(description: "cancelled") let task = Task { @MainActor () -> [Int] in var counts: [Int] = [] let observation = ValueObservation .trackingConstantRegion(Table("t").fetchCount) .handleEvents(didCancel: { cancellationExpectation.fulfill() }) for try await count in try observation.values(in: writer, scheduling: .immediate).prefix(while: { $0 <= 3 }) { counts.append(count) try await writer.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } } return counts } let counts = try await task.value XCTAssertTrue(counts.contains(0)) XCTAssertTrue(counts.contains(where: { $0 >= 2 })) XCTAssertEqual(counts.sorted(), counts) // Observation was ended #if compiler(>=5.8) await fulfillment(of: [cancellationExpectation], timeout: 2) #else wait(for: [cancellationExpectation], timeout: 2) #endif } try await AsyncTest(test).run { try DatabaseQueue() } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } } @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) func testAsyncAwait_values_break() async throws { func test(_ writer: some DatabaseWriter) async throws { // We need something to change try await writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let cancellationExpectation = expectation(description: "cancelled") let task = Task { () -> [Int] in var counts: [Int] = [] let observation = ValueObservation .trackingConstantRegion(Table("t").fetchCount) .handleEvents(didCancel: { cancellationExpectation.fulfill() }) for try await count in observation.values(in: writer) { counts.append(count) if count > 3 { break } else { try await writer.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } } } return counts } let counts = try await task.value XCTAssertTrue(counts.contains(0)) XCTAssertTrue(counts.contains(where: { $0 >= 2 })) XCTAssertEqual(counts.sorted(), counts) // Observation was ended #if compiler(>=5.8) await fulfillment(of: [cancellationExpectation], timeout: 2) #else wait(for: [cancellationExpectation], timeout: 2) #endif } try await AsyncTest(test).run { try DatabaseQueue() } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } } @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) func testAsyncAwait_values_immediate_break() async throws { func test(_ writer: some DatabaseWriter) async throws { // We need something to change try await writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let cancellationExpectation = expectation(description: "cancelled") let task = Task { @MainActor () -> [Int] in var counts: [Int] = [] let observation = ValueObservation .trackingConstantRegion(Table("t").fetchCount) .handleEvents(didCancel: { cancellationExpectation.fulfill() }) for try await count in observation.values(in: writer, scheduling: .immediate) { counts.append(count) break } return counts } let counts = try await task.value // A single value was published assertValueObservationRecordingMatch(recorded: counts, expected: [0]) // Observation was ended #if compiler(>=5.8) await fulfillment(of: [cancellationExpectation], timeout: 2) #else wait(for: [cancellationExpectation], timeout: 2) #endif } try await AsyncTest(test).run { try DatabaseQueue() } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } } @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) func testAsyncAwait_values_cancelled() async throws { func test(_ writer: some DatabaseWriter) async throws { // We need something to change try await writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let cancellationExpectation = expectation(description: "cancelled") // Launch a task that we'll cancel let cancelledTask = Task { // Loops until cancelled let observation = ValueObservation.trackingConstantRegion(Table("t").fetchCount) let cancelledObservation = observation.handleEvents(didCancel: { cancellationExpectation.fulfill() }) for try await _ in cancelledObservation.values(in: writer) { } return "cancelled loop" } // Lanch the task that cancels Task { let observation = ValueObservation.trackingConstantRegion(Table("t").fetchCount) for try await count in observation.values(in: writer) { if count >= 3 { cancelledTask.cancel() break } else { try await writer.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } } } } // Make sure loop has ended in the cancelled task let cancelledValue = try await cancelledTask.value XCTAssertEqual(cancelledValue, "cancelled loop") // Make sure observation was cancelled as well #if compiler(>=5.8) await fulfillment(of: [cancellationExpectation], timeout: 2) #else wait(for: [cancellationExpectation], timeout: 2) #endif } try await AsyncTest(test).run { try DatabaseQueue() } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } } // An attempt at finding a regression test for func testManyObservations() throws { // We'll start many observations let observationCount = 100 dbConfiguration.maximumReaderCount = 5 func test(_ writer: some DatabaseWriter, scheduling scheduler: some ValueObservationScheduler) throws { try writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let observation = ValueObservation.tracking { try Table("t").fetchCount($0) } let initialValueExpectation = self.expectation(description: "") #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) initialValueExpectation.assertForOverFulfill = true #else // ValueObservation on DatabasePool will notify the first value twice initialValueExpectation.assertForOverFulfill = false #endif initialValueExpectation.expectedFulfillmentCount = observationCount let secondValueExpectation = self.expectation(description: "") secondValueExpectation.expectedFulfillmentCount = observationCount var cancellables: [AnyDatabaseCancellable] = [] for _ in 0.. func testManyObservationsWithLongConcurrentWrite() throws { // We'll start many observations let observationCount = 100 dbConfiguration.maximumReaderCount = 5 func test(_ writer: some DatabaseWriter, scheduling scheduler: some ValueObservationScheduler) throws { try writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } let observation = ValueObservation.tracking { return try Table("t").fetchCount($0) } let initialValueExpectation = self.expectation(description: "") #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) initialValueExpectation.assertForOverFulfill = true #else // ValueObservation on DatabasePool will notify the first value twice initialValueExpectation.assertForOverFulfill = false #endif initialValueExpectation.expectedFulfillmentCount = observationCount let secondValueExpectation = self.expectation(description: "") secondValueExpectation.expectedFulfillmentCount = observationCount let semaphore = DispatchSemaphore(value: 0) writer.asyncWriteWithoutTransaction { db in semaphore.signal() Thread.sleep(forTimeInterval: 0.5) } semaphore.wait() var cancellables: [AnyDatabaseCancellable] = [] for _ in 0.. func testIssue1362() throws { func test(_ writer: some DatabaseWriter) throws { try writer.write { try $0.execute(sql: "CREATE TABLE s(id INTEGER PRIMARY KEY AUTOINCREMENT)") } var cancellables = [AnyDatabaseCancellable]() // Start an observation and wait until it has installed its // transaction observer. let installedExpectation = expectation(description: "transaction observer installed") let finalExpectation = expectation(description: "final value") let initialObservation = ValueObservation.trackingConstantRegion(Table("s").fetchCount) let cancellable = initialObservation.start( in: writer, // Immediate initial value so that the next value comes // from the write access that installs the transaction observer. scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, onChange: { count in if count == 1 { installedExpectation.fulfill() } if count == 2 { finalExpectation.fulfill() } }) cancellables.append(cancellable) try writer.write { try $0.execute(sql: "INSERT INTO s DEFAULT VALUES") } // count = 1 wait(for: [installedExpectation], timeout: 2) // Start a write that will trigger initialObservation when we decide. let semaphore = DispatchSemaphore(value: 0) writer.asyncWriteWithoutTransaction { db in semaphore.wait() do { try db.execute(sql: "INSERT INTO s DEFAULT VALUES") // count = 2 } catch { XCTFail("Unexpected error: \(error)") } } // Start as many observations as there are readers for _ in 0.. func testIssue1383() throws { do { let dbPool = try makeDatabasePool(filename: "test") try dbPool.writeWithoutTransaction { db in try db.execute(sql: "CREATE TABLE t(a)") // Truncate the wal file (size zero) try db.checkpoint(.truncate) } } do { let dbPool = try makeDatabasePool(filename: "test") let observation = ValueObservation.tracking(Table("t").fetchCount) _ = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error \(error)") }, onChange: { _ in }) } } // Regression test for func testIssue1383_async() throws { do { let dbPool = try makeDatabasePool(filename: "test") try dbPool.writeWithoutTransaction { db in try db.execute(sql: "CREATE TABLE t(a)") // Truncate the wal file (size zero) try db.checkpoint(.truncate) } } do { let dbPool = try makeDatabasePool(filename: "test") let observation = ValueObservation.tracking(Table("t").fetchCount) let expectation = self.expectation(description: "completion") expectation.assertForOverFulfill = false let cancellable = observation.start( in: dbPool, onError: { error in XCTFail("Unexpected error \(error)") expectation.fulfill() }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { _ in wait(for: [expectation], timeout: 2) } } } // Regression test for func testIssue1383_createWal() throws { let url = testBundle.url(forResource: "Issue1383", withExtension: "sqlite")! // Delete files created by previous test runs try? FileManager.default.removeItem(at: url.deletingLastPathComponent().appendingPathComponent("Issue1383.sqlite-wal")) try? FileManager.default.removeItem(at: url.deletingLastPathComponent().appendingPathComponent("Issue1383.sqlite-shm")) let dbPool = try DatabasePool(path: url.path) let observation = ValueObservation.tracking(Table("t").fetchCount) _ = observation.start( in: dbPool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error \(error)") }, onChange: { _ in }) } // Regression test for func testIssue1500() throws { let pool = try makeDatabasePool() try pool.read { db in _ = try db.tableExists("t") } try pool.write { db in try db.create(table: "t") { t in t.column("a") } } _ = ValueObservation .trackingConstantRegion { db in try db.tableExists("t") } .start( in: pool, scheduling: .immediate, onError: { error in XCTFail("Unexpected error \(error)") }, onChange: { value in XCTAssertEqual(value, true) }) } }