add iOS
This commit is contained in:
+306
@@ -0,0 +1,306 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
import GRDB
|
||||
import XCTest
|
||||
|
||||
private struct Player: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
var name: String
|
||||
var score: Int?
|
||||
|
||||
static func createTable(_ db: Database) throws {
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseReaderReadPublisherTests : XCTestCase {
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testReadPublisher() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) throws {
|
||||
let publisher = reader.readPublisher(value: { db in
|
||||
try Player.fetchCount(db)
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let value = try wait(for: recorder.single, timeout: 5)
|
||||
XCTAssertEqual(value, 0)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// TODO: fix crasher
|
||||
//
|
||||
// * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x20)
|
||||
// frame #0: 0x00007fff7115c6e8 libobjc.A.dylib`objc_retain + 24
|
||||
// frame #1: 0x00007fff71c313a1 libswiftCore.dylib`swift::metadataimpl::ValueWitnesses<swift::metadataimpl::ObjCRetainableBox>::initializeWithCopy(swift::OpaqueValue*, swift::OpaqueValue*, swift::TargetMetadata<swift::InProcess> const*) + 17
|
||||
// frame #2: 0x000000010d926309 GRDB`outlined init with copy of Subscribers.Completion<A.Publisher.Failure> at <compiler-generated>:0
|
||||
// frame #3: 0x000000010d92474b GRDB`ReceiveValuesOnSubscription._receive(completion=failure, self=0x00000001064c13c0) at ReceiveValuesOn.swift:184:9
|
||||
// frame #4: 0x000000010d92392c GRDB`closure #1 in closure #1 in closure #1 in ReceiveValuesOnSubscription.receive(self=0x00000001064c13c0, completion=failure) at ReceiveValuesOn.swift:158:26
|
||||
// frame #5: 0x00007fff71d88f49 libswiftDispatch.dylib`reabstraction thunk helper from @escaping @callee_guaranteed () -> () to @escaping @callee_unowned @convention(block) () -> () + 25
|
||||
// frame #6: 0x00007fff722b76c4 libdispatch.dylib`_dispatch_call_block_and_release + 12
|
||||
// frame #7: 0x00007fff722b8658 libdispatch.dylib`_dispatch_client_callout + 8
|
||||
// frame #8: 0x00007fff722c3cab libdispatch.dylib`_dispatch_main_queue_callback_4CF + 936
|
||||
// frame #9: 0x00007fff38299e81 CoreFoundation`__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
|
||||
// frame #10: 0x00007fff38259c87 CoreFoundation`__CFRunLoopRun + 2028
|
||||
// frame #11: 0x00007fff38258e3e CoreFoundation`CFRunLoopRunSpecific + 462
|
||||
// frame #12: 0x00000001003aa82b XCTest`-[XCTWaiter waitForExpectations:timeout:enforceOrder:] + 823
|
||||
// frame #13: 0x0000000100324a04 XCTest`-[XCTestCase(AsynchronousTesting) waitForExpectations:timeout:enforceOrder:] + 102
|
||||
// frame #14: 0x000000010961363d GRDBOSXTests`XCTestCase.wait<R>(publisherExpectation=GRDBOSXTests.PublisherExpectations.Recording<Swift.Array<GRDB.Row>, Swift.Error> @ 0x00007ffeefbfd2f0, timeout=1, description="", self=0x0000000101d8a9c0) at PublisherExpectation.swift:97:9
|
||||
// frame #15: 0x000000010915dbcf GRDBOSXTests`test #1 (reader=0x000000010672f210, self=0x0000000101d8a9c0) in DatabaseReaderReadPublisherTests.testReadPublisherError() at DatabaseReaderReadPublisherTests.swift:62:33
|
||||
// frame #16: 0x000000010915dfc0 GRDBOSXTests`partial apply for test #1 (reader:) in DatabaseReaderReadPublisherTests.testReadPublisherError() at <compiler-generated>:0
|
||||
// frame #17: 0x000000010915c334 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error) at <compiler-generated>:0
|
||||
// frame #18: 0x000000010915e014 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error)partial apply at <compiler-generated>:0
|
||||
// frame #19: 0x00000001095a85f8 GRDBOSXTests`closure #1 in Test.init(context=0x000000010672f210, _0=<unavailable>, test=0x000000010915e000 GRDBOSXTests`reabstraction thunk helper from @escaping @callee_guaranteed (@guaranteed GRDB.DatabaseReader) -> (@error @owned Swift.Error) to @escaping @callee_guaranteed (@in_guaranteed GRDB.DatabaseReader) -> (@error @owned Swift.Error)partial apply forwarder with unmangled suffix ".16" at <compiler-generated>) at Support.swift:13:41
|
||||
// frame #20: 0x00000001095a86af GRDBOSXTests`partial apply for closure #1 in Test.init(repeatCount:_:) at <compiler-generated>:0
|
||||
// frame #21: 0x00000001095a8a8f GRDBOSXTests`Test.run(context=0x000000010915e880 GRDBOSXTests`reabstraction thunk helper from @callee_guaranteed () -> (@owned GRDB.DatabaseReader, @error @owned Swift.Error) to @escaping @callee_guaranteed () -> (@out GRDB.DatabaseReader, @error @owned Swift.Error)partial apply forwarder with unmangled suffix ".17" at <compiler-generated>, self=(repeatCount = 1, test = 0x00000001095a8690 GRDBOSXTests`partial apply forwarder for closure #1 (A, Swift.Int) throws -> () in GRDBOSXTests.Test.init(repeatCount: Swift.Int, _: (A) throws -> ()) -> GRDBOSXTests.Test<A> at <compiler-generated>)) at Support.swift:24:17
|
||||
// * frame #22: 0x000000010915d4ec GRDBOSXTests`DatabaseReaderReadPublisherTests.testReadPublisherError(self=0x0000000101d8a9c0) at DatabaseReaderReadPublisherTests.swift:71:14
|
||||
// frame #23: 0x000000010915f26a GRDBOSXTests`@objc DatabaseReaderReadPublisherTests.testReadPublisherError() at <compiler-generated>:0
|
||||
// frame #24: 0x00007fff3823c8ac CoreFoundation`__invoking___ + 140
|
||||
// frame #25: 0x00007fff3823c751 CoreFoundation`-[NSInvocation invoke] + 303
|
||||
// frame #26: 0x0000000100339d3a XCTest`__24-[XCTestCase invokeTest]_block_invoke_3 + 52
|
||||
// frame #27: 0x0000000100402215 XCTest`+[XCTSwiftErrorObservation observeErrorsInBlock:] + 69
|
||||
// frame #28: 0x0000000100339c3c XCTest`__24-[XCTestCase invokeTest]_block_invoke_2 + 119
|
||||
// frame #29: 0x00000001003c959a XCTest`-[XCTMemoryChecker _assertInvalidObjectsDeallocatedAfterScope:] + 65
|
||||
// frame #30: 0x00000001003448ea XCTest`-[XCTestCase assertInvalidObjectsDeallocatedAfterScope:] + 61
|
||||
// frame #31: 0x0000000100339b82 XCTest`__24-[XCTestCase invokeTest]_block_invoke.231 + 199
|
||||
// frame #32: 0x00000001003ae6d8 XCTest`-[XCTestCase(XCTIssueHandling) _caughtUnhandledDeveloperExceptionPermittingControlFlowInterruptions:caughtInterruptionException:whileExecutingBlock:] + 179
|
||||
// frame #33: 0x0000000100339645 XCTest`-[XCTestCase invokeTest] + 1037
|
||||
// frame #34: 0x000000010033b023 XCTest`__26-[XCTestCase performTest:]_block_invoke_2 + 43
|
||||
// frame #35: 0x00000001003ae6d8 XCTest`-[XCTestCase(XCTIssueHandling) _caughtUnhandledDeveloperExceptionPermittingControlFlowInterruptions:caughtInterruptionException:whileExecutingBlock:] + 179
|
||||
// frame #36: 0x000000010033af5a XCTest`__26-[XCTestCase performTest:]_block_invoke.362 + 86
|
||||
// frame #37: 0x00000001003bfb9f XCTest`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 220
|
||||
// frame #38: 0x000000010033a7c7 XCTest`-[XCTestCase performTest:] + 695
|
||||
// frame #39: 0x000000010038c6da XCTest`-[XCTest runTest] + 57
|
||||
// frame #40: 0x0000000100334035 XCTest`__27-[XCTestSuite performTest:]_block_invoke + 329
|
||||
// frame #41: 0x0000000100333856 XCTest`__59-[XCTestSuite _performProtectedSectionForTest:testSection:]_block_invoke + 24
|
||||
// frame #42: 0x00000001003bfb9f XCTest`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 220
|
||||
// frame #43: 0x00000001003bfab0 XCTest`+[XCTContext runInContextForTestCase:block:] + 52
|
||||
// frame #44: 0x000000010033380d XCTest`-[XCTestSuite _performProtectedSectionForTest:testSection:] + 148
|
||||
// frame #45: 0x0000000100333b11 XCTest`-[XCTestSuite performTest:] + 290
|
||||
// frame #46: 0x000000010038c6da XCTest`-[XCTest runTest] + 57
|
||||
// frame #47: 0x0000000100334035 XCTest`__27-[XCTestSuite performTest:]_block_invoke + 329
|
||||
// frame #48: 0x0000000100333856 XCTest`__59-[XCTestSuite _performProtectedSectionForTest:testSection:]_block_invoke + 24
|
||||
// frame #49: 0x00000001003bfb9f XCTest`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 220
|
||||
// frame #50: 0x00000001003bfab0 XCTest`+[XCTContext runInContextForTestCase:block:] + 52
|
||||
// frame #51: 0x000000010033380d XCTest`-[XCTestSuite _performProtectedSectionForTest:testSection:] + 148
|
||||
// frame #52: 0x0000000100333b11 XCTest`-[XCTestSuite performTest:] + 290
|
||||
// frame #53: 0x000000010038c6da XCTest`-[XCTest runTest] + 57
|
||||
// frame #54: 0x0000000100334035 XCTest`__27-[XCTestSuite performTest:]_block_invoke + 329
|
||||
// frame #55: 0x0000000100333856 XCTest`__59-[XCTestSuite _performProtectedSectionForTest:testSection:]_block_invoke + 24
|
||||
// frame #56: 0x00000001003bfb9f XCTest`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 220
|
||||
// frame #57: 0x00000001003bfab0 XCTest`+[XCTContext runInContextForTestCase:block:] + 52
|
||||
// frame #58: 0x000000010033380d XCTest`-[XCTestSuite _performProtectedSectionForTest:testSection:] + 148
|
||||
// frame #59: 0x0000000100333b11 XCTest`-[XCTestSuite performTest:] + 290
|
||||
// frame #60: 0x000000010038c6da XCTest`-[XCTest runTest] + 57
|
||||
// frame #61: 0x00000001003dc8b5 XCTest`__44-[XCTTestRunSession runTestsAndReturnError:]_block_invoke_2 + 148
|
||||
// frame #62: 0x00000001003bfb9f XCTest`+[XCTContext runInContextForTestCase:markAsReportingBase:block:] + 220
|
||||
// frame #63: 0x00000001003bfab0 XCTest`+[XCTContext runInContextForTestCase:block:] + 52
|
||||
// frame #64: 0x00000001003dc81a XCTest`__44-[XCTTestRunSession runTestsAndReturnError:]_block_invoke + 111
|
||||
// frame #65: 0x00000001003dc99b XCTest`__44-[XCTTestRunSession runTestsAndReturnError:]_block_invoke.95 + 96
|
||||
// frame #66: 0x000000010035acb8 XCTest`-[XCTestObservationCenter _observeTestExecutionForBlock:] + 325
|
||||
// frame #67: 0x00000001003dc5e0 XCTest`-[XCTTestRunSession runTestsAndReturnError:] + 615
|
||||
// frame #68: 0x0000000100317a7e XCTest`-[XCTestDriver _runTests] + 466
|
||||
// frame #69: 0x00000001003bbb82 XCTest`_XCTestMain + 108
|
||||
// frame #70: 0x0000000100002f07 xctest`main + 210
|
||||
// frame #71: 0x00007fff72311cc9 libdyld.dylib`start + 1
|
||||
// frame #72: 0x00007fff72311cc9 libdyld.dylib`start + 1
|
||||
func testReadPublisherError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) throws {
|
||||
let publisher = reader.readPublisher(value: { db in
|
||||
try Row.fetchAll(db, sql: "THIS IS NOT SQL")
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testReadPublisherIsAsynchronous() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) throws {
|
||||
let expectation = self.expectation(description: "")
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let cancellable = reader
|
||||
.readPublisher(value: { db in
|
||||
try Player.fetchCount(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in
|
||||
semaphore.wait()
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
semaphore.signal()
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testReadPublisherDefaultScheduler() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) {
|
||||
let expectation = self.expectation(description: "")
|
||||
let cancellable = reader
|
||||
.readPublisher(value: { db in
|
||||
try Player.fetchCount(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
expectation.fulfill()
|
||||
},
|
||||
receiveValue: { _ in
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
})
|
||||
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testReadPublisherCustomScheduler() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) {
|
||||
let queue = DispatchQueue(label: "test")
|
||||
let expectation = self.expectation(description: "")
|
||||
let cancellable = reader
|
||||
.readPublisher(receiveOn: queue, value: { db in
|
||||
try Player.fetchCount(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
expectation.fulfill()
|
||||
},
|
||||
receiveValue: { _ in
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
})
|
||||
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testReadPublisherIsReadonly() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(reader: some DatabaseReader) throws {
|
||||
let publisher = reader.readPublisher(value: { db in
|
||||
try Player.createTable(db)
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_READONLY)
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshot() }
|
||||
#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER && (compiler(>=5.7.1) || !(os(macOS) || targetEnvironment(macCatalyst))))
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshotPool() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
import GRDB
|
||||
import XCTest
|
||||
|
||||
private struct Player: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
var name: String
|
||||
var score: Int?
|
||||
|
||||
static func createTable(_ db: Database) throws {
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseRegionObservationPublisherTests : XCTestCase {
|
||||
|
||||
func testChangesNotifications() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = DatabaseRegionObservation(tracking: Player.all())
|
||||
.publisher(in: writer)
|
||||
.tryMap(Player.fetchCount)
|
||||
let recorder = publisher.record()
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
|
||||
try db.inTransaction {
|
||||
try Player(id: 2, name: "Barbara", score: 750).insert(db)
|
||||
try Player(id: 3, name: "Craig", score: 500).insert(db)
|
||||
return .commit
|
||||
}
|
||||
}
|
||||
|
||||
let elements = try wait(for: recorder.next(2), timeout: 1)
|
||||
XCTAssertEqual(elements, [1, 3])
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// This is an usage test. Do the available APIs allow to prepend a
|
||||
// database connection synchronously, with the guarantee that no race can
|
||||
// have the subscriber miss an impactful change?
|
||||
//
|
||||
// TODO: do the same, but asynchronously. If this is too hard, update the
|
||||
// public API so that users can easily do it.
|
||||
func testPrependInitialDatabaseSync() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let expectation = self.expectation(description: "")
|
||||
let testSubject = PassthroughSubject<Database, Error>()
|
||||
let testCancellable = testSubject
|
||||
.tryMap(Player.fetchCount)
|
||||
.collect(3)
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
assertNoFailure(completion)
|
||||
},
|
||||
receiveValue: { value in
|
||||
XCTAssertEqual(value, [0, 1, 3])
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
let observationCancellable = try writer.write { db in
|
||||
DatabaseRegionObservation(tracking: Player.all())
|
||||
.publisher(in: writer)
|
||||
.prepend(db)
|
||||
.subscribe(testSubject)
|
||||
}
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
|
||||
try db.inTransaction {
|
||||
try Player(id: 2, name: "Barbara", score: 750).insert(db)
|
||||
try Player(id: 3, name: "Craig", score: 500).insert(db)
|
||||
return .commit
|
||||
}
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
testCancellable.cancel()
|
||||
observationCancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
import GRDB
|
||||
import XCTest
|
||||
|
||||
private struct Player: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
var name: String
|
||||
var score: Int?
|
||||
|
||||
static func createTable(_ db: Database) throws {
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DatabaseWriterWritePublisherTests : XCTestCase {
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisher() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
try XCTAssertEqual(writer.read(Player.fetchCount), 0)
|
||||
let publisher = writer.writePublisher(updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
try wait(for: recorder.single, timeout: 5)
|
||||
try XCTAssertEqual(writer.read(Player.fetchCount), 1)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisherValue() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(updates: { db -> Int in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let count = try wait(for: recorder.single, timeout: 5)
|
||||
XCTAssertEqual(count, 1)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisherError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(updates: { db in
|
||||
try db.execute(sql: "THIS IS NOT SQL")
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
func testWritePublisherErrorRollbacksTransaction() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
try db.execute(sql: "THIS IS NOT SQL")
|
||||
})
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
let count = try writer.read(Player.fetchCount)
|
||||
XCTAssertEqual(count, 0)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisherIsAsynchronous() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let expectation = self.expectation(description: "")
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let cancellable = writer
|
||||
.writePublisher(updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in
|
||||
semaphore.wait()
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
semaphore.signal()
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisherDefaultScheduler() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test<Writer: DatabaseWriter>(writer: Writer) {
|
||||
let expectation = self.expectation(description: "")
|
||||
expectation.expectedFulfillmentCount = 2 // value + completion
|
||||
let cancellable = writer
|
||||
.writePublisher(updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
expectation.fulfill()
|
||||
},
|
||||
receiveValue: { _ in
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWritePublisherCustomScheduler() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test<Writer: DatabaseWriter>(writer: Writer) {
|
||||
let queue = DispatchQueue(label: "test")
|
||||
let expectation = self.expectation(description: "")
|
||||
expectation.expectedFulfillmentCount = 2 // value + completion
|
||||
let cancellable = writer
|
||||
.writePublisher(receiveOn: queue, updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
})
|
||||
.sink(
|
||||
receiveCompletion: { completion in
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
expectation.fulfill()
|
||||
},
|
||||
receiveValue: { _ in
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
waitForExpectations(timeout: 5, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// TODO: Fix flaky test with both pool and on-disk queue:
|
||||
// - Expectation timeout
|
||||
func testWriteThenReadPublisher() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer
|
||||
.writePublisher(
|
||||
updates: { db in try Player(id: 1, name: "Arthur", score: 1000).insert(db) },
|
||||
thenRead: { db, _ in try Player.fetchCount(db) })
|
||||
let recorder = publisher.record()
|
||||
let count = try wait(for: recorder.single, timeout: 5)
|
||||
XCTAssertEqual(count, 1)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWriteThenReadPublisherIsReadonly() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer
|
||||
.writePublisher(
|
||||
updates: { _ in },
|
||||
thenRead: { db, _ in try Player.createTable(db) })
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_READONLY)
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
func testWriteThenReadPublisherWriteError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(
|
||||
updates: { db in try db.execute(sql: "THIS IS NOT SQL") },
|
||||
thenRead: { _, _ in XCTFail("Should not read") })
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
func testWriteThenReadPublisherWriteErrorRollbacksTransaction() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(
|
||||
updates: { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
try db.execute(sql: "THIS IS NOT SQL")
|
||||
},
|
||||
thenRead: { _, _ in XCTFail("Should not read") })
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
let count = try writer.read(Player.fetchCount)
|
||||
XCTAssertEqual(count, 0)
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
// TODO: Fix flaky test with both pool and on-disk queue:
|
||||
// - Expectation timeout
|
||||
func testWriteThenReadPublisherReadError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = writer.writePublisher(
|
||||
updates: { _ in },
|
||||
thenRead: { db, _ in try Row.fetchAll(db, sql: "THIS IS NOT SQL") })
|
||||
let recorder = publisher.record()
|
||||
let recording = try wait(for: recorder.recording, timeout: 5)
|
||||
XCTAssertTrue(recording.output.isEmpty)
|
||||
assertFailure(recording.completion) { (error: DatabaseError) in
|
||||
XCTAssertEqual(error.resultCode, .SQLITE_ERROR)
|
||||
XCTAssertEqual(error.sql, "THIS IS NOT SQL")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Regression tests
|
||||
|
||||
// Regression test against deadlocks created by concurrent completion
|
||||
// and cancellations triggered by .switchToLatest().prefix(1)
|
||||
func testDeadlockPrevention() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter, iteration: Int) throws {
|
||||
// print(iteration)
|
||||
let scoreSubject = PassthroughSubject<Int, Error>()
|
||||
let publisher = scoreSubject
|
||||
.map { score in
|
||||
writer.writePublisher { db -> Int in
|
||||
try Player(id: 1, name: "Arthur", score: score).insert(db)
|
||||
return try Player.fetchCount(db)
|
||||
}
|
||||
}
|
||||
.switchToLatest()
|
||||
.prefix(1)
|
||||
let recorder = publisher.record()
|
||||
scoreSubject.send(0)
|
||||
let count = try wait(for: recorder.single, timeout: 5)
|
||||
XCTAssertEqual(count, 1)
|
||||
}
|
||||
|
||||
try Test(repeatCount: 100, test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(repeatCount: 100, test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(repeatCount: 100, test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
final class Test<Context> {
|
||||
// Raise the repeatCount in order to help spotting flaky tests.
|
||||
private let repeatCount: Int
|
||||
private let test: (Context, Int) throws -> ()
|
||||
|
||||
init(repeatCount: Int = 1, _ test: @escaping (Context) throws -> ()) {
|
||||
self.repeatCount = repeatCount
|
||||
self.test = { context, _ in try test(context) }
|
||||
}
|
||||
|
||||
init(repeatCount: Int, _ test: @escaping (Context, Int) throws -> ()) {
|
||||
self.repeatCount = repeatCount
|
||||
self.test = test
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func run(context: () throws -> Context) throws -> Self {
|
||||
for i in 1...repeatCount {
|
||||
try test(context(), i)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func runInTemporaryDirectory(context: (_ directoryURL: URL) throws -> Context) throws -> Self {
|
||||
for i in 1...repeatCount {
|
||||
let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("GRDB", isDirectory: true)
|
||||
.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true)
|
||||
|
||||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
defer {
|
||||
try! FileManager.default.removeItem(at: directoryURL)
|
||||
}
|
||||
|
||||
try test(context(directoryURL), i)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func runAtTemporaryDatabasePath(context: (_ path: String) throws -> Context) throws -> Self {
|
||||
try runInTemporaryDirectory { url in
|
||||
try context(url.appendingPathComponent("db.sqlite").path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
final class AsyncTest<Context> {
|
||||
// Raise the repeatCount in order to help spotting flaky tests.
|
||||
private let repeatCount: Int
|
||||
private let test: (Context, Int) async throws -> ()
|
||||
|
||||
init(repeatCount: Int = 1, _ test: @escaping (Context) async throws -> ()) {
|
||||
self.repeatCount = repeatCount
|
||||
self.test = { context, _ in try await test(context) }
|
||||
}
|
||||
|
||||
init(repeatCount: Int, _ test: @escaping (Context, Int) async throws -> ()) {
|
||||
self.repeatCount = repeatCount
|
||||
self.test = test
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func run(context: () async throws -> Context) async throws -> Self {
|
||||
for i in 1...repeatCount {
|
||||
try await test(context(), i)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func runInTemporaryDirectory(context: (_ directoryURL: URL) async throws -> Context) async throws -> Self {
|
||||
for i in 1...repeatCount {
|
||||
let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("GRDB", isDirectory: true)
|
||||
.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true)
|
||||
|
||||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
defer {
|
||||
try! FileManager.default.removeItem(at: directoryURL)
|
||||
}
|
||||
|
||||
try await test(context(directoryURL), i)
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func runAtTemporaryDatabasePath(context: (_ path: String) async throws -> Context) async throws -> Self {
|
||||
try await runInTemporaryDirectory { url in
|
||||
try await context(url.appendingPathComponent("db.sqlite").path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func assertNoFailure<Failure>(
|
||||
_ completion: Subscribers.Completion<Failure>,
|
||||
file: StaticString = #file,
|
||||
line: UInt = #line)
|
||||
{
|
||||
if case let .failure(error) = completion {
|
||||
XCTFail("Unexpected completion failure: \(error)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
public func assertFailure<Failure, ExpectedFailure>(
|
||||
_ completion: Subscribers.Completion<Failure>,
|
||||
file: StaticString = #file,
|
||||
line: UInt = #line,
|
||||
test: (ExpectedFailure) -> Void)
|
||||
{
|
||||
if case let .failure(error) = completion, let expectedError = error as? ExpectedFailure {
|
||||
test(expectedError)
|
||||
} else {
|
||||
XCTFail("Expected \(ExpectedFailure.self), got \(completion)", file: file, line: line)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
#if canImport(Combine)
|
||||
import Combine
|
||||
import GRDB
|
||||
import XCTest
|
||||
|
||||
private struct Player: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
var name: String
|
||||
var score: Int?
|
||||
|
||||
static func createTable(_ db: Database) throws {
|
||||
try db.create(table: "player") { t in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
t.column("name", .text).notNull()
|
||||
t.column("score", .integer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ValueObservationPublisherTests : XCTestCase {
|
||||
|
||||
// MARK: - Default Scheduler
|
||||
|
||||
func testDefaultSchedulerChangesNotifications() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer)
|
||||
let recorder = publisher.record()
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
|
||||
try db.inTransaction {
|
||||
try Player(id: 2, name: "Barbara", score: 750).insert(db)
|
||||
try Player(id: 3, name: "Craig", score: 500).insert(db)
|
||||
return .commit
|
||||
}
|
||||
}
|
||||
|
||||
let expectedElements = [0, 1, 3]
|
||||
if writer is DatabaseQueue {
|
||||
let elements = try wait(for: recorder.next(expectedElements.count), timeout: 1)
|
||||
XCTAssertEqual(elements, expectedElements)
|
||||
} else {
|
||||
// TODO: prefix(until:)
|
||||
let elements = try wait(for: recorder.prefix(expectedElements.count + 2).inverted, timeout: 1)
|
||||
assertValueObservationRecordingMatch(recorded: elements, expected: expectedElements)
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testDefaultSchedulerFirstValueIsEmittedAsynchronously() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let expectation = self.expectation(description: "")
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let cancellable = ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer)
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in
|
||||
semaphore.wait()
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
semaphore.signal()
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
cancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testDefaultSchedulerError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = ValueObservation
|
||||
.trackingConstantRegion { try $0.execute(sql: "THIS IS NOT SQL") }
|
||||
.publisher(in: writer)
|
||||
let recorder = publisher.record()
|
||||
let completion = try wait(for: recorder.completion, timeout: 1)
|
||||
switch completion {
|
||||
case let .failure(error):
|
||||
XCTAssertNotNil(error as? DatabaseError)
|
||||
case .finished:
|
||||
XCTFail("Expected error")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Immediate Scheduler
|
||||
|
||||
func testImmediateSchedulerChangesNotifications() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer, scheduling: .immediate)
|
||||
let recorder = publisher.record()
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
|
||||
try db.inTransaction {
|
||||
try Player(id: 2, name: "Barbara", score: 750).insert(db)
|
||||
try Player(id: 3, name: "Craig", score: 500).insert(db)
|
||||
return .commit
|
||||
}
|
||||
}
|
||||
|
||||
let expectedElements = [0, 1, 3]
|
||||
if writer is DatabaseQueue {
|
||||
let elements = try wait(for: recorder.next(expectedElements.count), timeout: 1)
|
||||
XCTAssertEqual(elements, expectedElements)
|
||||
} else {
|
||||
// TODO: prefix(until:)
|
||||
let elements = try wait(for: recorder.prefix(expectedElements.count + 2).inverted, timeout: 1)
|
||||
assertValueObservationRecordingMatch(recorded: elements, expected: expectedElements)
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testImmediateSchedulerEmitsFirstValueSynchronously() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let testSubject = PassthroughSubject<Int, Error>()
|
||||
let testCancellable = testSubject
|
||||
.sink(
|
||||
receiveCompletion: { _ in },
|
||||
receiveValue: { _ in
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
semaphore.signal()
|
||||
})
|
||||
|
||||
let observationCancellable = ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer, scheduling: .immediate)
|
||||
.subscribe(testSubject)
|
||||
|
||||
semaphore.wait()
|
||||
testCancellable.cancel()
|
||||
observationCancellable.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testImmediateSchedulerError() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let publisher = ValueObservation
|
||||
.trackingConstantRegion { try $0.execute(sql: "THIS IS NOT SQL") }
|
||||
.publisher(in: writer, scheduling: .immediate)
|
||||
let recorder = publisher.record()
|
||||
let completion = try recorder.completion.get()
|
||||
switch completion {
|
||||
case let .failure(error):
|
||||
XCTAssertNotNil(error as? DatabaseError)
|
||||
case .finished:
|
||||
XCTFail("Expected error")
|
||||
}
|
||||
}
|
||||
|
||||
try Test(test).run { try DatabaseQueue() }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) }
|
||||
}
|
||||
|
||||
// MARK: - Demand
|
||||
|
||||
@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *)
|
||||
private class DemandSubscriber<Input, Failure: Error>: Subscriber {
|
||||
private var subscription: Subscription?
|
||||
let subject = PassthroughSubject<Input, Failure>()
|
||||
deinit {
|
||||
subscription?.cancel()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
subscription!.cancel()
|
||||
}
|
||||
|
||||
func request(_ demand: Subscribers.Demand) {
|
||||
subscription!.request(demand)
|
||||
}
|
||||
|
||||
func receive(subscription: Subscription) {
|
||||
self.subscription = subscription
|
||||
}
|
||||
|
||||
func receive(_ input: Input) -> Subscribers.Demand {
|
||||
subject.send(input)
|
||||
return .none
|
||||
}
|
||||
|
||||
func receive(completion: Subscribers.Completion<Failure>) {
|
||||
subject.send(completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func testDemandNoneReceivesNoElement() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let subscriber = DemandSubscriber<Int, Error>()
|
||||
|
||||
let expectation = self.expectation(description: "")
|
||||
expectation.isInverted = true
|
||||
let testCancellable = subscriber.subject
|
||||
.sink(
|
||||
receiveCompletion: { _ in XCTFail("Unexpected completion") },
|
||||
receiveValue: { _ in expectation.fulfill() })
|
||||
|
||||
ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer)
|
||||
.subscribe(subscriber)
|
||||
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
testCancellable.cancel()
|
||||
subscriber.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testDemandOneReceivesOneElement() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let subscriber = DemandSubscriber<Int, Error>()
|
||||
let expectation = self.expectation(description: "")
|
||||
|
||||
let testCancellable = subscriber.subject.sink(
|
||||
receiveCompletion: { _ in XCTFail("Unexpected completion") },
|
||||
receiveValue: { value in
|
||||
XCTAssertEqual(value, 0)
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer)
|
||||
.subscribe(subscriber)
|
||||
|
||||
subscriber.request(.max(1))
|
||||
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
testCancellable.cancel()
|
||||
subscriber.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testDemandOneDoesNotReceiveTwoElements() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let subscriber = DemandSubscriber<Int, Error>()
|
||||
let expectation = self.expectation(description: "")
|
||||
expectation.isInverted = true
|
||||
|
||||
let testCancellable = subscriber.subject
|
||||
.collect(2)
|
||||
.sink(
|
||||
receiveCompletion: { _ in XCTFail("Unexpected completion") },
|
||||
receiveValue: { _ in expectation.fulfill() })
|
||||
|
||||
ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer, scheduling: .immediate /* make sure we get the initial db state */)
|
||||
.subscribe(subscriber)
|
||||
|
||||
subscriber.request(.max(1))
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
testCancellable.cancel()
|
||||
subscriber.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
func testDemandTwoReceivesTwoElements() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
func setUp<Writer: DatabaseWriter>(_ writer: Writer) throws -> Writer {
|
||||
try writer.write(Player.createTable)
|
||||
return writer
|
||||
}
|
||||
|
||||
func test(writer: some DatabaseWriter) throws {
|
||||
let subscriber = DemandSubscriber<Int, Error>()
|
||||
let expectation = self.expectation(description: "")
|
||||
|
||||
let testCancellable = subscriber.subject
|
||||
.collect(2)
|
||||
.sink(
|
||||
receiveCompletion: { _ in XCTFail("Unexpected completion") },
|
||||
receiveValue: { values in
|
||||
expectation.fulfill()
|
||||
})
|
||||
|
||||
ValueObservation
|
||||
.trackingConstantRegion(Player.fetchCount)
|
||||
.publisher(in: writer, scheduling: .immediate /* make sure we get two db states */)
|
||||
.subscribe(subscriber)
|
||||
|
||||
subscriber.request(.max(2))
|
||||
|
||||
try writer.writeWithoutTransaction { db in
|
||||
try Player(id: 1, name: "Arthur", score: 1000).insert(db)
|
||||
}
|
||||
|
||||
waitForExpectations(timeout: 1, handler: nil)
|
||||
testCancellable.cancel()
|
||||
subscriber.cancel()
|
||||
}
|
||||
|
||||
try Test(test).run { try setUp(DatabaseQueue()) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabaseQueue(path: $0)) }
|
||||
try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)) }
|
||||
}
|
||||
|
||||
// MARK: - Regression Tests
|
||||
|
||||
/// Regression test for https://github.com/groue/GRDB.swift/issues/1194
|
||||
func testIssue1194() throws {
|
||||
guard #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) else {
|
||||
throw XCTSkip("Combine is not available")
|
||||
}
|
||||
|
||||
struct Record: Codable, FetchableRecord, PersistableRecord {
|
||||
var id: Int64
|
||||
}
|
||||
|
||||
var configuration = Configuration()
|
||||
configuration.targetQueue = DispatchQueue(label: "crash.test", qos: .userInitiated)
|
||||
|
||||
let database = try DatabaseQueue(configuration: configuration)
|
||||
|
||||
var migrator = DatabaseMigrator()
|
||||
migrator.registerMigration("v1") { (db) in
|
||||
try db.create(table: Record.databaseTableName) { (t) in
|
||||
t.autoIncrementedPrimaryKey("id")
|
||||
}
|
||||
}
|
||||
|
||||
try migrator.migrate(database)
|
||||
|
||||
let observation = ValueObservation.tracking { (db) in
|
||||
try Record.fetchCount(db)
|
||||
}
|
||||
|
||||
let exp = expectation(description: "")
|
||||
let cancellable = observation.publisher(in: database, scheduling: .immediate)
|
||||
.map { _ in
|
||||
database.readPublisher { (db) in
|
||||
try Record.fetchCount(db)
|
||||
}
|
||||
}
|
||||
.switchToLatest()
|
||||
.sink(receiveCompletion: { _ in },
|
||||
receiveValue: { (value) in
|
||||
exp.fulfill()
|
||||
})
|
||||
|
||||
withExtendedLifetime(cancellable) {
|
||||
waitForExpectations(timeout: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user