1375 lines
56 KiB
Swift
1375 lines
56 KiB
Swift
import XCTest
|
|
import Dispatch
|
|
@testable import GRDB
|
|
|
|
class ValueObservationTests: GRDBTestCase {
|
|
// Test passes if it compiles.
|
|
// See <https://github.com/groue/GRDB.swift/issues/1541>
|
|
func testStartFromAnyDatabaseReader(reader: any DatabaseReader) {
|
|
_ = ValueObservation
|
|
.trackingConstantRegion { _ in }
|
|
.start(in: reader, onError: { _ in }, onChange: { })
|
|
}
|
|
|
|
// Test passes if it compiles.
|
|
// See <https://github.com/groue/GRDB.swift/issues/1541>
|
|
func testStartFromAnyDatabaseWriter(writer: any DatabaseWriter) {
|
|
_ = ValueObservation
|
|
.trackingConstantRegion { _ in }
|
|
.start(in: writer, onError: { _ in }, onChange: { })
|
|
}
|
|
|
|
// Test passes if it compiles.
|
|
// See <https://github.com/groue/GRDB.swift/issues/1541>
|
|
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
|
func testValuesFromAnyDatabaseWriter(writer: any DatabaseWriter) {
|
|
func observe<T>(
|
|
fetch: @Sendable @escaping (Database) throws -> T
|
|
) throws -> AsyncValueObservation<T> {
|
|
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<Row>(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<Void, Void>(
|
|
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<Void, Void>(
|
|
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..<N).map { _ in
|
|
ValueObservation
|
|
.tracking(Table("t").fetchCount)
|
|
.removeDuplicates()
|
|
.start(
|
|
in: dbWriter,
|
|
onError: { XCTFail("Unexpected error: \($0)") },
|
|
onChange: { value in
|
|
if value == 0 {
|
|
initialValueExpectation.fulfill()
|
|
} else {
|
|
changeExpectation.fulfill()
|
|
}
|
|
})
|
|
}
|
|
|
|
wait(for: [initialValueExpectation], timeout: 5)
|
|
dbWriter.asyncWriteWithoutTransaction {
|
|
try! $0.execute(sql: "INSERT INTO t DEFAULT VALUES")
|
|
}
|
|
wait(for: [changeExpectation], timeout: 5)
|
|
|
|
// Cleanup
|
|
for cancellable in cancellables { cancellable.cancel() }
|
|
try dbWriter.close()
|
|
}
|
|
|
|
try test(makeDatabaseQueue())
|
|
try test(makeDatabasePool())
|
|
}
|
|
|
|
// MARK: - Async Await
|
|
|
|
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
|
func testAsyncAwait_values_prefix() 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 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<String, Error> {
|
|
// 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 <https://github.com/groue/GRDB.swift/issues/1362>
|
|
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..<observationCount {
|
|
let cancellable = observation.start(in: writer, scheduling: scheduler) { error in
|
|
XCTFail("Unexpected error: \(error)")
|
|
} onChange: { count in
|
|
if count == 0 {
|
|
initialValueExpectation.fulfill()
|
|
} else {
|
|
secondValueExpectation.fulfill()
|
|
}
|
|
}
|
|
cancellables.append(cancellable)
|
|
}
|
|
|
|
try withExtendedLifetime(cancellables) {
|
|
wait(for: [initialValueExpectation], timeout: 2)
|
|
try writer.write {
|
|
try $0.execute(sql: "INSERT INTO t DEFAULT VALUES")
|
|
}
|
|
wait(for: [secondValueExpectation], timeout: 2)
|
|
}
|
|
}
|
|
|
|
try Test(test).run { try (DatabaseQueue(), .immediate) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabaseQueue(path: $0), .immediate) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabasePool(path: $0), .immediate) }
|
|
|
|
try Test(test).run { try (DatabaseQueue(), .async(onQueue: .main)) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabaseQueue(path: $0), .async(onQueue: .main)) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabasePool(path: $0), .async(onQueue: .main)) }
|
|
}
|
|
|
|
// An attempt at finding a regression test for <https://github.com/groue/GRDB.swift/issues/1362>
|
|
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..<observationCount {
|
|
let cancellable = observation.start(in: writer, scheduling: scheduler) { error in
|
|
XCTFail("Unexpected error: \(error)")
|
|
} onChange: { count in
|
|
if count == 0 {
|
|
initialValueExpectation.fulfill()
|
|
} else {
|
|
secondValueExpectation.fulfill()
|
|
}
|
|
}
|
|
cancellables.append(cancellable)
|
|
}
|
|
|
|
try withExtendedLifetime(cancellables) {
|
|
wait(for: [initialValueExpectation], timeout: 2)
|
|
try writer.write {
|
|
try $0.execute(sql: "INSERT INTO t DEFAULT VALUES")
|
|
}
|
|
wait(for: [secondValueExpectation], timeout: 2)
|
|
}
|
|
}
|
|
|
|
try Test(test).run { try (DatabaseQueue(), .async(onQueue: .main)) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabaseQueue(path: $0), .async(onQueue: .main)) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabasePool(path: $0), .async(onQueue: .main)) }
|
|
|
|
try Test(test).run { try (DatabaseQueue(), .immediate) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabaseQueue(path: $0), .immediate) }
|
|
try Test(test).runAtTemporaryDatabasePath { try (DatabasePool(path: $0), .immediate) }
|
|
}
|
|
|
|
// Regression test for <https://github.com/groue/GRDB.swift/issues/1362>
|
|
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..<writer.configuration.maximumReaderCount {
|
|
let observation = ValueObservation.trackingConstantRegion(Table("s").fetchCount)
|
|
let cancellable = observation.start(
|
|
in: writer,
|
|
onError: { error in XCTFail("Unexpected error: \(error)") },
|
|
onChange: { _ in })
|
|
cancellables.append(cancellable)
|
|
}
|
|
|
|
// Wait until all observations are waiting for the writer so
|
|
// that they can install their transaction observer.
|
|
Thread.sleep(forTimeInterval: 0.5)
|
|
|
|
// Perform the write that triggers initialObservation
|
|
semaphore.signal()
|
|
|
|
// initialObservation should get its final value
|
|
wait(for: [finalExpectation], timeout: 2)
|
|
|
|
withExtendedLifetime(cancellables) {}
|
|
}
|
|
|
|
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
|
}
|
|
|
|
// Regression test for <https://github.com/groue/GRDB.swift/issues/1383>
|
|
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 <https://github.com/groue/GRDB.swift/issues/1383>
|
|
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 <https://github.com/groue/GRDB.swift/issues/1383>
|
|
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 <https://github.com/groue/GRDB.swift/issues/1500>
|
|
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)
|
|
})
|
|
}
|
|
}
|