import XCTest import Dispatch @testable import GRDB class ValueObservationPrintTests: GRDBTestCase { class TestStream: TextOutputStream { @LockedBox var strings: [String] = [] func write(_ string: String) { strings.append(string) } } /// Helps dealing with various SQLite versions private func region(sql: String, in dbReader: some DatabaseReader) throws -> String { try dbReader.read { db in try db .makeStatement(sql: sql) .databaseRegion .description } } // MARK: - Readonly func test_readonly_success_asynchronousScheduling() throws { let dbPool = try makeDatabasePool(filename: "test") try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() let observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "value: nil"]) } } var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) #endif } func test_readonly_success_immediateScheduling() throws { let dbPool = try makeDatabasePool(filename: "test") try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() let observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, scheduling: .immediate, onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "value: nil"]) } } var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) #endif } func test_readonly_failure_asynchronousScheduling() throws { struct TestError: Error { } let dbPool = try makeDatabasePool(filename: "test") // workaround Xcode 14.1 compiler (???) bug: database is not in the WAL // mode unless the pool is used. try dbPool.write { _ in } func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() let observation = ValueObservation .trackingConstantRegion { _ in throw TestError() } .print(to: logger) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, scheduling: .async(onQueue: .main), onError: { _ in expectation.fulfill() }, onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "failure: TestError()"]) } } var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) #endif } func test_readonly_failure_immediateScheduling() throws { struct TestError: Error { } let dbPool = try makeDatabasePool(filename: "test") // workaround Xcode 14.1 compiler (???) bug: database is not in the WAL // mode unless the pool is used. try dbPool.write { _ in } func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() let observation = ValueObservation .trackingConstantRegion { _ in throw TestError() } .print(to: logger) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, scheduling: .immediate, onError: { _ in expectation.fulfill() }, onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "failure: TestError()"]) } } var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst)))) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) #endif } // MARK: - Writeonly func test_writeonly_success_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbWriter, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in try! dbWriter.write { db in try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings.prefix(7), [ "start", "fetch", "tracked region: \(expectedRegion)", "value: nil", "database did change", "fetch", "value: Optional(1)"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func test_writeonly_success_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbWriter, scheduling: .immediate, onError: { _ in }, onChange: { _ in try! dbWriter.write { db in try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings.prefix(7), [ "start", "fetch", "tracked region: \(expectedRegion)", "value: nil", "database did change", "fetch", "value: Optional(1)"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func test_writeonly_immediateFailure_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, scheduling: .async(onQueue: .main), onError: { _ in expectation.fulfill() }, onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func test_writeonly_immediateFailure_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, scheduling: .immediate, onError: { _ in expectation.fulfill() }, onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func test_writeonly_lateFailure_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, scheduling: .async(onQueue: .main), onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbWriter.write { db in try db.execute(sql: """ INSERT INTO player DEFAULT VALUES; DROP TABLE player; """) } }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "tracked region: \(expectedRegion)", "value: nil", "database did change", "fetch", "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } func test_writeonly_lateFailure_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() var observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, scheduling: .immediate, onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbWriter.write { db in try db.execute(sql: """ INSERT INTO player DEFAULT VALUES; DROP TABLE player; """) } }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "tracked region: \(expectedRegion)", "value: nil", "database did change", "fetch", "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) } } try test(makeDatabaseQueue()) try test(makeDatabasePool()) } // MARK: - Concurrent func test_concurrent_success_asynchronousScheduling() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() // 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 player DEFAULT VALUES; DELETE FROM player; """) } } return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM player") } .print(to: logger) let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbPool) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbPool, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) // Order of events may not be stable, because the first // "value: nil" is notified concurrently (from the reduce queue) // with the first "database did change" (from the writer queue). // That's why we test the sorted output. XCTAssertEqual(logger.strings.sorted(), [ "database did change", "fetch", "fetch", "start", "tracked region: \(expectedRegion)", "value: nil", "value: nil"]) } } func test_concurrent_success_immediateScheduling() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger = TestStream() // 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 player DEFAULT VALUES; DELETE FROM player; """) } } return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM player") } .print(to: logger) let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbPool) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbPool, scheduling: .immediate, onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings, [ "start", "fetch", "value: nil", "database did change", "fetch", "tracked region: \(expectedRegion)", "value: nil"]) } } // MARK: - Varying Database Region func test_varyingRegion() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: """ CREATE TABLE a(id INTEGER PRIMARY KEY); CREATE TABLE b(id INTEGER PRIMARY KEY); CREATE TABLE choice(t TEXT); INSERT INTO choice (t) VALUES ('a'); """) } let logger = TestStream() let observation = ValueObservation .tracking { db -> Int? in let table = try String.fetchOne(db, sql: "SELECT t FROM choice")! return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM \(table)") } .print(to: logger) let expectedRegionA = try region(sql: "SELECT MAX(id) FROM a", in: dbQueue) let expectedRegionB = try region(sql: "SELECT MAX(id) FROM b", in: dbQueue) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 3 let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in try! dbQueue.write { db in try db.execute(sql: """ UPDATE choice SET t = 'b'; INSERT INTO a DEFAULT VALUES; INSERT INTO b DEFAULT VALUES; """) } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger.strings.prefix(11), [ "start", "fetch", "tracked region: \(expectedRegionA),choice(t)", "value: nil", "database did change", "fetch", "tracked region: \(expectedRegionB),choice(t)", "value: Optional(1)", "database did change", "fetch", "value: Optional(2)"]) } } // MARK: - Variations func test_prefix() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger1 = TestStream() let logger2 = TestStream() let observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print("", to: logger1) .print("log", to: logger2) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger1.strings.prefix(4), [ "start", "fetch", "tracked region: player(*)", "value: nil"]) XCTAssertEqual(logger2.strings.prefix(4), [ "log: start", "log: fetch", "log: tracked region: player(*)", "log: value: nil"]) } } func test_chain() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let logger1 = TestStream() let logger2 = TestStream() let observation = ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger1) .removeDuplicates() .map { _ in "foo" } .print(to: logger2) let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(logger1.strings.prefix(4), [ "start", "fetch", "tracked region: player(*)", "value: nil"]) XCTAssertEqual(logger2.strings.prefix(4), [ "start", "fetch", "tracked region: player(*)", "value: foo"]) } } func test_handleEvents() throws { func waitFor(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in try! dbQueue.write { db in try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) } } func waitForError(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbQueue.write { db in try db.execute(sql: """ INSERT INTO player DEFAULT VALUES; DROP TABLE player; """) } }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) } } func waitForCancel(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, scheduling: .async(onQueue: .main), onError: { _ in }, onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) cancellable.cancel() } } let observation = ValueObservation.trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } do { let logger = TestStream() try waitFor(observation.handleEvents(willStart: { logger.write("start") })) XCTAssertEqual(logger.strings, ["start"]) } do { let logger = TestStream() try waitFor(observation.handleEvents(willTrackRegion: { _ in logger.write("region") })) XCTAssertEqual(logger.strings, ["region"]) } do { let logger = TestStream() try waitFor(observation.handleEvents(databaseDidChange: { logger.write("change") })) XCTAssertEqual(logger.strings.prefix(1), ["change"]) } do { let logger = TestStream() try waitFor(observation.handleEvents(willFetch: { logger.write("fetch") })) XCTAssertEqual(logger.strings.prefix(2), ["fetch", "fetch"]) } do { let logger = TestStream() try waitFor(observation.handleEvents(didReceiveValue: { _ in logger.write("value") })) XCTAssertEqual(logger.strings.prefix(2), ["value", "value"]) } do { let logger = TestStream() try waitForError(observation.handleEvents(didFail: { _ in logger.write("error") })) XCTAssertEqual(logger.strings, ["error"]) } do { let logger = TestStream() try waitForCancel(observation.handleEvents(didCancel: { logger.write("cancel") })) XCTAssertEqual(logger.strings, ["cancel"]) } } }