Files

682 lines
25 KiB
Swift

import XCTest
import GRDB
class SharedValueObservationTests: GRDBTestCase {
// Test passes if it compiles.
// See <https://github.com/groue/GRDB.swift/issues/1541>
func testInitFromAnyDatabaseReader(reader: any DatabaseReader) {
_ = ValueObservation
.trackingConstantRegion { _ in }
.shared(in: reader)
}
// Test passes if it compiles.
// See <https://github.com/groue/GRDB.swift/issues/1541>
func testInitFromAnyDatabaseWriter(writer: any DatabaseWriter) {
_ = ValueObservation
.trackingConstantRegion { _ in }
.shared(in: writer)
}
func test_immediate_observationLifetime() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .immediate,
extent: .observationLifetime)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
withExtendedLifetime(sharedObservation) { sharedObservation in
do {
var value: Int?
let cancellable = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value = $0 })
XCTAssertEqual(value, 0)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
cancellable.cancel()
XCTAssertEqual(log.flush(), [])
}
do {
var value: Int?
let cancellable = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value = $0 })
XCTAssertEqual(value, 0)
XCTAssertEqual(log.flush(), [])
cancellable.cancel()
XCTAssertEqual(log.flush(), [])
}
}
// Deallocate the shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), ["cancel"])
}
func test_immediate_observationLifetime_reentrancy() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .immediate,
extent: .observationLifetime)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
withExtendedLifetime(sharedObservation) { sharedObservation in
do {
var value1: Int?
var value2: Int?
let cancellable1 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value in
value1 = value
_ = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value in
value2 = value
})
})
XCTAssertEqual(value1, 0)
XCTAssertEqual(value2, 0)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
cancellable1.cancel()
XCTAssertEqual(log.flush(), [])
}
}
// Deallocate the shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), ["cancel"])
}
#if canImport(Combine)
func test_immediate_publisher() throws {
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
throw XCTSkip("Combine is not available")
}
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let publisher = ValueObservation
.tracking(Table("player").fetchCount)
.shared(
in: dbQueue,
scheduling: .immediate)
.publisher()
do {
let recorder = publisher.record()
try XCTAssertEqual(recorder.next().get(), 0)
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 1)
}
do {
let recorder = publisher.record()
try XCTAssertEqual(recorder.next().get(), 1)
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 2)
}
}
#endif
func test_immediate_whileObserved() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .immediate,
extent: .whileObserved)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
withExtendedLifetime(sharedObservation) { sharedObservation in
do {
var value: Int?
let cancellable = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value = $0 })
XCTAssertEqual(value, 0)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
cancellable.cancel()
XCTAssertEqual(log.flush(), ["cancel"])
}
do {
var value: Int?
let cancellable = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value = $0 })
XCTAssertEqual(value, 0)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
cancellable.cancel()
XCTAssertEqual(log.flush(), ["cancel"])
}
}
// Deallocate the shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), [])
}
func test_immediate_whileObserved_reentrancy() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .immediate,
extent: .whileObserved)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
withExtendedLifetime(sharedObservation) { sharedObservation in
var value1: Int?
var value2: Int?
let cancellable1 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value in
value1 = value
_ = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value in
value2 = value
})
})
XCTAssertEqual(value1, 0)
XCTAssertEqual(value2, 0)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
cancellable1.cancel()
XCTAssertEqual(log.flush(), ["cancel"])
}
// Deallocate the shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), [])
}
func test_async_observationLifetime() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .async(onQueue: .main),
extent: .observationLifetime)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
try withExtendedLifetime(sharedObservation) { sharedObservation in
// --- Start observation 1
var values1: [Int] = []
let exp1 = expectation(description: "")
exp1.expectedFulfillmentCount = 2
exp1.assertForOverFulfill = false
let cancellable1 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values1.append($0)
exp1.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp1], timeout: 1)
XCTAssertEqual(values1, [0, 1])
XCTAssertEqual(log.flush(), [
"start", "fetch", "tracked region: player(*)", "value: 0",
"database did change", "fetch", "value: 1"])
// --- Start observation 2
var values2: [Int] = []
let exp2 = expectation(description: "")
exp2.expectedFulfillmentCount = 2
exp2.assertForOverFulfill = false
let cancellable2 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values2.append($0)
exp2.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp2], timeout: 1)
XCTAssertEqual(values1, [0, 1, 2])
XCTAssertEqual(values2, [1, 2])
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 2"])
// --- Stop observation 1
cancellable1.cancel()
XCTAssertEqual(log.flush(), [])
// --- Start observation 3
var values3: [Int] = []
let exp3 = expectation(description: "")
exp3.expectedFulfillmentCount = 2
exp3.assertForOverFulfill = false
let cancellable3 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values3.append($0)
exp3.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp3], timeout: 1)
XCTAssertEqual(values1, [0, 1, 2])
XCTAssertEqual(values2, [1, 2, 3])
XCTAssertEqual(values3, [2, 3])
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 3"])
// --- Stop observation 2
cancellable2.cancel()
XCTAssertEqual(log.flush(), [])
// --- Stop observation 3
cancellable3.cancel()
XCTAssertEqual(log.flush(), [])
}
// --- Release shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), ["cancel"])
}
func test_async_observationLifetime_early_release() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .async(onQueue: .main),
extent: .observationLifetime)
XCTAssertEqual(log.flush(), [])
// ---
let exp = expectation(description: "")
exp.expectedFulfillmentCount = 3
let cancellable = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: { value in
// Early release
sharedObservation = nil
switch value {
case 0:
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 0"])
case 1:
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 1"])
case 2:
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 2"])
default:
break
}
exp.fulfill()
try! dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
})
wait(for: [exp], timeout: 1)
cancellable.cancel()
XCTAssertTrue(log.flush().contains("cancel")) // Ignore eventual notification of value: 3
}
#if canImport(Combine)
func test_async_publisher() throws {
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
throw XCTSkip("Combine is not available")
}
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let publisher = ValueObservation
.tracking(Table("player").fetchCount)
.shared(in: dbQueue) // default async
.publisher()
do {
let recorder = publisher.record()
try XCTAssert(recorder.availableElements.get().isEmpty)
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 0)
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 1)
}
do {
let recorder = publisher.record()
try XCTAssert(recorder.availableElements.get().isEmpty)
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 1)
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 2)
}
}
#endif
func test_async_whileObserved() throws {
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var sharedObservation: SharedValueObservation<Int>? = ValueObservation
.tracking(Table("player").fetchCount)
.print(to: log)
.shared(
in: dbQueue,
scheduling: .async(onQueue: .main),
extent: .whileObserved)
XCTAssertEqual(log.flush(), [])
// We want to control when the shared observation is deallocated
try withExtendedLifetime(sharedObservation) { sharedObservation in
// --- Start observation 1
var values1: [Int] = []
let exp1 = expectation(description: "")
exp1.expectedFulfillmentCount = 2
exp1.assertForOverFulfill = false
let cancellable1 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values1.append($0)
exp1.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp1], timeout: 1)
XCTAssertEqual(values1, [0, 1])
XCTAssertEqual(log.flush(), [
"start", "fetch", "tracked region: player(*)", "value: 0",
"database did change", "fetch", "value: 1"])
// --- Start observation 2
var values2: [Int] = []
let exp2 = expectation(description: "")
exp2.expectedFulfillmentCount = 2
exp2.assertForOverFulfill = false
let cancellable2 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values2.append($0)
exp2.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp2], timeout: 1)
XCTAssertEqual(values1, [0, 1, 2])
XCTAssertEqual(values2, [1, 2])
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 2"])
// --- Stop observation 1
cancellable1.cancel()
XCTAssertEqual(log.flush(), [])
// --- Start observation 3
var values3: [Int] = []
let exp3 = expectation(description: "")
exp3.expectedFulfillmentCount = 2
exp3.assertForOverFulfill = false
let cancellable3 = sharedObservation!.start(
onError: { XCTFail("Unexpected error \($0)") },
onChange: {
values3.append($0)
exp3.fulfill()
})
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
wait(for: [exp3], timeout: 1)
XCTAssertEqual(values1, [0, 1, 2])
XCTAssertEqual(values2, [1, 2, 3])
XCTAssertEqual(values3, [2, 3])
XCTAssertEqual(log.flush(), ["database did change", "fetch", "value: 3"])
// --- Stop observation 2
cancellable2.cancel()
XCTAssertEqual(log.flush(), [])
// --- Stop observation 3
cancellable3.cancel()
XCTAssertEqual(log.flush(), ["cancel"])
}
// --- Release shared observation
sharedObservation = nil
XCTAssertEqual(log.flush(), [])
}
#if canImport(Combine)
func test_error_recovery_observationLifetime() throws {
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
throw XCTSkip("Combine is not available")
}
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var fetchError: Error? = nil
let publisher = ValueObservation
.tracking { db -> Int in
if let error = fetchError { throw error }
return try Table("player").fetchCount(db)
}
.print(to: log)
.shared(in: dbQueue, extent: .observationLifetime)
.publisher()
do {
let recorder1 = publisher.record()
let recorder2 = publisher.record()
try XCTAssertEqual(wait(for: recorder1.next(), timeout: 1), 0)
try XCTAssertEqual(wait(for: recorder2.next(), timeout: 1), 0)
fetchError = TestError()
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
if case .finished = try wait(for: recorder1.completion, timeout: 1) { XCTFail("Expected error") }
if case .finished = try wait(for: recorder2.completion, timeout: 1) { XCTFail("Expected error") }
XCTAssertEqual(log.flush(), [
"start", "fetch", "tracked region: player(*)", "value: 0",
"database did change", "fetch", "failure: TestError()"])
}
do {
let recorder = publisher.record()
if case .finished = try wait(for: recorder.completion, timeout: 1) { XCTFail("Expected error") }
XCTAssertEqual(log.flush(), [])
}
do {
fetchError = nil
let recorder = publisher.record()
if case .finished = try wait(for: recorder.completion, timeout: 1) { XCTFail("Expected error") }
XCTAssertEqual(log.flush(), [])
}
}
#endif
#if canImport(Combine)
func test_error_recovery_whileObserved() throws {
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
throw XCTSkip("Combine is not available")
}
let dbQueue = try makeDatabaseQueue()
try dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let log = Log()
var fetchError: Error? = nil
let publisher = ValueObservation
.tracking { db -> Int in
if let error = fetchError { throw error }
return try Table("player").fetchCount(db)
}
.print(to: log)
.shared(in: dbQueue, extent: .whileObserved)
.publisher()
do {
let recorder1 = publisher.record()
let recorder2 = publisher.record()
try XCTAssertEqual(wait(for: recorder1.next(), timeout: 1), 0)
try XCTAssertEqual(wait(for: recorder2.next(), timeout: 1), 0)
fetchError = TestError()
try dbQueue.write { try $0.execute(sql: "INSERT INTO player DEFAULT VALUES")}
if case .finished = try wait(for: recorder1.completion, timeout: 1) { XCTFail("Expected error") }
if case .finished = try wait(for: recorder2.completion, timeout: 1) { XCTFail("Expected error") }
XCTAssertEqual(log.flush(), [
"start", "fetch", "tracked region: player(*)", "value: 0",
"database did change", "fetch", "failure: TestError()"])
}
do {
let recorder = publisher.record()
if case .finished = try wait(for: recorder.completion, timeout: 1) { XCTFail("Expected error") }
XCTAssertEqual(log.flush(), ["start", "fetch", "failure: TestError()"])
}
do {
fetchError = nil
let recorder = publisher.record()
try XCTAssertEqual(wait(for: recorder.next(), timeout: 1), 1)
XCTAssertEqual(log.flush(), ["start", "fetch", "tracked region: player(*)", "value: 1"])
}
}
#endif
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
func testAsyncAwait() async throws {
let dbQueue = try makeDatabaseQueue()
try await dbQueue.write { db in
try db.create(table: "player") { t in
t.autoIncrementedPrimaryKey("id")
}
}
let values = ValueObservation
.tracking(Table("player").fetchCount)
.shared(in: dbQueue)
.values()
for try await value in values {
XCTAssertEqual(value, 0)
break
}
}
}
private class Log: TextOutputStream {
var strings: [String] = []
let lock = NSLock()
func write(_ string: String) {
lock.lock()
strings.append(string)
lock.unlock()
}
@discardableResult
func flush() -> [String] {
lock.lock()
let result = strings
strings = []
lock.unlock()
return result
}
}
private struct TestError: Error { }