diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..ff18881a87 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:pyenv", + "python-envs.pythonProjects": [] +} \ No newline at end of file diff --git a/GRDB/Core/DatabasePool.swift b/GRDB/Core/DatabasePool.swift index 21b5db7508..f2483f03a7 100644 --- a/GRDB/Core/DatabasePool.swift +++ b/GRDB/Core/DatabasePool.swift @@ -1,34 +1,35 @@ import Dispatch import Foundation + #if os(iOS) -import UIKit + import UIKit #endif public final class DatabasePool { private let writer: SerializedDatabase - + /// The pool of reader connections. /// It is constant, until close() sets it to nil. private var readerPool: Pool? - + let databaseSnapshotCountMutex = Mutex(0) - + /// If Database Suspension is enabled, this array contains the necessary `NotificationCenter` observers. private var suspensionObservers: [NSObjectProtocol] = [] - + // MARK: - Database Information - + public var configuration: Configuration { writer.configuration } - + /// The path to the database. public var path: String { writer.path } - + // MARK: - Initializer - + /// Opens or creates an SQLite database. /// /// For example: @@ -45,23 +46,25 @@ public final class DatabasePool { /// - configuration: A configuration. /// - throws: A ``DatabaseError`` whenever an SQLite error occurs. public init(path: String, configuration: Configuration = Configuration()) throws { - GRDBPrecondition(configuration.maximumReaderCount > 0, "configuration.maximumReaderCount must be at least 1") - + GRDBPrecondition( + configuration.maximumReaderCount > 0, + "configuration.maximumReaderCount must be at least 1") + // Writer writer = try SerializedDatabase( path: path, configuration: configuration, defaultLabel: "GRDB.DatabasePool", purpose: "writer") - + // Readers var readerConfiguration = DatabasePool.readerConfiguration(configuration) - + // Readers can't allow dangling transactions because there's no // guarantee that one can get the same reader later in order to close // an opened transaction. readerConfiguration.allowsUnsafeTransactions = false - + readerPool = Pool( maximumCount: configuration.maximumReaderCount, qos: configuration.readQoS, @@ -72,7 +75,7 @@ public final class DatabasePool { defaultLabel: "GRDB.DatabasePool", purpose: "reader.\(index)") }) - + // Set up journal mode unless readonly if !configuration.readonly { switch configuration.journalMode { @@ -82,40 +85,40 @@ public final class DatabasePool { } } } - + setupSuspension() - + // Be a nice iOS citizen, and don't consume too much memory // See https://github.com/groue/GRDB.swift/#memory-management #if os(iOS) - if configuration.automaticMemoryManagement { - setupMemoryManagement() - } + if configuration.automaticMemoryManagement { + setupMemoryManagement() + } #endif } - + deinit { // Remove block-based Notification observers. suspensionObservers.forEach(NotificationCenter.default.removeObserver(_:)) - + // Undo job done in setupMemoryManagement() // // https://developer.apple.com/library/mac/releasenotes/Foundation/RN-Foundation/index.html#10_11Error // Explicit unregistration is required before macOS 10.11. NotificationCenter.default.removeObserver(self) - + // Close reader connections before the writer connection. // Context: https://github.com/groue/GRDB.swift/issues/739 readerPool = nil } - + /// Returns a Configuration suitable for readonly connections on a /// WAL database. private static func readerConfiguration(_ configuration: Configuration) -> Configuration { var configuration = configuration - + configuration.readonly = true - + // // > But there are some obscure cases where a query against a WAL-mode // > database can return SQLITE_BUSY, so applications should be prepared @@ -137,18 +140,18 @@ public final class DatabasePool { if configuration.readonlyBusyMode == nil { configuration.readonlyBusyMode = .timeout(10) } - + return configuration } } // @unchecked because of readerPool and suspensionObservers -extension DatabasePool: @unchecked Sendable { } +extension DatabasePool: @unchecked Sendable {} extension DatabasePool { - + // MARK: - Memory management - + /// Frees as much memory as possible, by disposing non-essential memory. /// /// This method is synchronous, and blocks the current thread until all @@ -164,7 +167,7 @@ extension DatabasePool { public func releaseMemory() { // Release writer memory writer.sync { $0.releaseMemory() } - + if configuration.persistentReadOnlyConnections { // Keep existing readers readerPool?.forEach { reader in @@ -184,7 +187,7 @@ extension DatabasePool { } } } - + /// Eventually frees as much memory as possible, by disposing /// non-essential memory. /// @@ -206,65 +209,66 @@ extension DatabasePool { // (they will close after their current jobs have completed). readerPool?.removeAll() } - + // Release writer memory eventually. writer.async { $0.releaseMemory() } } - + #if os(iOS) - /// Listens to UIApplicationDidEnterBackgroundNotification and - /// UIApplicationDidReceiveMemoryWarningNotification in order to release - /// as much memory as possible. - private func setupMemoryManagement() { - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(DatabasePool.applicationDidReceiveMemoryWarning(_:)), - name: UIApplication.didReceiveMemoryWarningNotification, - object: nil) - center.addObserver( - self, - selector: #selector(DatabasePool.applicationDidEnterBackground(_:)), - name: UIApplication.didEnterBackgroundNotification, - object: nil) - } - - @objc - private func applicationDidEnterBackground(_ notification: NSNotification) { - guard let application = notification.object as? UIApplication else { - return + /// Listens to UIApplicationDidEnterBackgroundNotification and + /// UIApplicationDidReceiveMemoryWarningNotification in order to release + /// as much memory as possible. + private func setupMemoryManagement() { + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(DatabasePool.applicationDidReceiveMemoryWarning(_:)), + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil) + center.addObserver( + self, + selector: #selector(DatabasePool.applicationDidEnterBackground(_:)), + name: UIApplication.didEnterBackgroundNotification, + object: nil) } - - let task: UIBackgroundTaskIdentifier = application.beginBackgroundTask(expirationHandler: nil) - if task == .invalid { - // Release memory synchronously - releaseMemory() - } else { - // Release memory eventually. - // - // We don't know when reader connections will be closed (because - // they may be currently in use), so we don't quite know when - // reader memory will be freed (which would be the ideal timing for - // ending our background task). - // - // So let's just end the background task after the writer connection - // has freed its memory. That's better than nothing. - releaseMemoryEventually() - writer.async { _ in - application.endBackgroundTask(task) + + @objc + private func applicationDidEnterBackground(_ notification: NSNotification) { + guard let application = notification.object as? UIApplication else { + return + } + + let task: UIBackgroundTaskIdentifier = application.beginBackgroundTask( + expirationHandler: nil) + if task == .invalid { + // Release memory synchronously + releaseMemory() + } else { + // Release memory eventually. + // + // We don't know when reader connections will be closed (because + // they may be currently in use), so we don't quite know when + // reader memory will be freed (which would be the ideal timing for + // ending our background task). + // + // So let's just end the background task after the writer connection + // has freed its memory. That's better than nothing. + releaseMemoryEventually() + writer.async { _ in + application.endBackgroundTask(task) + } } } - } - - @objc - private func applicationDidReceiveMemoryWarning(_ notification: NSNotification) { - releaseMemoryEventually() - } + + @objc + private func applicationDidReceiveMemoryWarning(_ notification: NSNotification) { + releaseMemoryEventually() + } #endif } extension DatabasePool: DatabaseReader { - + public func close() throws { try readerPool?.barrier { // Close writer connection first. If we can't close it, @@ -277,26 +281,26 @@ extension DatabasePool: DatabaseReader { // https://github.com/groue/GRDB.swift/issues/739. // TODO: fix this regression. try writer.sync { try $0.close() } - + // OK writer is closed. Now close readers and // eventually prevent any future read access defer { readerPool = nil } - + try readerPool?.forEach { reader in try reader.sync { try $0.close() } } } } - + // MARK: - Interrupting Database Operations - + public func interrupt() { writer.interrupt() readerPool?.forEach { $0.interrupt() } } - + // MARK: - Database Suspension - + func suspend() { if configuration.readonly { // read-only WAL connections can't acquire locks and do not need to @@ -305,7 +309,7 @@ extension DatabasePool: DatabaseReader { } writer.suspend() } - + func resume() { if configuration.readonly { // read-only WAL connections can't acquire locks and do not need to @@ -314,28 +318,30 @@ extension DatabasePool: DatabaseReader { } writer.resume() } - + private func setupSuspension() { if configuration.observesSuspensionNotifications { let center = NotificationCenter.default - suspensionObservers.append(center.addObserver( - forName: Database.suspendNotification, - object: nil, - queue: nil, - using: { [weak self] _ in self?.suspend() } - )) - suspensionObservers.append(center.addObserver( - forName: Database.resumeNotification, - object: nil, - queue: nil, - using: { [weak self] _ in self?.resume() } - )) - } - } - + suspensionObservers.append( + center.addObserver( + forName: Database.suspendNotification, + object: nil, + queue: nil, + using: { [weak self] _ in self?.suspend() } + )) + suspensionObservers.append( + center.addObserver( + forName: Database.resumeNotification, + object: nil, + queue: nil, + using: { [weak self] _ in self?.resume() } + )) + } + } + // MARK: - Reading from Database - - @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails + + @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails public func read(_ value: (Database) throws -> T) throws -> T { GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.") guard let readerPool else { @@ -350,34 +356,34 @@ extension DatabasePool: DatabaseReader { } } } - + public func read( _ value: @Sendable (Database) throws -> T ) async throws -> T { guard let readerPool else { throw DatabaseError.connectionIsClosed() } - + return try await readerPool.get { reader in try await reader.execute { db in - defer { - // Commit or rollback, but make sure we leave the read-only transaction - // (commit may fail with a CancellationError). - do { - try db.commit() - } catch { - try? db.rollback() - } - assert(!db.isInsideTransaction) - } - // The block isolation comes from the DEFERRED transaction. - try db.beginTransaction(.deferred) - try db.clearSchemaCacheIfNeeded() - return try value(db) - } - } - } - + defer { + // Commit or rollback, but make sure we leave the read-only transaction + // (commit may fail with a CancellationError). + do { + try db.commit() + } catch { + try? db.rollback() + } + assert(!db.isInsideTransaction) + } + // The block isolation comes from the DEFERRED transaction. + try db.beginTransaction(.deferred) + try db.clearSchemaCacheIfNeeded() + return try value(db) + } + } + } + public func asyncRead( _ value: @escaping @Sendable (Result) -> Void ) { @@ -385,7 +391,7 @@ extension DatabasePool: DatabaseReader { value(.failure(DatabaseError.connectionIsClosed())) return } - + readerPool.asyncGet { result in do { let (reader, releaseReader) = try result.get() @@ -416,8 +422,8 @@ extension DatabasePool: DatabaseReader { } } } - - @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails + + @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails public func unsafeRead(_ value: (Database) throws -> T) throws -> T { GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.") guard let readerPool else { @@ -430,14 +436,14 @@ extension DatabasePool: DatabaseReader { } } } - + public func unsafeRead( _ value: @Sendable (Database) throws -> T ) async throws -> T { guard let readerPool else { throw DatabaseError.connectionIsClosed() } - + return try await readerPool.get { reader in try await reader.execute { db in try db.clearSchemaCacheIfNeeded() @@ -445,7 +451,7 @@ extension DatabasePool: DatabaseReader { } } } - + public func asyncUnsafeRead( _ value: @escaping @Sendable (Result) -> Void ) { @@ -453,7 +459,7 @@ extension DatabasePool: DatabaseReader { value(.failure(DatabaseError.connectionIsClosed())) return } - + readerPool.asyncGet { result in do { let (reader, releaseReader) = try result.get() @@ -474,7 +480,7 @@ extension DatabasePool: DatabaseReader { } } } - + public func unsafeReentrantRead(_ value: (Database) throws -> T) throws -> T { if let reader = currentReader { return try reader.reentrantSync(value) @@ -492,13 +498,13 @@ extension DatabasePool: DatabaseReader { } } } - + public func spawnConcurrentRead( _ value: @escaping @Sendable (Result) -> Void ) { asyncConcurrentRead(value) } - + /// Performs an asynchronous read access. /// /// This method must be called from the writer dispatch queue, outside of @@ -542,18 +548,20 @@ extension DatabasePool: DatabaseReader { // Check that we're on the writer queue... writer.execute { db in // ... and that no transaction is opened. - GRDBPrecondition(!db.isInsideTransaction, """ + GRDBPrecondition( + !db.isInsideTransaction, + """ must not be called from inside a transaction. \ If this error is raised from a DatabasePool.write block, use \ DatabasePool.writeWithoutTransaction instead (and use \ transactions when needed). """) } - + // The semaphore that blocks the writing dispatch queue until snapshot // isolation has been established: let isolationSemaphore = DispatchSemaphore(value: 0) - + do { guard let readerPool else { throw DatabaseError.connectionIsClosed() @@ -625,22 +633,22 @@ extension DatabasePool: DatabaseReader { value(.failure(error)) return } - + // Now that we have an isolated snapshot of the last commit, we // can release the writer queue. isolationSemaphore.signal() - + value(.success(db)) } } catch { isolationSemaphore.signal() value(.failure(error)) } - + // Block the writer queue until snapshot isolation success or error _ = isolationSemaphore.wait(timeout: .distantFuture) } - + /// Invalidates open read-only SQLite connections. /// /// After this method is called, read-only database access methods will use @@ -655,14 +663,14 @@ extension DatabasePool: DatabaseReader { public func invalidateReadOnlyConnections() { readerPool?.removeAll() } - + /// Returns a reader that can be used from the current dispatch queue, /// if any. private var currentReader: SerializedDatabase? { guard let readerPool else { return nil } - + var readers: [SerializedDatabase] = [] readerPool.forEach { reader in // We can't check for reader.onValidQueue here because @@ -671,7 +679,7 @@ extension DatabasePool: DatabaseReader { // it below. readers.append(reader) } - + // Now the readers array contains some readers. The pool readers may // already be different, because some other thread may have started // a new read, for example. @@ -681,54 +689,59 @@ extension DatabasePool: DatabaseReader { // in the pool, and thus still relevant for our check: return readers.first { $0.onValidQueue } } - + // MARK: - WAL Snapshot Transactions - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - /// Returns a long-lived WAL snapshot transaction on a reader connection. - func walSnapshotTransaction() throws -> WALSnapshotTransaction { - guard let readerPool else { - throw DatabaseError.connectionIsClosed() - } - - let (reader, releaseReader) = try readerPool.get() - return try WALSnapshotTransaction(onReader: reader, release: { isInsideTransaction in - // Discard the connection if the transaction could not be - // properly ended. If we'd reuse it, the next read would - // fail because we'd fail starting a read transaction. - releaseReader(isInsideTransaction ? .discard : .reuse) - }) - } - - /// Returns a long-lived WAL snapshot transaction on a reader connection. - /// - /// - important: The `completion` argument is executed in a serial - /// dispatch queue, so make sure you use the transaction asynchronously. - func asyncWALSnapshotTransaction( - _ completion: @escaping @Sendable (Result) -> Void - ) { - guard let readerPool else { - completion(.failure(DatabaseError.connectionIsClosed())) - return + + #if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + /// Returns a long-lived WAL snapshot transaction on a reader connection. + func walSnapshotTransaction() throws -> WALSnapshotTransaction { + guard let readerPool else { + throw DatabaseError.connectionIsClosed() + } + + let (reader, releaseReader) = try readerPool.get() + return try WALSnapshotTransaction( + onReader: reader, + release: { isInsideTransaction in + // Discard the connection if the transaction could not be + // properly ended. If we'd reuse it, the next read would + // fail because we'd fail starting a read transaction. + releaseReader(isInsideTransaction ? .discard : .reuse) + }) } - - readerPool.asyncGet { result in - completion(result.flatMap { reader, releaseReader in - Result { - try WALSnapshotTransaction(onReader: reader, release: { isInsideTransaction in - // Discard the connection if the transaction could not be - // properly ended. If we'd reuse it, the next read would - // fail because we'd fail starting a read transaction. - releaseReader(isInsideTransaction ? .discard : .reuse) + + /// Returns a long-lived WAL snapshot transaction on a reader connection. + /// + /// - important: The `completion` argument is executed in a serial + /// dispatch queue, so make sure you use the transaction asynchronously. + func asyncWALSnapshotTransaction( + _ completion: @escaping @Sendable (Result) -> Void + ) { + guard let readerPool else { + completion(.failure(DatabaseError.connectionIsClosed())) + return + } + + readerPool.asyncGet { result in + completion( + result.flatMap { reader, releaseReader in + Result { + try WALSnapshotTransaction( + onReader: reader, + release: { isInsideTransaction in + // Discard the connection if the transaction could not be + // properly ended. If we'd reuse it, the next read would + // fail because we'd fail starting a read transaction. + releaseReader(isInsideTransaction ? .discard : .reuse) + }) + } }) - } - }) + } } - } -#endif - + #endif + // MARK: - Database Observation - + public func _add( observation: ValueObservation, scheduling scheduler: some ValueObservationScheduler, @@ -740,14 +753,14 @@ extension DatabasePool: DatabaseReader { observation: observation, scheduling: scheduler, onChange: onChange) - + } else if observation.requiresWriteAccess { // Observe from the writer database connection. return _addWriteOnly( observation: observation, scheduling: scheduler, onChange: onChange) - + } else { // DatabasePool can perform concurrent observation return _addConcurrent( @@ -756,7 +769,7 @@ extension DatabasePool: DatabaseReader { onChange: onChange) } } - + /// A concurrent observation fetches the initial value without waiting for /// the writer. private func _addConcurrent( @@ -779,19 +792,19 @@ extension DatabasePool: DatabaseReader { extension DatabasePool: DatabaseWriter { // MARK: - Writing in Database - - @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails + + @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails public func writeWithoutTransaction(_ updates: (Database) throws -> T) rethrows -> T { try writer.sync(updates) } - + public func writeWithoutTransaction( _ updates: @Sendable (Database) throws -> T ) async throws -> T { try await writer.execute(updates) } - - @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails + + @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails public func barrierWriteWithoutTransaction(_ updates: (Database) throws -> T) throws -> T { guard let readerPool else { throw DatabaseError.connectionIsClosed() @@ -800,14 +813,14 @@ extension DatabasePool: DatabaseWriter { try writer.sync(updates) } } - + public func barrierWriteWithoutTransaction( _ updates: @Sendable (Database) throws -> T ) async throws -> T { guard let readerPool else { throw DatabaseError.connectionIsClosed() } - + // Pool.barrier does not support async calls (yet?). // So we perform cancellation checks just as in // the async version of SerializedDatabase.execute(). @@ -830,7 +843,7 @@ extension DatabasePool: DatabaseWriter { cancelMutex.withLock { $0?() } } } - + public func asyncBarrierWriteWithoutTransaction( _ updates: @escaping @Sendable (Result) -> Void ) { @@ -842,7 +855,7 @@ extension DatabasePool: DatabaseWriter { self.writer.sync { updates(.success($0)) } } } - + /// Wraps database operations inside a database transaction. /// /// The `updates` function runs in the writer dispatch queue, serialized @@ -873,8 +886,9 @@ extension DatabasePool: DatabaseWriter { /// - throws: The error thrown by `updates`, or by the wrapping transaction. public func writeInTransaction( _ kind: Database.TransactionKind? = nil, - _ updates: (Database) throws -> Database.TransactionCompletion) - throws + _ updates: (Database) throws -> Database.TransactionCompletion + ) + throws { try writer.sync { db in try db.inTransaction(kind) { @@ -882,11 +896,11 @@ extension DatabasePool: DatabaseWriter { } } } - + public func unsafeReentrantWrite(_ updates: (Database) throws -> T) rethrows -> T { try writer.reentrantSync(updates) } - + public func asyncWriteWithoutTransaction( _ updates: @escaping @Sendable (Database) -> Void ) { @@ -895,9 +909,9 @@ extension DatabasePool: DatabaseWriter { } extension DatabasePool { - + // MARK: - Snapshots - + /// Creates a database snapshot that serializes accesses to an unchanging /// database content, as it exists at the moment the snapshot is created. /// @@ -942,31 +956,31 @@ extension DatabasePool { "makeSnapshot() must not be called from inside a transaction.") } } - + return try DatabaseSnapshot( path: path, configuration: DatabasePool.readerConfiguration(writer.configuration), defaultLabel: "GRDB.DatabasePool", purpose: "snapshot.\(databaseSnapshotCountMutex.increment())") } - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - /// Creates a database snapshot that allows concurrent accesses to an - /// unchanging database content, as it exists at the moment the snapshot - /// is created. - /// - /// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features) - /// - /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite - /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), - /// or if this method is called from a write transaction, or if the - /// wal file is missing or truncated (size zero). - /// - /// Related SQLite documentation: - public func makeSnapshotPool() throws -> DatabaseSnapshotPool { - try unsafeReentrantRead { db in - try DatabaseSnapshotPool(db) + + #if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + /// Creates a database snapshot that allows concurrent accesses to an + /// unchanging database content, as it exists at the moment the snapshot + /// is created. + /// + /// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features) + /// + /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite + /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), + /// or if this method is called from a write transaction, or if the + /// wal file is missing or truncated (size zero). + /// + /// Related SQLite documentation: + public func makeSnapshotPool() throws -> DatabaseSnapshotPool { + try unsafeReentrantRead { db in + try DatabaseSnapshotPool(db) + } } - } -#endif + #endif } diff --git a/GRDB/Core/DatabaseSnapshotPool.swift b/GRDB/Core/DatabaseSnapshotPool.swift index 29820afe80..bfc2965100 100644 --- a/GRDB/Core/DatabaseSnapshotPool.swift +++ b/GRDB/Core/DatabaseSnapshotPool.swift @@ -1,416 +1,421 @@ -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) -// Import C SQLite functions -#if SWIFT_PACKAGE -import GRDBSQLite -#elseif GRDBCIPHER -import SQLCipher -#elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 -#endif +#if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + // Import C SQLite functions + #if SWIFT_PACKAGE + import GRDBSQLite + #elseif GRDBCIPHER + import SQLCipher + #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER + import SQLite3 + #endif -/// A database connection that allows concurrent accesses to an unchanging -/// database content, as it existed at the moment the snapshot was created. -/// -/// ## Overview -/// -/// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features) -/// -/// A `DatabaseSnapshotPool` never sees any database modification during all its -/// lifetime. All database accesses performed from a snapshot always see the -/// same identical database content. -/// -/// It creates a pool of up to ``Configuration/maximumReaderCount`` read-only -/// SQLite connections. All read accesses are executed in **reader dispatch -/// queues** (one per read-only SQLite connection). SQLite connections are -/// closed when the `DatabasePool` is deallocated. -/// -/// An SQLite database in the [WAL mode](https://www.sqlite.org/wal.html) is -/// required for creating a `DatabaseSnapshotPool`. -/// -/// ## Usage -/// -/// You create a `DatabaseSnapshotPool` from a -/// [WAL mode](https://www.sqlite.org/wal.html) database, such as databases -/// created from a ``DatabasePool``: -/// -/// ```swift -/// let dbPool = try DatabasePool(path: "/path/to/database.sqlite") -/// let snapshot = try dbPool.makeSnapshotPool() -/// ``` -/// -/// When you want to control the database state seen by a snapshot, create the -/// snapshot from a database connection, outside of a write transaction. You can -/// for example take snapshots from a ``ValueObservation``: -/// -/// ```swift -/// // An observation of the 'player' table -/// // that notifies fresh database snapshots: -/// let observation = ValueObservation.tracking { db in -/// // Don't fetch players now, and return a snapshot instead. -/// // Register an access to the player table so that the -/// // observation tracks changes to this table. -/// try db.registerAccess(to: Player.all()) -/// return try DatabaseSnapshotPool(db) -/// } -/// -/// // Start observing the 'player' table -/// let cancellable = try observation.start(in: dbPool) { error in -/// // Handle error -/// } onChange: { (snapshot: DatabaseSnapshotPool) in -/// // Handle a fresh snapshot -/// } -/// ``` -/// -/// `DatabaseSnapshotPool` inherits its database access methods from the -/// ``DatabaseReader`` protocols. -/// -/// Related SQLite documentation: -/// -/// - -/// - -/// -/// ## Topics -/// -/// ### Creating a DatabaseSnapshotPool -/// -/// See also ``DatabasePool/makeSnapshotPool()``. -/// -/// - ``init(_:configuration:)`` -/// - ``init(path:configuration:)`` -public final class DatabaseSnapshotPool { - public let configuration: Configuration - - /// The path to the database file. - public let path: String - - /// The pool of reader connections. - /// It is constant, until close() sets it to nil. - private var readerPool: Pool? - - /// The WAL snapshot - private let walSnapshot: WALSnapshot - - /// A connection that prevents checkpoints and keeps the WAL snapshot valid. - /// It is never used. - private let snapshotHolder: DatabaseQueue - - /// Creates a snapshot of the database. + /// A database connection that allows concurrent accesses to an unchanging + /// database content, as it existed at the moment the snapshot was created. + /// + /// ## Overview + /// + /// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features) + /// + /// A `DatabaseSnapshotPool` never sees any database modification during all its + /// lifetime. All database accesses performed from a snapshot always see the + /// same identical database content. + /// + /// It creates a pool of up to ``Configuration/maximumReaderCount`` read-only + /// SQLite connections. All read accesses are executed in **reader dispatch + /// queues** (one per read-only SQLite connection). SQLite connections are + /// closed when the `DatabasePool` is deallocated. + /// + /// An SQLite database in the [WAL mode](https://www.sqlite.org/wal.html) is + /// required for creating a `DatabaseSnapshotPool`. + /// + /// ## Usage /// - /// For example: + /// You create a `DatabaseSnapshotPool` from a + /// [WAL mode](https://www.sqlite.org/wal.html) database, such as databases + /// created from a ``DatabasePool``: /// /// ```swift /// let dbPool = try DatabasePool(path: "/path/to/database.sqlite") - /// let snapshot = try dbPool.writeWithoutTransaction { db -> DatabaseSnapshotPool in - /// try db.inTransaction { - /// try Player.deleteAll() - /// return .commit - /// } + /// let snapshot = try dbPool.makeSnapshotPool() + /// ``` + /// + /// When you want to control the database state seen by a snapshot, create the + /// snapshot from a database connection, outside of a write transaction. You can + /// for example take snapshots from a ``ValueObservation``: /// - /// // Create the snapshot after all players have been deleted. - /// return DatabaseSnapshotPool(db) + /// ```swift + /// // An observation of the 'player' table + /// // that notifies fresh database snapshots: + /// let observation = ValueObservation.tracking { db in + /// // Don't fetch players now, and return a snapshot instead. + /// // Register an access to the player table so that the + /// // observation tracks changes to this table. + /// try db.registerAccess(to: Player.all()) + /// return try DatabaseSnapshotPool(db) /// } /// - /// // Later... Maybe some players have been created. - /// // The snapshot is guaranteed to see an empty table of players, though: - /// let count = try snapshot.read { db in - /// try Player.fetchCount(db) + /// // Start observing the 'player' table + /// let cancellable = try observation.start(in: dbPool) { error in + /// // Handle error + /// } onChange: { (snapshot: DatabaseSnapshotPool) in + /// // Handle a fresh snapshot /// } - /// assert(count == 0) /// ``` /// - /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite - /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), - /// or if this method is called from a write transaction, or if the - /// wal file is missing or truncated (size zero). + /// `DatabaseSnapshotPool` inherits its database access methods from the + /// ``DatabaseReader`` protocols. /// - /// Related SQLite documentation: + /// Related SQLite documentation: /// - /// - parameter db: A database connection. - /// - parameter configuration: A configuration. If nil, the configuration of - /// `db` is used. - /// - throws: A ``DatabaseError`` whenever an SQLite error occurs. - public init(_ db: Database, configuration: Configuration? = nil) throws { - let path = db.path - var configuration = Self.configure(configuration ?? db.configuration) - - // Acquire and hold WAL snapshot - let walSnapshot = try db.isolated(readOnly: true) { - try WALSnapshot(db) - } - var holderConfig = Configuration() - holderConfig.allowsUnsafeTransactions = true - snapshotHolder = try DatabaseQueue(path: path, configuration: holderConfig) - try snapshotHolder.inDatabase { db in - try db.beginTransaction(.deferred) - try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") - let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) - guard code == SQLITE_OK else { - throw DatabaseError(resultCode: code) - } - } - - configuration.prepareDatabase { db in - try db.beginTransaction(.deferred) - try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") - let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) - guard code == SQLITE_OK else { - throw DatabaseError(resultCode: code) - } - } - - self.configuration = configuration - self.path = path - self.walSnapshot = walSnapshot - - readerPool = Pool( - maximumCount: configuration.maximumReaderCount, - qos: configuration.readQoS, - makeElement: { [configuration] index in - return try SerializedDatabase( - path: path, - configuration: configuration, - defaultLabel: "GRDB.DatabaseSnapshotPool", - purpose: "snapshot.\(index)") - }) - } - - /// Creates a snapshot of the database. + /// - + /// - /// - /// For example: - /// - /// ```swift - /// let snapshot = try DatabaseSnapshotPool(path: "/path/to/database.sqlite") - /// ``` + /// ## Topics /// - /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite - /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), - /// or if the wal file is missing or truncated (size zero). + /// ### Creating a DatabaseSnapshotPool /// - /// Related SQLite documentation: + /// See also ``DatabasePool/makeSnapshotPool()``. /// - /// - parameters: - /// - path: The path to the database file. - /// - configuration: A configuration. - /// - throws: A ``DatabaseError`` whenever an SQLite error occurs. - public init(path: String, configuration: Configuration = Configuration()) throws { - var configuration = Self.configure(configuration) - - // Acquire and hold WAL snapshot - var holderConfig = Configuration() - holderConfig.allowsUnsafeTransactions = true - snapshotHolder = try DatabaseQueue(path: path, configuration: holderConfig) - let walSnapshot = try snapshotHolder.inDatabase { db in - try db.beginTransaction(.deferred) - try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") - return try WALSnapshot(db) - } - - configuration.prepareDatabase { db in - try db.beginTransaction(.deferred) - try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") - let code = sqlite3_snapshot_open(db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) - guard code == SQLITE_OK else { - throw DatabaseError(resultCode: code) + /// - ``init(_:configuration:)`` + /// - ``init(path:configuration:)`` + public final class DatabaseSnapshotPool { + public let configuration: Configuration + + /// The path to the database file. + public let path: String + + /// The pool of reader connections. + /// It is constant, until close() sets it to nil. + private var readerPool: Pool? + + /// The WAL snapshot + private let walSnapshot: WALSnapshot + + /// A connection that prevents checkpoints and keeps the WAL snapshot valid. + /// It is never used. + private let snapshotHolder: DatabaseQueue + + /// Creates a snapshot of the database. + /// + /// For example: + /// + /// ```swift + /// let dbPool = try DatabasePool(path: "/path/to/database.sqlite") + /// let snapshot = try dbPool.writeWithoutTransaction { db -> DatabaseSnapshotPool in + /// try db.inTransaction { + /// try Player.deleteAll() + /// return .commit + /// } + /// + /// // Create the snapshot after all players have been deleted. + /// return DatabaseSnapshotPool(db) + /// } + /// + /// // Later... Maybe some players have been created. + /// // The snapshot is guaranteed to see an empty table of players, though: + /// let count = try snapshot.read { db in + /// try Player.fetchCount(db) + /// } + /// assert(count == 0) + /// ``` + /// + /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite + /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), + /// or if this method is called from a write transaction, or if the + /// wal file is missing or truncated (size zero). + /// + /// Related SQLite documentation: + /// + /// - parameter db: A database connection. + /// - parameter configuration: A configuration. If nil, the configuration of + /// `db` is used. + /// - throws: A ``DatabaseError`` whenever an SQLite error occurs. + public init(_ db: Database, configuration: Configuration? = nil) throws { + let path = db.path + var configuration = Self.configure(configuration ?? db.configuration) + + // Acquire and hold WAL snapshot + let walSnapshot = try db.isolated(readOnly: true) { + try WALSnapshot(db) } + var holderConfig = Configuration() + holderConfig.allowsUnsafeTransactions = true + snapshotHolder = try DatabaseQueue(path: path, configuration: holderConfig) + try snapshotHolder.inDatabase { db in + try db.beginTransaction(.deferred) + try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") + let code = sqlite3_snapshot_open( + db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) + guard code == SQLITE_OK else { + throw DatabaseError(resultCode: code) + } + } + + configuration.prepareDatabase { db in + try db.beginTransaction(.deferred) + try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") + let code = sqlite3_snapshot_open( + db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) + guard code == SQLITE_OK else { + throw DatabaseError(resultCode: code) + } + } + + self.configuration = configuration + self.path = path + self.walSnapshot = walSnapshot + + readerPool = Pool( + maximumCount: configuration.maximumReaderCount, + qos: configuration.readQoS, + makeElement: { [configuration] index in + return try SerializedDatabase( + path: path, + configuration: configuration, + defaultLabel: "GRDB.DatabaseSnapshotPool", + purpose: "snapshot.\(index)") + }) } - - self.configuration = configuration - self.path = path - self.walSnapshot = walSnapshot - - readerPool = Pool( - maximumCount: configuration.maximumReaderCount, - qos: configuration.readQoS, - makeElement: { [configuration] index in - return try SerializedDatabase( - path: path, - configuration: configuration, - defaultLabel: "GRDB.DatabaseSnapshotPool", - purpose: "snapshot.\(index)") - }) - } - - private static func configure(_ configuration: Configuration) -> Configuration { - var configuration = configuration - - // DatabaseSnapshotPool needs a non-empty pool of connections. - GRDBPrecondition(configuration.maximumReaderCount > 0, "configuration.maximumReaderCount must be at least 1") - - // DatabaseSnapshotPool is read-only. - configuration.readonly = true - - // DatabaseSnapshotPool keeps a long-lived transaction. - configuration.allowsUnsafeTransactions = true - - // DatabaseSnapshotPool requires the WAL mode. - // See - if configuration.readonlyBusyMode == nil { - configuration.readonlyBusyMode = .timeout(10) - } - - return configuration - } -} -extension DatabaseSnapshotPool: @unchecked Sendable { } + /// Creates a snapshot of the database. + /// + /// For example: + /// + /// ```swift + /// let snapshot = try DatabaseSnapshotPool(path: "/path/to/database.sqlite") + /// ``` + /// + /// A ``DatabaseError`` of code `SQLITE_ERROR` is thrown if the SQLite + /// database is not in the [WAL mode](https://www.sqlite.org/wal.html), + /// or if the wal file is missing or truncated (size zero). + /// + /// Related SQLite documentation: + /// + /// - parameters: + /// - path: The path to the database file. + /// - configuration: A configuration. + /// - throws: A ``DatabaseError`` whenever an SQLite error occurs. + public init(path: String, configuration: Configuration = Configuration()) throws { + var configuration = Self.configure(configuration) -extension DatabaseSnapshotPool: DatabaseSnapshotReader { - public func close() throws { - try readerPool?.barrier { - defer { readerPool = nil } - - try readerPool?.forEach { reader in - try reader.sync { try $0.close() } + // Acquire and hold WAL snapshot + var holderConfig = Configuration() + holderConfig.allowsUnsafeTransactions = true + snapshotHolder = try DatabaseQueue(path: path, configuration: holderConfig) + let walSnapshot = try snapshotHolder.inDatabase { db in + try db.beginTransaction(.deferred) + try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") + return try WALSnapshot(db) } + + configuration.prepareDatabase { db in + try db.beginTransaction(.deferred) + try db.execute(sql: "SELECT rootpage FROM sqlite_master LIMIT 1") + let code = sqlite3_snapshot_open( + db.sqliteConnection, "main", walSnapshot.sqliteSnapshot) + guard code == SQLITE_OK else { + throw DatabaseError(resultCode: code) + } + } + + self.configuration = configuration + self.path = path + self.walSnapshot = walSnapshot + + readerPool = Pool( + maximumCount: configuration.maximumReaderCount, + qos: configuration.readQoS, + makeElement: { [configuration] index in + return try SerializedDatabase( + path: path, + configuration: configuration, + defaultLabel: "GRDB.DatabaseSnapshotPool", + purpose: "snapshot.\(index)") + }) } - } - - public func interrupt() { - readerPool?.forEach { $0.interrupt() } - } - - @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails - public func read(_ value: (Database) throws -> T) throws -> T { - GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.") - guard let readerPool else { - throw DatabaseError.connectionIsClosed() - } - - let (reader, releaseReader) = try readerPool.get() - var completion: PoolCompletion! - defer { - releaseReader(completion) - } - return try reader.sync { db in - do { - let value = try value(db) - completion = poolCompletion(db) - return value - } catch { - completion = poolCompletion(db) - throw error + + private static func configure(_ configuration: Configuration) -> Configuration { + var configuration = configuration + + // DatabaseSnapshotPool needs a non-empty pool of connections. + GRDBPrecondition( + configuration.maximumReaderCount > 0, + "configuration.maximumReaderCount must be at least 1") + + // DatabaseSnapshotPool is read-only. + configuration.readonly = true + + // DatabaseSnapshotPool keeps a long-lived transaction. + configuration.allowsUnsafeTransactions = true + + // DatabaseSnapshotPool requires the WAL mode. + // See + if configuration.readonlyBusyMode == nil { + configuration.readonlyBusyMode = .timeout(10) } + + return configuration } } - - public func read( - _ value: @Sendable (Database) throws -> T - ) async throws -> T { - guard let readerPool else { - throw DatabaseError.connectionIsClosed() + + extension DatabaseSnapshotPool: @unchecked Sendable {} + + extension DatabaseSnapshotPool: DatabaseSnapshotReader { + public func close() throws { + try readerPool?.barrier { + defer { readerPool = nil } + + try readerPool?.forEach { reader in + try reader.sync { try $0.close() } + } + } } - - let (reader, releaseReader) = try await readerPool.get() - var readerCompletion: PoolCompletion? - defer { - // readerCompletion might be null in cancelled database accesses - releaseReader(readerCompletion ?? .reuse) + + public func interrupt() { + readerPool?.forEach { $0.interrupt() } } - let (result, completion) = try await reader.execute { db in - let result = Result { try value(db) } - let completion = poolCompletion(db) - return (result, completion) + + @_disfavoredOverload // SR-15150 Async overloading in protocol implementation fails + public func read(_ value: (Database) throws -> T) throws -> T { + GRDBPrecondition(currentReader == nil, "Database methods are not reentrant.") + guard let readerPool else { + throw DatabaseError.connectionIsClosed() + } + + let (reader, releaseReader) = try readerPool.get() + var completion: PoolCompletion! + defer { + releaseReader(completion) + } + return try reader.sync { db in + do { + let value = try value(db) + completion = poolCompletion(db) + return value + } catch { + completion = poolCompletion(db) + throw error + } + } } - readerCompletion = completion - return try result.get() - } - - public func asyncRead( - _ value: @escaping @Sendable (Result) -> Void - ) { - guard let readerPool else { - value(.failure(DatabaseError.connectionIsClosed())) - return + + public func read( + _ value: @Sendable (Database) throws -> T + ) async throws -> T { + guard let readerPool else { + throw DatabaseError.connectionIsClosed() + } + + let (reader, releaseReader) = try await readerPool.get() + var readerCompletion: PoolCompletion? + defer { + // readerCompletion might be null in cancelled database accesses + releaseReader(readerCompletion ?? .reuse) + } + let (result, completion) = try await reader.execute { db in + let result = Result { try value(db) } + let completion = poolCompletion(db) + return (result, completion) + } + readerCompletion = completion + return try result.get() } - - readerPool.asyncGet { result in - do { - let (reader, releaseReader) = try result.get() - // Second async jump because that's how `Pool.async` has to be used. - reader.async { db in - value(.success(db)) - releaseReader(self.poolCompletion(db)) + + public func asyncRead( + _ value: @escaping @Sendable (Result) -> Void + ) { + guard let readerPool else { + value(.failure(DatabaseError.connectionIsClosed())) + return + } + + readerPool.asyncGet { result in + do { + let (reader, releaseReader) = try result.get() + // Second async jump because that's how `Pool.async` has to be used. + reader.async { db in + value(.success(db)) + releaseReader(self.poolCompletion(db)) + } + } catch { + value(.failure(error)) } - } catch { - value(.failure(error)) } } - } - - // There is no such thing as an unsafe access to a snapshot. - // We can't provide this as a default implementation in - // `DatabaseSnapshotReader`, because of - // . - public func unsafeRead( - _ value: @Sendable (Database) throws -> T - ) async throws -> T { - try await read(value) - } - - public func unsafeReentrantRead(_ value: (Database) throws -> T) throws -> T { - if let reader = currentReader { - return try reader.reentrantSync { db in - let result = try value(db) - if snapshotIsLost(db) { - throw DatabaseError.snapshotIsLost() + + // There is no such thing as an unsafe access to a snapshot. + // We can't provide this as a default implementation in + // `DatabaseSnapshotReader`, because of + // . + public func unsafeRead( + _ value: @Sendable (Database) throws -> T + ) async throws -> T { + try await read(value) + } + + public func unsafeReentrantRead(_ value: (Database) throws -> T) throws -> T { + if let reader = currentReader { + return try reader.reentrantSync { db in + let result = try value(db) + if snapshotIsLost(db) { + throw DatabaseError.snapshotIsLost() + } + return result } - return result + } else { + // There is no unsafe access to a snapshot. + return try read(value) } - } else { - // There is no unsafe access to a snapshot. - return try read(value) } - } - - public func _add( - observation: ValueObservation, - scheduling scheduler: some ValueObservationScheduler, - onChange: @escaping @Sendable (Reducer.Value) -> Void - ) -> AnyDatabaseCancellable where Reducer: ValueReducer { - _addReadOnly(observation: observation, scheduling: scheduler, onChange: onChange) - } - - /// Returns a reader that can be used from the current dispatch queue, - /// if any. - private var currentReader: SerializedDatabase? { - guard let readerPool else { - return nil + + public func _add( + observation: ValueObservation, + scheduling scheduler: some ValueObservationScheduler, + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) -> AnyDatabaseCancellable where Reducer: ValueReducer { + _addReadOnly(observation: observation, scheduling: scheduler, onChange: onChange) + } + + /// Returns a reader that can be used from the current dispatch queue, + /// if any. + private var currentReader: SerializedDatabase? { + guard let readerPool else { + return nil + } + + var readers: [SerializedDatabase] = [] + readerPool.forEach { reader in + // We can't check for reader.onValidQueue here because + // Pool.forEach() runs its closure argument in some arbitrary + // dispatch queue. We thus extract the reader so that we can query + // it below. + readers.append(reader) + } + + // Now the readers array contains some readers. The pool readers may + // already be different, because some other thread may have started + // a new read, for example. + // + // This doesn't matter: the reader we are looking for is already on + // its own dispatch queue. If it exists, is still in use, thus still + // in the pool, and thus still relevant for our check: + return readers.first { $0.onValidQueue } } - - var readers: [SerializedDatabase] = [] - readerPool.forEach { reader in - // We can't check for reader.onValidQueue here because - // Pool.forEach() runs its closure argument in some arbitrary - // dispatch queue. We thus extract the reader so that we can query - // it below. - readers.append(reader) + + private func poolCompletion(_ db: Database) -> PoolCompletion { + snapshotIsLost(db) ? .discard : .reuse } - - // Now the readers array contains some readers. The pool readers may - // already be different, because some other thread may have started - // a new read, for example. - // - // This doesn't matter: the reader we are looking for is already on - // its own dispatch queue. If it exists, is still in use, thus still - // in the pool, and thus still relevant for our check: - return readers.first { $0.onValidQueue } - } - - private func poolCompletion(_ db: Database) -> PoolCompletion { - snapshotIsLost(db) ? .discard : .reuse - } - - private func snapshotIsLost(_ db: Database) -> Bool { - do { - let currentSnapshot = try WALSnapshot(db) - if currentSnapshot.compare(walSnapshot) == 0 { - return false - } else { + + private func snapshotIsLost(_ db: Database) -> Bool { + do { + let currentSnapshot = try WALSnapshot(db) + if currentSnapshot.compare(walSnapshot) == 0 { + return false + } else { + return true + } + } catch { return true } - } catch { - return true } } -} #endif diff --git a/GRDB/Core/DispatchQueueActor.swift b/GRDB/Core/DispatchQueueActor.swift index 3a51d8897f..db25554dc7 100644 --- a/GRDB/Core/DispatchQueueActor.swift +++ b/GRDB/Core/DispatchQueueActor.swift @@ -1,20 +1,20 @@ -import Dispatch +@preconcurrency import Dispatch /// An actor that runs in a DispatchQueue. /// /// Inspired by actor DispatchQueueActor { private let executor: DispatchQueueExecutor - + /// - precondition: the queue is serial, or flags contains `.barrier`. init(queue: DispatchQueue, flags: DispatchWorkItemFlags = []) { self.executor = DispatchQueueExecutor(queue: queue, flags: flags) } - + nonisolated var unownedExecutor: UnownedSerialExecutor { executor.asUnownedSerialExecutor() } - + func execute(_ work: () throws -> T) rethrows -> T { try work() } @@ -23,22 +23,22 @@ actor DispatchQueueActor { private final class DispatchQueueExecutor: SerialExecutor { private let queue: DispatchQueue private let flags: DispatchWorkItemFlags - + init(queue: DispatchQueue, flags: DispatchWorkItemFlags) { self.queue = queue self.flags = flags } - + func enqueue(_ job: UnownedJob) { queue.async(flags: flags) { job.runSynchronously(on: self.asUnownedSerialExecutor()) } } - + func asUnownedSerialExecutor() -> UnownedSerialExecutor { UnownedSerialExecutor(ordinary: self) } - + func checkIsolated() { dispatchPrecondition(condition: .onQueue(queue)) } diff --git a/GRDB/Core/Support/CoreGraphics/CGFloat.swift b/GRDB/Core/Support/CoreGraphics/CGFloat.swift index 6b0fb6053b..d8568a9dba 100644 --- a/GRDB/Core/Support/CoreGraphics/CGFloat.swift +++ b/GRDB/Core/Support/CoreGraphics/CGFloat.swift @@ -1,5 +1,8 @@ #if canImport(CoreGraphics) -import CoreGraphics + import CoreGraphics +#elseif !canImport(Darwin) + import Foundation +#endif /// CGFloat adopts DatabaseValueConvertible extension CGFloat: DatabaseValueConvertible { @@ -7,7 +10,7 @@ extension CGFloat: DatabaseValueConvertible { public var databaseValue: DatabaseValue { Double(self).databaseValue } - + public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> CGFloat? { guard let double = Double.fromDatabaseValue(dbValue) else { return nil @@ -15,4 +18,3 @@ extension CGFloat: DatabaseValueConvertible { return CGFloat(double) } } -#endif diff --git a/GRDB/Core/Support/Foundation/Date.swift b/GRDB/Core/Support/Foundation/Date.swift index 6676c065ae..3f718f3cb2 100644 --- a/GRDB/Core/Support/Foundation/Date.swift +++ b/GRDB/Core/Support/Foundation/Date.swift @@ -1,15 +1,14 @@ +import Foundation + // Import C SQLite functions #if SWIFT_PACKAGE -import GRDBSQLite + import GRDBSQLite #elseif GRDBCIPHER -import SQLCipher + import SQLCipher #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 + import SQLite3 #endif -import Foundation - -#if !os(Linux) /// NSDate is stored in the database using the format /// "yyyy-MM-dd HH:mm:ss.SSS", in the UTC time zone. extension NSDate: DatabaseValueConvertible { @@ -18,7 +17,7 @@ extension NSDate: DatabaseValueConvertible { public var databaseValue: DatabaseValue { (self as Date).databaseValue } - + /// Creates an `NSDate` with the specified database value. /// /// If the database value contains a number, that number is interpreted as a @@ -38,7 +37,6 @@ extension NSDate: DatabaseValueConvertible { return cast(date) } } -#endif /// Date is stored in the database using the format /// "yyyy-MM-dd HH:mm:ss.SSS", in the UTC time zone. @@ -48,7 +46,7 @@ extension Date: DatabaseValueConvertible { public var databaseValue: DatabaseValue { storageDateFormatter.string(from: self).databaseValue } - + /// Creates an `Date` with the specified database value. /// /// If the database value contains a number, that number is interpreted as a @@ -70,7 +68,7 @@ extension Date: DatabaseValueConvertible { } return nil } - + @usableFromInline init?(databaseDateComponents: DatabaseDateComponents) { guard databaseDateComponents.format.hasYMDComponents else { @@ -82,32 +80,32 @@ extension Date: DatabaseValueConvertible { } self.init(timeIntervalSinceReferenceDate: date.timeIntervalSinceReferenceDate) } - + /// Creates a date from a [Julian Day](https://en.wikipedia.org/wiki/Julian_day). public init?(julianDay: Double) { // Conversion uses the same algorithm as SQLite: https://www.sqlite.org/src/artifact/8ec787fed4929d8c // TODO: check for overflows one day, and return nil when computation can't complete. - let JD = Int64(julianDay * 86400000) - let Z = Int(((JD + 43200000)/86400000)) - var A = Int(((Double(Z) - 1867216.25)/36524.25)) - A = Z + 1 + A - (A/4) + let JD = Int64(julianDay * 86_400_000) + let Z = Int(((JD + 43_200_000) / 86_400_000)) + var A = Int(((Double(Z) - 1867216.25) / 36524.25)) + A = Z + 1 + A - (A / 4) let B = A + 1524 - let C = Int(((Double(B) - 122.1)/365.25)) - let D = (36525*(C&32767))/100 - let E = Int((Double(B-D)/30.6001)) - let X1 = Int((30.6001*Double(E))) + let C = Int(((Double(B) - 122.1) / 365.25)) + let D = (36525 * (C & 32767)) / 100 + let E = Int((Double(B - D) / 30.6001)) + let X1 = Int((30.6001 * Double(E))) let day = B - D - X1 - let month = E<14 ? E-1 : E-13 - let year = month>2 ? C - 4716 : C - 4715 - var s = Int(((JD + 43200000) % 86400000)) - var second = Double(s)/1000.0 + let month = E < 14 ? E - 1 : E - 13 + let year = month > 2 ? C - 4716 : C - 4715 + var s = Int(((JD + 43_200_000) % 86_400_000)) + var second = Double(s) / 1000.0 s = Int(second) second -= Double(s) - let hour = s/3600 - s -= hour*3600 - let minute = s/60 - second += Double(s - minute*60) - + let hour = s / 3600 + s -= hour * 3600 + let minute = s / 60 + second += Double(s - minute * 60) + var dateComponents = DateComponents() dateComponents.year = year dateComponents.month = month @@ -116,7 +114,7 @@ extension Date: DatabaseValueConvertible { dateComponents.minute = minute dateComponents.second = Int(second) dateComponents.nanosecond = Int((second - Double(Int(second))) * 1.0e9) - + guard let date = UTCCalendar.date(from: dateComponents) else { return nil } @@ -125,7 +123,7 @@ extension Date: DatabaseValueConvertible { } extension Date: StatementColumnConvertible { - + /// Returns a value initialized from a raw SQLite statement pointer. /// /// - parameters: @@ -138,8 +136,10 @@ extension Date: StatementColumnConvertible { case SQLITE_INTEGER, SQLITE_FLOAT: self.init(timeIntervalSince1970: sqlite3_column_double(sqliteStatement, index)) case SQLITE_TEXT: - guard let components = DatabaseDateComponents(sqliteStatement: sqliteStatement, index: index), - let date = Date(databaseDateComponents: components) + guard + let components = DatabaseDateComponents( + sqliteStatement: sqliteStatement, index: index), + let date = Date(databaseDateComponents: components) else { return nil } diff --git a/GRDB/Core/Support/Foundation/Decimal.swift b/GRDB/Core/Support/Foundation/Decimal.swift index e200c6c596..fe1e912e8a 100644 --- a/GRDB/Core/Support/Foundation/Decimal.swift +++ b/GRDB/Core/Support/Foundation/Decimal.swift @@ -1,15 +1,14 @@ -#if !os(Linux) +import Foundation + // Import C SQLite functions #if SWIFT_PACKAGE -import GRDBSQLite + import GRDBSQLite #elseif GRDBCIPHER -import SQLCipher + import SQLCipher #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 + import SQLite3 #endif -import Foundation - /// Decimal adopts DatabaseValueConvertible extension Decimal: DatabaseValueConvertible { /// Returns a TEXT decimal value. @@ -18,7 +17,7 @@ extension Decimal: DatabaseValueConvertible { .description(withLocale: Locale(identifier: "en_US_POSIX")) .databaseValue } - + /// Creates an `Decimal` with the specified database value. /// /// If the database value contains a integer or a double, returns a @@ -34,7 +33,7 @@ extension Decimal: DatabaseValueConvertible { return self.init(int64) case .double(let double): return self.init(double) - case let .string(string): + case .string(let string): // Must match NSNumber.fromDatabaseValue(_:) return self.init(string: string, locale: _posixLocale) default: @@ -65,4 +64,3 @@ extension Decimal: StatementColumnConvertible { @usableFromInline let _posixLocale = Locale(identifier: "en_US_POSIX") -#endif diff --git a/GRDB/Core/Support/Foundation/NSData.swift b/GRDB/Core/Support/Foundation/NSData.swift index 72861ac02e..0e4e137485 100644 --- a/GRDB/Core/Support/Foundation/NSData.swift +++ b/GRDB/Core/Support/Foundation/NSData.swift @@ -1,14 +1,13 @@ -#if !os(Linux) import Foundation /// NSData is convertible to and from DatabaseValue. extension NSData: DatabaseValueConvertible { - + /// Returns a BLOB database value. public var databaseValue: DatabaseValue { (self as Data).databaseValue } - + /// Returns a `NSData` from the specified database value. /// /// If the database value contains a data blob, returns it. @@ -24,4 +23,3 @@ extension NSData: DatabaseValueConvertible { return cast(data) } } -#endif diff --git a/GRDB/Core/Support/Foundation/NSNumber.swift b/GRDB/Core/Support/Foundation/NSNumber.swift index 1c5778c19e..2febc9e83b 100644 --- a/GRDB/Core/Support/Foundation/NSNumber.swift +++ b/GRDB/Core/Support/Foundation/NSNumber.swift @@ -1,4 +1,3 @@ -#if !os(Linux) && !os(Windows) import Foundation private let integerRoundingBehavior = NSDecimalNumberHandler( @@ -11,7 +10,7 @@ private let integerRoundingBehavior = NSDecimalNumberHandler( /// NSNumber adopts DatabaseValueConvertible extension NSNumber: DatabaseValueConvertible { - + /// A database value. /// /// If the number is an integer `NSDecimalNumber`, returns an INTEGER @@ -22,13 +21,13 @@ extension NSNumber: DatabaseValueConvertible { public var databaseValue: DatabaseValue { // Don't lose precision: store integers that fits in Int64 as Int64 if let decimal = self as? NSDecimalNumber, - decimal == decimal.rounding(accordingToBehavior: integerRoundingBehavior), // integer - decimal.compare(NSDecimalNumber(value: Int64.max)) != .orderedDescending, // decimal <= Int64.max - decimal.compare(NSDecimalNumber(value: Int64.min)) != .orderedAscending // decimal >= Int64.min + decimal == decimal.rounding(accordingToBehavior: integerRoundingBehavior), // integer + decimal.compare(NSDecimalNumber(value: Int64.max)) != .orderedDescending, // decimal <= Int64.max + decimal.compare(NSDecimalNumber(value: Int64.min)) != .orderedAscending // decimal >= Int64.min { return int64Value.databaseValue } - + switch String(cString: objCType) { case "c": return Int64(int8Value).databaseValue @@ -69,7 +68,7 @@ extension NSNumber: DatabaseValueConvertible { fatalError("DatabaseValueConvertible: Unsupported NSNumber type: \(objCType)") } } - + /// Returns a `NSNumber` from the specified database value. /// /// If the database value is an integer or a double, returns an `NSNumber` @@ -81,11 +80,19 @@ extension NSNumber: DatabaseValueConvertible { /// Otherwise, returns nil. public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? { switch dbValue.storage { + case .int64(let int64) where self is NSDecimalNumber.Type: + let number = NSDecimalNumber(value: int64) + return number as? Self case .int64(let int64): - return self.init(value: int64) + let number = NSNumber(value: int64) + return number as? Self + case .double(let double) where self is NSDecimalNumber.Type: + let number = NSDecimalNumber(value: double) + return number as? Self case .double(let double): - return self.init(value: double) - case let .string(string): + let number = NSNumber(value: double) + return number as? Self + case .string(let string): // Must match Decimal.fromDatabaseValue(_:) guard let decimal = Decimal(string: string, locale: posixLocale) else { return nil } return NSDecimalNumber(decimal: decimal) as? Self @@ -93,7 +100,7 @@ extension NSNumber: DatabaseValueConvertible { return nil } } + } private let posixLocale = Locale(identifier: "en_US_POSIX") -#endif diff --git a/GRDB/Core/Support/Foundation/NSString.swift b/GRDB/Core/Support/Foundation/NSString.swift index acddddb35d..df4a41640c 100644 --- a/GRDB/Core/Support/Foundation/NSString.swift +++ b/GRDB/Core/Support/Foundation/NSString.swift @@ -1,14 +1,13 @@ -#if !os(Linux) import Foundation /// NSString adopts DatabaseValueConvertible extension NSString: DatabaseValueConvertible { - + /// Returns a TEXT database value. public var databaseValue: DatabaseValue { (self as String).databaseValue } - + /// Returns a `NSString` from the specified database value. /// /// If the database value contains a string, returns it. @@ -24,4 +23,3 @@ extension NSString: DatabaseValueConvertible { return self.init(string: string) } } -#endif diff --git a/GRDB/Core/Support/Foundation/URL.swift b/GRDB/Core/Support/Foundation/URL.swift index 6db66ec3b8..8f061305ee 100644 --- a/GRDB/Core/Support/Foundation/URL.swift +++ b/GRDB/Core/Support/Foundation/URL.swift @@ -1,14 +1,17 @@ import Foundation -#if !os(Linux) && !os(Windows) /// NSURL stores its absoluteString in the database. extension NSURL: DatabaseValueConvertible { - + /// Returns a TEXT database value containing the absolute URL. public var databaseValue: DatabaseValue { - absoluteString?.databaseValue ?? .null + #if !canImport(Darwin) + absoluteString.databaseValue + #else + absoluteString?.databaseValue ?? .null + #endif } - + public static func fromDatabaseValue(_ dbValue: DatabaseValue) -> Self? { guard let string = String.fromDatabaseValue(dbValue) else { return nil @@ -16,7 +19,6 @@ extension NSURL: DatabaseValueConvertible { return cast(URL(string: string)) } } -#endif /// URL stores its absoluteString in the database. -extension URL: DatabaseValueConvertible { } +extension URL: DatabaseValueConvertible {} diff --git a/GRDB/Core/Support/Foundation/UUID.swift b/GRDB/Core/Support/Foundation/UUID.swift index 7595b53ef6..76db14f049 100644 --- a/GRDB/Core/Support/Foundation/UUID.swift +++ b/GRDB/Core/Support/Foundation/UUID.swift @@ -1,15 +1,14 @@ +import Foundation + // Import C SQLite functions #if SWIFT_PACKAGE -import GRDBSQLite + import GRDBSQLite #elseif GRDBCIPHER -import SQLCipher + import SQLCipher #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 + import SQLite3 #endif -import Foundation - -#if !os(Linux) && !os(Windows) /// NSUUID adopts DatabaseValueConvertible extension NSUUID: DatabaseValueConvertible { /// Returns a BLOB database value containing the uuid bytes. @@ -20,7 +19,7 @@ extension NSUUID: DatabaseValueConvertible { return Data(bytes: buffer.baseAddress!, count: 16).databaseValue } } - + /// Returns a `NSUUID` from the specified database value. /// /// If the database value contains a string, parses this string as an uuid. @@ -33,16 +32,22 @@ extension NSUUID: DatabaseValueConvertible { switch dbValue.storage { case .blob(let data) where data.count == 16: return data.withUnsafeBytes { - self.init(uuidBytes: $0.bindMemory(to: UInt8.self).baseAddress) + #if canImport(Darwin) + self.init(uuidBytes: $0.bindMemory(to: UInt8.self).baseAddress) + #else + guard let uuidBytes = $0.bindMemory(to: UInt8.self).baseAddress else { + return nil as Self? + } + return NSUUID(uuidBytes: uuidBytes) as? Self + #endif } case .string(let string): - return self.init(uuidString: string) + return NSUUID(uuidString: string) as? Self default: return nil } } } -#endif /// UUID adopts DatabaseValueConvertible extension UUID: DatabaseValueConvertible { @@ -52,7 +57,7 @@ extension UUID: DatabaseValueConvertible { Data(bytes: $0.baseAddress!, count: $0.count).databaseValue } } - + /// Returns a `UUID` from the specified database value. /// /// If the database value contains a string, parses this string as an uuid. @@ -88,8 +93,8 @@ extension UUID: StatementColumnConvertible { self.init(uuid: uuid.uuid) case SQLITE_BLOB: guard sqlite3_column_bytes(sqliteStatement, index) == 16, - let blob = sqlite3_column_blob(sqliteStatement, index) else - { + let blob = sqlite3_column_blob(sqliteStatement, index) + else { return nil } self.init(uuid: blob.assumingMemoryBound(to: uuid_t.self).pointee) diff --git a/GRDB/Core/WALSnapshot.swift b/GRDB/Core/WALSnapshot.swift index a8c0168a73..f99ead3f1c 100644 --- a/GRDB/Core/WALSnapshot.swift +++ b/GRDB/Core/WALSnapshot.swift @@ -1,102 +1,108 @@ -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) -// Import C SQLite functions -#if SWIFT_PACKAGE -import GRDBSQLite -#elseif GRDBCIPHER -import SQLCipher -#elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 -#endif +#if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + // Import C SQLite functions + #if SWIFT_PACKAGE + import GRDBSQLite + #elseif GRDBCIPHER + import SQLCipher + #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER + import SQLite3 + #endif -/// An instance of WALSnapshot records the state of a WAL mode database for some -/// specific point in history. -/// -/// We use `WALSnapshot` to help `ValueObservation` check for changes -/// that would happen between the initial fetch, and the start of the -/// actual observation. This class has no other purpose, and is not intended to -/// become public. -/// -/// It does not work with SQLCipher, because SQLCipher does not support -/// `SQLITE_ENABLE_SNAPSHOT` correctly: we have linker errors. -/// See . -/// -/// With custom SQLite builds, it only works if `SQLITE_ENABLE_SNAPSHOT` -/// is defined. -/// -/// With system SQLite, it works because the SDK exposes the C apis and -/// since XCode 14. -/// -/// Yes, this is an awfully complex logic. -/// -/// See . -final class WALSnapshot: @unchecked Sendable { - // @unchecked because sqlite3_snapshot has no threading requirements. - // - let sqliteSnapshot: UnsafeMutablePointer - - init(_ db: Database) throws { - var sqliteSnapshot: UnsafeMutablePointer? - let code = withUnsafeMutablePointer(to: &sqliteSnapshot) { - return sqlite3_snapshot_get(db.sqliteConnection, "main", $0) - } - guard code == SQLITE_OK else { - // - // - // > The following must be true for sqlite3_snapshot_get() to succeed. [...] - // > - // > 1. The database handle must not be in autocommit mode. - // > 2. Schema S of database connection D must be a WAL - // > mode database. - // > 3. There must not be a write transaction open on schema S - // > of database connection D. - // > 4. One or more transactions must have been written to the - // > current wal file since it was created on disk (by any - // > connection). This means that a snapshot cannot be taken - // > on a wal mode database with no wal file immediately - // > after it is first opened. At least one transaction must - // > be written to it first. - - // Test condition 1: - if sqlite3_get_autocommit(db.sqliteConnection) != 0 { - throw DatabaseError(resultCode: code, message: """ - Can't create snapshot because database is in autocommit mode. - """) + /// An instance of WALSnapshot records the state of a WAL mode database for some + /// specific point in history. + /// + /// We use `WALSnapshot` to help `ValueObservation` check for changes + /// that would happen between the initial fetch, and the start of the + /// actual observation. This class has no other purpose, and is not intended to + /// become public. + /// + /// It does not work with SQLCipher, because SQLCipher does not support + /// `SQLITE_ENABLE_SNAPSHOT` correctly: we have linker errors. + /// See . + /// + /// With custom SQLite builds, it only works if `SQLITE_ENABLE_SNAPSHOT` + /// is defined. + /// + /// With system SQLite, it works because the SDK exposes the C apis and + /// since XCode 14. + /// + /// Yes, this is an awfully complex logic. + /// + /// See . + final class WALSnapshot: @unchecked Sendable { + // @unchecked because sqlite3_snapshot has no threading requirements. + // + let sqliteSnapshot: UnsafeMutablePointer + + init(_ db: Database) throws { + var sqliteSnapshot: UnsafeMutablePointer? + let code = withUnsafeMutablePointer(to: &sqliteSnapshot) { + return sqlite3_snapshot_get(db.sqliteConnection, "main", $0) + } + guard code == SQLITE_OK else { + // + // + // > The following must be true for sqlite3_snapshot_get() to succeed. [...] + // > + // > 1. The database handle must not be in autocommit mode. + // > 2. Schema S of database connection D must be a WAL + // > mode database. + // > 3. There must not be a write transaction open on schema S + // > of database connection D. + // > 4. One or more transactions must have been written to the + // > current wal file since it was created on disk (by any + // > connection). This means that a snapshot cannot be taken + // > on a wal mode database with no wal file immediately + // > after it is first opened. At least one transaction must + // > be written to it first. + + // Test condition 1: + if sqlite3_get_autocommit(db.sqliteConnection) != 0 { + throw DatabaseError( + resultCode: code, + message: """ + Can't create snapshot because database is in autocommit mode. + """) + } + + // Test condition 2: + if let journalMode = try? String.fetchOne(db, sql: "PRAGMA journal_mode"), + journalMode != "wal" + { + throw DatabaseError( + resultCode: code, + message: """ + Can't create snapshot because database is not in WAL mode. + """) + } + + // Condition 3 can't happen because GRDB only calls this + // initializer from read transactions. + // + // Hence it is condition 4 that is false: + throw DatabaseError( + resultCode: code, + message: """ + Can't create snapshot from a missing or empty wal file. + """) } - - // Test condition 2: - if let journalMode = try? String.fetchOne(db, sql: "PRAGMA journal_mode"), - journalMode != "wal" - { - throw DatabaseError(resultCode: code, message: """ - Can't create snapshot because database is not in WAL mode. - """) + guard let sqliteSnapshot else { + throw DatabaseError(resultCode: .SQLITE_INTERNAL) // WTF SQLite? } - - // Condition 3 can't happen because GRDB only calls this - // initializer from read transactions. - // - // Hence it is condition 4 that is false: - throw DatabaseError(resultCode: code, message: """ - Can't create snapshot from a missing or empty wal file. - """) + self.sqliteSnapshot = sqliteSnapshot } - guard let sqliteSnapshot else { - throw DatabaseError(resultCode: .SQLITE_INTERNAL) // WTF SQLite? + + deinit { + sqlite3_snapshot_free(sqliteSnapshot) + } + + /// Compares two WAL snapshots. + /// + /// `a.compare(b) < 0` iff a is older than b. + /// + /// See . + func compare(_ other: WALSnapshot) -> CInt { + sqlite3_snapshot_cmp(sqliteSnapshot, other.sqliteSnapshot) } - self.sqliteSnapshot = sqliteSnapshot - } - - deinit { - sqlite3_snapshot_free(sqliteSnapshot) - } - - /// Compares two WAL snapshots. - /// - /// `a.compare(b) < 0` iff a is older than b. - /// - /// See . - func compare(_ other: WALSnapshot) -> CInt { - sqlite3_snapshot_cmp(sqliteSnapshot, other.sqliteSnapshot) } -} #endif diff --git a/GRDB/Core/WALSnapshotTransaction.swift b/GRDB/Core/WALSnapshotTransaction.swift index 9e961bde34..eab094a34c 100644 --- a/GRDB/Core/WALSnapshotTransaction.swift +++ b/GRDB/Core/WALSnapshotTransaction.swift @@ -1,131 +1,133 @@ -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) -/// A long-live read-only WAL transaction. -/// -/// `WALSnapshotTransaction` **takes ownership** of its reader -/// `SerializedDatabase` (TODO: make it a move-only type eventually). -final class WALSnapshotTransaction: @unchecked Sendable { - // @unchecked because `databaseAccess` is protected by a mutex. - - private struct DatabaseAccess { - let reader: SerializedDatabase - let release: @Sendable (_ isInsideTransaction: Bool) -> Void - - // MUST be called only once - func commitAndRelease() { - // WALSnapshotTransaction may be deinitialized in the dispatch - // queue of its reader: allow reentrancy. - let isInsideTransaction = reader.reentrantSync(allowingLongLivedTransaction: false) { db in - // Commit or rollback, but try hard to leave the read-only transaction - // (commit may fail with a CancellationError). - do { - try db.commit() - } catch { - try? db.rollback() +#if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + /// A long-live read-only WAL transaction. + /// + /// `WALSnapshotTransaction` **takes ownership** of its reader + /// `SerializedDatabase` (TODO: make it a move-only type eventually). + final class WALSnapshotTransaction: @unchecked Sendable { + // @unchecked because `databaseAccess` is protected by a mutex. + + private struct DatabaseAccess { + let reader: SerializedDatabase + let release: @Sendable (_ isInsideTransaction: Bool) -> Void + + // MUST be called only once + func commitAndRelease() { + // WALSnapshotTransaction may be deinitialized in the dispatch + // queue of its reader: allow reentrancy. + let isInsideTransaction = reader.reentrantSync(allowingLongLivedTransaction: false) + { db in + // Commit or rollback, but try hard to leave the read-only transaction + // (commit may fail with a CancellationError). + do { + try db.commit() + } catch { + try? db.rollback() + } + return db.isInsideTransaction } - return db.isInsideTransaction + release(isInsideTransaction) } - release(isInsideTransaction) } - } - - // TODO: consider using the serialized DispatchQueue of reader instead of a lock. - /// nil when closed - private let databaseAccessMutex: Mutex - - /// The state of the database at the beginning of the transaction. - let walSnapshot: WALSnapshot - - /// Creates a long-live WAL transaction on a read-only connection. - /// - /// The `release` closure is always called. It is called when the - /// `WALSnapshotTransaction` is deallocated, or if the initializer - /// throws. - /// - /// In normal operations, the argument to `release` is always false, - /// meaning that the connection is no longer in a transaction. If true, - /// the connection has been left inside a transaction, due to - /// some error. - /// - /// Usage: - /// - /// ```swift - /// let transaction = WALSnapshotTransaction( - /// reader: reader, - /// release: { isInsideTransaction in - /// ... - /// }) - /// ``` - /// - /// - parameter reader: A read-only database connection. - /// - parameter release: A closure to call when the read-only connection - /// is no longer used. - init( - onReader reader: SerializedDatabase, - release: @escaping @Sendable (_ isInsideTransaction: Bool) -> Void) - throws - { - assert(reader.configuration.readonly) - let databaseAccess = DatabaseAccess(reader: reader, release: release) - - do { - // Open a long-lived transaction, and enter snapshot isolation - self.walSnapshot = try reader.sync(allowingLongLivedTransaction: true) { db in - try db.beginTransaction(.deferred) - // This also acquires snapshot isolation because checking - // database schema performs a read access. - try db.clearSchemaCacheIfNeeded() - return try WALSnapshot(db) + + // TODO: consider using the serialized DispatchQueue of reader instead of a lock. + /// nil when closed + private let databaseAccessMutex: Mutex + + /// The state of the database at the beginning of the transaction. + let walSnapshot: WALSnapshot + + /// Creates a long-live WAL transaction on a read-only connection. + /// + /// The `release` closure is always called. It is called when the + /// `WALSnapshotTransaction` is deallocated, or if the initializer + /// throws. + /// + /// In normal operations, the argument to `release` is always false, + /// meaning that the connection is no longer in a transaction. If true, + /// the connection has been left inside a transaction, due to + /// some error. + /// + /// Usage: + /// + /// ```swift + /// let transaction = WALSnapshotTransaction( + /// reader: reader, + /// release: { isInsideTransaction in + /// ... + /// }) + /// ``` + /// + /// - parameter reader: A read-only database connection. + /// - parameter release: A closure to call when the read-only connection + /// is no longer used. + init( + onReader reader: SerializedDatabase, + release: @escaping @Sendable (_ isInsideTransaction: Bool) -> Void + ) + throws + { + assert(reader.configuration.readonly) + let databaseAccess = DatabaseAccess(reader: reader, release: release) + + do { + // Open a long-lived transaction, and enter snapshot isolation + self.walSnapshot = try reader.sync(allowingLongLivedTransaction: true) { db in + try db.beginTransaction(.deferred) + // This also acquires snapshot isolation because checking + // database schema performs a read access. + try db.clearSchemaCacheIfNeeded() + return try WALSnapshot(db) + } + self.databaseAccessMutex = Mutex(databaseAccess) + } catch { + // self is not initialized, so deinit will not run. + databaseAccess.commitAndRelease() + throw error } - self.databaseAccessMutex = Mutex(databaseAccess) - } catch { - // self is not initialized, so deinit will not run. - databaseAccess.commitAndRelease() - throw error } - } - - deinit { - close() - } - - /// Executes database operations in the snapshot transaction, and - /// returns their result after they have finished executing. - func read(_ value: (Database) throws -> T) throws -> T { - try databaseAccessMutex.withLock { databaseAccess in - guard let databaseAccess else { - throw DatabaseError.snapshotIsLost() - } - - // We should check the validity of the snapshot, as DatabaseSnapshotPool does. - return try databaseAccess.reader.sync(value) + + deinit { + close() } - } - - /// Schedules database operations for execution, and - /// returns immediately. - func asyncRead(_ value: @escaping @Sendable (Result) -> Void) { - databaseAccessMutex.withLock { databaseAccess in - guard let databaseAccess else { - value(.failure(DatabaseError.snapshotIsLost())) - return - } - - databaseAccess.reader.async { db in + + /// Executes database operations in the snapshot transaction, and + /// returns their result after they have finished executing. + func read(_ value: (Database) throws -> T) throws -> T { + try databaseAccessMutex.withLock { databaseAccess in + guard let databaseAccess else { + throw DatabaseError.snapshotIsLost() + } + // We should check the validity of the snapshot, as DatabaseSnapshotPool does. - // At least check if self was closed: - if self.databaseAccessMutex.load() == nil { + return try databaseAccess.reader.sync(value) + } + } + + /// Schedules database operations for execution, and + /// returns immediately. + func asyncRead(_ value: @escaping @Sendable (Result) -> Void) { + databaseAccessMutex.withLock { databaseAccess in + guard let databaseAccess else { value(.failure(DatabaseError.snapshotIsLost())) + return + } + + databaseAccess.reader.async { db in + // We should check the validity of the snapshot, as DatabaseSnapshotPool does. + // At least check if self was closed: + if self.databaseAccessMutex.load() == nil { + value(.failure(DatabaseError.snapshotIsLost())) + } + value(.success(db)) } - value(.success(db)) } } - } - - func close() { - databaseAccessMutex.withLock { databaseAccess in - databaseAccess?.commitAndRelease() - databaseAccess = nil + + func close() { + databaseAccessMutex.withLock { databaseAccess in + databaseAccess?.commitAndRelease() + databaseAccess = nil + } } } -} #endif diff --git a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift index 9cc507ddfd..3aca8f31ee 100644 --- a/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift +++ b/GRDB/ValueObservation/Observers/ValueConcurrentObserver.swift @@ -19,19 +19,20 @@ import Foundation /// /// **Notify** is calling user callbacks, in case of database change or error. final class ValueConcurrentObserver: @unchecked Sendable -where Reducer: ValueReducer, - Scheduler: ValueObservationScheduler +where + Reducer: ValueReducer, + Scheduler: ValueObservationScheduler { // MARK: - Configuration // // Configuration is not mutable. - + /// How to schedule observed values and errors. private let scheduler: Scheduler - + /// Configures the tracked database region. private let trackingMode: ValueObservationTrackingMode - + // MARK: - Mutable State // // The observer has four distinct mutable states that evolve independently, @@ -73,27 +74,29 @@ where Reducer: ValueReducer, // - In case of error, `DatabaseAccess` may be lost synchronously, and // `NotificationCallbacks` is lost asynchronously, after the error could // be notified. See error catching clauses. - + /// Ability to access the database private struct DatabaseAccess { /// The observed DatabasePool. let dbPool: DatabasePool - + /// The fetcher that fetches database values. private let fetcher: Reducer.Fetcher - + init(dbPool: DatabasePool, fetcher: Reducer.Fetcher) { self.dbPool = dbPool self.fetcher = fetcher } - + func fetch(_ db: Database) throws -> Reducer.Fetcher.Value { try db.isolated(readOnly: true) { try fetcher.fetch(db) } } - - func fetchRecordingObservedRegion(_ db: Database) throws -> (Reducer.Fetcher.Value, DatabaseRegion) { + + func fetchRecordingObservedRegion(_ db: Database) throws -> ( + Reducer.Fetcher.Value, DatabaseRegion + ) { var region = DatabaseRegion() let fetchedValue = try db.isolated(readOnly: true) { try db.recordingSelection(®ion) { @@ -103,73 +106,73 @@ where Reducer: ValueReducer, return try (fetchedValue, region.observableRegion(db)) } } - + /// The fetching state for observation of constant regions. enum FetchingState { /// No need to fetch. case idle - + /// Waiting for a fetched value. case fetching - + /// Waiting for a fetched value, and for a subsequent fetch after /// that, because a change has been detected as we were fetching. case fetchingAndNeedsFetch } - + /// Ability to notify observation events private struct NotificationCallbacks { let events: ValueObservationEvents let onChange: (Reducer.Value) -> Void } - + /// Relationship with the `TransactionObserver` protocol private struct ObservationState { var region: DatabaseRegion? var isModified = false - + static var notObserving: Self { .init(region: nil, isModified: false) } } - + /// Protects `databaseAccess` and `notificationCallbacks`. /// /// Check out this compiler bug: /// - /// - private let lock = NSLock() - + /// The dispatch queue where database values are reduced into observed /// values before being notified. Protects `reducer`. private let reduceQueue: DispatchQueue - + /// Access to the database, protected by `lock`. private var databaseAccess: DatabaseAccess? - + /// Ability to notify observation events, protected by `lock`. private var notificationCallbacks: NotificationCallbacks? - + /// The fetching state for observation of constant regions. private let fetchingStateMutex = Mutex(FetchingState.idle) - + /// Support for `TransactionObserver`, protected by the serialized writer /// dispatch queue. private var observationState = ObservationState.notObserving - + /// Protected by `reduceQueue`. private var reducer: Reducer - + init( dbPool: DatabasePool, scheduler: Scheduler, trackingMode: ValueObservationTrackingMode, reducer: Reducer, events: ValueObservationEvents, - onChange: @escaping @Sendable (Reducer.Value) -> Void) - { + onChange: @escaping @Sendable (Reducer.Value) -> Void + ) { // Configuration self.scheduler = scheduler self.trackingMode = trackingMode - + // State self.databaseAccess = DatabaseAccess( dbPool: dbPool, @@ -240,12 +243,12 @@ extension ValueConcurrentObserver { // able to cancel observation. fatalError("can't start a cancelled or failed observation") } - + if scheduler.immediateInitialValue() { do { // Start the observation in an synchronous way let initialValue = try syncStart(from: databaseAccess) - + // Notify the initial value from the dispatch queue the // observation was started from notificationCallbacks.onChange(initialValue) @@ -253,22 +256,22 @@ extension ValueConcurrentObserver { // Notify error from the dispatch queue the observation // was started from. notificationCallbacks.events.didFail?(error) - + // Early return! - return AnyDatabaseCancellable { /* nothing to cancel */ } + return AnyDatabaseCancellable { /* nothing to cancel */ } } } else { // Start the observation in an asynchronous way asyncStart(from: databaseAccess) } - + // Make sure the returned cancellable cancels the observation // when deallocated. We can't relying on the deallocation of // self to trigger early cancellation, because self may be retained by // some closure waiting to run in some DispatchQueue. return AnyDatabaseCancellable(self) } - + private func startObservation(_ writerDB: Database, observedRegion: DatabaseRegion) { observationState.region = observedRegion assert(observationState.isModified == false) @@ -276,271 +279,288 @@ extension ValueConcurrentObserver { } } -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) -extension ValueConcurrentObserver { - /// Synchronously starts the observation, and returns the initial value. - /// - /// Unlike `asyncStart()`, this method does not notify the initial value or error. - private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value { - // Start from a read access. The whole point of using a DatabasePool - // for observing the database is to be able to fetch the initial value - // without having to wait for an eventual long-running write - // transaction to complete. - // - // We perform the initial read from a long-lived WAL snapshot - // transaction, because it is a handy way to keep a read transaction - // open until we grab a write access, and compare the database versions. - let initialFetchTransaction: WALSnapshotTransaction - do { - initialFetchTransaction = try databaseAccess.dbPool.walSnapshotTransaction() - } catch DatabaseError.SQLITE_ERROR { - // We can't create a WAL snapshot. The WAL file is probably - // missing, or is truncated. Let's degrade the observation - // by not using any snapshot. - // For more information, see - return try syncStartWithoutWALSnapshot(from: databaseAccess) - } - - let fetchedValue: Reducer.Fetcher.Value - let initialRegion: DatabaseRegion - (fetchedValue, initialRegion) = try initialFetchTransaction.read { db in - switch trackingMode { - case let .constantRegion(regions): - let fetchedValue = try databaseAccess.fetch(db) - let region = try DatabaseRegion.union(regions)(db) - let initialRegion = try region.observableRegion(db) - return (fetchedValue, initialRegion) - - case .constantRegionRecordedFromSelection, +#if (SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER)) && !os(Linux) + extension ValueConcurrentObserver { + /// Synchronously starts the observation, and returns the initial value. + /// + /// Unlike `asyncStart()`, this method does not notify the initial value or error. + private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value { + // Start from a read access. The whole point of using a DatabasePool + // for observing the database is to be able to fetch the initial value + // without having to wait for an eventual long-running write + // transaction to complete. + // + // We perform the initial read from a long-lived WAL snapshot + // transaction, because it is a handy way to keep a read transaction + // open until we grab a write access, and compare the database versions. + let initialFetchTransaction: WALSnapshotTransaction + do { + initialFetchTransaction = try databaseAccess.dbPool.walSnapshotTransaction() + } catch DatabaseError.SQLITE_ERROR { + // We can't create a WAL snapshot. The WAL file is probably + // missing, or is truncated. Let's degrade the observation + // by not using any snapshot. + // For more information, see + return try syncStartWithoutWALSnapshot(from: databaseAccess) + } + + let fetchedValue: Reducer.Fetcher.Value + let initialRegion: DatabaseRegion + (fetchedValue, initialRegion) = try initialFetchTransaction.read { db in + switch trackingMode { + case .constantRegion(let regions): + let fetchedValue = try databaseAccess.fetch(db) + let region = try DatabaseRegion.union(regions)(db) + let initialRegion = try region.observableRegion(db) + return (fetchedValue, initialRegion) + + case .constantRegionRecordedFromSelection, .nonConstantRegionRecordedFromSelection: - let (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db) - return (fetchedValue, initialRegion) + let (fetchedValue, initialRegion) = + try databaseAccess.fetchRecordingObservedRegion(db) + return (fetchedValue, initialRegion) + } } - } - - // Reduce - let initialValue = try reduceQueue.sync { - guard let initialValue = try reducer._value(fetchedValue) else { - fatalError("Broken contract: reducer has no initial value") + + // Reduce + let initialValue = try reduceQueue.sync { + guard let initialValue = try reducer._value(fetchedValue) else { + fatalError("Broken contract: reducer has no initial value") + } + return initialValue } + + // Start observation + asyncStartObservation( + from: databaseAccess, + initialFetchTransaction: initialFetchTransaction, + initialRegion: initialRegion) + return initialValue } - - // Start observation - asyncStartObservation( - from: databaseAccess, - initialFetchTransaction: initialFetchTransaction, - initialRegion: initialRegion) - - return initialValue - } - - /// Asynchronously starts the observation - /// - /// Unlike `syncStart()`, this method does notify the initial value or error. - private func asyncStart(from databaseAccess: DatabaseAccess) { - // Start from a read access. The whole point of using a DatabasePool - // for observing the database is to be able to fetch the initial value - // without having to wait for an eventual long-running write - // transaction to complete. - // - // We perform the initial read from a long-lived WAL snapshot - // transaction, because it is a handy way to keep a read transaction - // open until we grab a write access, and compare the database versions. - databaseAccess.dbPool.asyncWALSnapshotTransaction { result in - let (isNotifying, databaseAccess) = self.lock.synchronized { - (self.notificationCallbacks != nil, self.databaseAccess) - } - guard isNotifying, let databaseAccess else { return /* Cancelled */ } - - do { - let initialFetchTransaction = try result.get() - // Second async jump because that's how - // `DatabasePool.asyncWALSnapshotTransaction` has to be used. - initialFetchTransaction.asyncRead { dbResult in - do { - // Assume this value can safely be sent to the reduce queue. - nonisolated(unsafe) let fetchedValue: Reducer.Fetcher.Value - let initialRegion: DatabaseRegion - let db = try dbResult.get() - - switch self.trackingMode { - case let .constantRegion(regions): - fetchedValue = try databaseAccess.fetch(db) - let region = try DatabaseRegion.union(regions)(db) - initialRegion = try region.observableRegion(db) - - case .constantRegionRecordedFromSelection, + + /// Asynchronously starts the observation + /// + /// Unlike `syncStart()`, this method does notify the initial value or error. + private func asyncStart(from databaseAccess: DatabaseAccess) { + // Start from a read access. The whole point of using a DatabasePool + // for observing the database is to be able to fetch the initial value + // without having to wait for an eventual long-running write + // transaction to complete. + // + // We perform the initial read from a long-lived WAL snapshot + // transaction, because it is a handy way to keep a read transaction + // open until we grab a write access, and compare the database versions. + databaseAccess.dbPool.asyncWALSnapshotTransaction { result in + let (isNotifying, databaseAccess) = self.lock.synchronized { + (self.notificationCallbacks != nil, self.databaseAccess) + } + guard isNotifying, let databaseAccess else { return /* Cancelled */ } + + do { + let initialFetchTransaction = try result.get() + // Second async jump because that's how + // `DatabasePool.asyncWALSnapshotTransaction` has to be used. + initialFetchTransaction.asyncRead { dbResult in + do { + // Assume this value can safely be sent to the reduce queue. + nonisolated(unsafe) let fetchedValue: Reducer.Fetcher.Value + let initialRegion: DatabaseRegion + let db = try dbResult.get() + + switch self.trackingMode { + case .constantRegion(let regions): + fetchedValue = try databaseAccess.fetch(db) + let region = try DatabaseRegion.union(regions)(db) + initialRegion = try region.observableRegion(db) + + case .constantRegionRecordedFromSelection, .nonConstantRegionRecordedFromSelection: - (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db) - } - - // Reduce - // - // Reducing is performed asynchronously, so that we do not lock - // a database dispatch queue longer than necessary. - self.reduceQueue.async { - let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } - guard isNotifying else { return /* Cancelled */ } - - do { - guard let initialValue = try self.reducer._value(fetchedValue) else { - fatalError("Broken contract: reducer has no initial value") + (fetchedValue, initialRegion) = + try databaseAccess.fetchRecordingObservedRegion(db) + } + + // Reduce + // + // Reducing is performed asynchronously, so that we do not lock + // a database dispatch queue longer than necessary. + self.reduceQueue.async { + let isNotifying = self.lock.synchronized { + self.notificationCallbacks != nil } - - // Notify - self.scheduler.schedule { - let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange } - guard let onChange else { return /* Cancelled */ } - onChange(initialValue) + guard isNotifying else { return /* Cancelled */ } + + do { + guard let initialValue = try self.reducer._value(fetchedValue) + else { + fatalError("Broken contract: reducer has no initial value") + } + + // Notify + self.scheduler.schedule { + let onChange = self.lock.synchronized { + self.notificationCallbacks?.onChange + } + guard let onChange else { return /* Cancelled */ } + onChange(initialValue) + } + } catch { + self.notifyError(error) } - } catch { - self.notifyError(error) } + + // Start observation + self.asyncStartObservation( + from: databaseAccess, + initialFetchTransaction: initialFetchTransaction, + initialRegion: initialRegion) + } catch { + self.notifyError(error) } - - // Start observation - self.asyncStartObservation( - from: databaseAccess, - initialFetchTransaction: initialFetchTransaction, - initialRegion: initialRegion) - } catch { - self.notifyError(error) } + } catch DatabaseError.SQLITE_ERROR { + // We can't create a WAL snapshot. The WAL file is probably + // missing, or is truncated. Let's degrade the observation + // by not using any snapshot. + // For more information, see + self.asyncStartWithoutWALSnapshot(from: databaseAccess) + } catch { + self.notifyError(error) } - } catch DatabaseError.SQLITE_ERROR { - // We can't create a WAL snapshot. The WAL file is probably - // missing, or is truncated. Let's degrade the observation - // by not using any snapshot. - // For more information, see - self.asyncStartWithoutWALSnapshot(from: databaseAccess) - } catch { - self.notifyError(error) } } - } - - private func asyncStartObservation( - from databaseAccess: DatabaseAccess, - initialFetchTransaction: WALSnapshotTransaction, - initialRegion: DatabaseRegion) - { - // We'll start the observation when we can access the writer - // connection. Until then, maybe the database has been modified - // since the initial fetch: we'll then need to notify a fresh value. - // - // To know if the database has been modified between the initial - // fetch and the writer access, we'll compare WAL snapshots. - // - // WAL snapshots can only be compared if the database is not - // checkpointed. That's why we'll keep `initialFetchTransaction` - // alive until the comparison is done. - // - // However, we want to close `initialFetchTransaction` as soon as - // possible, so that the reader connection it holds becomes - // available for other reads. - - databaseAccess.dbPool.asyncWriteWithoutTransaction { writerDB in - let events = self.lock.synchronized { self.notificationCallbacks?.events } - guard let events else { return /* Cancelled */ } - - do { - var observedRegion = initialRegion - - try writerDB.isolated(readOnly: true) { - // Was the database modified since the initial fetch? - let isModified: Bool - if let currentWALSnapshot = try? WALSnapshot(writerDB) { - let ordering = initialFetchTransaction.walSnapshot.compare(currentWALSnapshot) - assert(ordering <= 0, "Unexpected snapshot ordering") - isModified = ordering < 0 - } else { - // Can't compare: assume the database was modified. - isModified = true - } - - // Comparison done: close the WAL snapshot transaction - // and release its reader connection. - initialFetchTransaction.close() - - if isModified { - events.databaseDidChange?() - - // Fetch - // Assume this value can safely be sent to the reduce queue. - nonisolated(unsafe) let fetchedValue: Reducer.Fetcher.Value - - switch self.trackingMode { - case .constantRegion: - fetchedValue = try databaseAccess.fetch(writerDB) - events.willTrackRegion?(initialRegion) - self.startObservation(writerDB, observedRegion: initialRegion) - - case .constantRegionRecordedFromSelection, - .nonConstantRegionRecordedFromSelection: - (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB) - events.willTrackRegion?(observedRegion) - self.startObservation(writerDB, observedRegion: observedRegion) + + private func asyncStartObservation( + from databaseAccess: DatabaseAccess, + initialFetchTransaction: WALSnapshotTransaction, + initialRegion: DatabaseRegion + ) { + // We'll start the observation when we can access the writer + // connection. Until then, maybe the database has been modified + // since the initial fetch: we'll then need to notify a fresh value. + // + // To know if the database has been modified between the initial + // fetch and the writer access, we'll compare WAL snapshots. + // + // WAL snapshots can only be compared if the database is not + // checkpointed. That's why we'll keep `initialFetchTransaction` + // alive until the comparison is done. + // + // However, we want to close `initialFetchTransaction` as soon as + // possible, so that the reader connection it holds becomes + // available for other reads. + + databaseAccess.dbPool.asyncWriteWithoutTransaction { writerDB in + let events = self.lock.synchronized { self.notificationCallbacks?.events } + guard let events else { return /* Cancelled */ } + + do { + var observedRegion = initialRegion + + try writerDB.isolated(readOnly: true) { + // Was the database modified since the initial fetch? + let isModified: Bool + if let currentWALSnapshot = try? WALSnapshot(writerDB) { + let ordering = initialFetchTransaction.walSnapshot.compare( + currentWALSnapshot) + assert(ordering <= 0, "Unexpected snapshot ordering") + isModified = ordering < 0 + } else { + // Can't compare: assume the database was modified. + isModified = true } - - // Reduce - // - // Reducing is performed asynchronously, so that we do not lock - // the writer dispatch queue longer than necessary. - // - // Important: reduceQueue.async guarantees the same ordering - // between transactions and notifications! - self.reduceQueue.async { - let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } - guard isNotifying else { return /* Cancelled */ } - - do { - let value = try self.reducer._value(fetchedValue) - - // Notify - if let value { - self.scheduler.schedule { - let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange } - guard let onChange else { return /* Cancelled */ } - onChange(value) - } + + // Comparison done: close the WAL snapshot transaction + // and release its reader connection. + initialFetchTransaction.close() + + if isModified { + events.databaseDidChange?() + + // Fetch + // Assume this value can safely be sent to the reduce queue. + nonisolated(unsafe) let fetchedValue: Reducer.Fetcher.Value + + switch self.trackingMode { + case .constantRegion: + fetchedValue = try databaseAccess.fetch(writerDB) + events.willTrackRegion?(initialRegion) + self.startObservation(writerDB, observedRegion: initialRegion) + + case .constantRegionRecordedFromSelection, + .nonConstantRegionRecordedFromSelection: + (fetchedValue, observedRegion) = + try databaseAccess.fetchRecordingObservedRegion(writerDB) + events.willTrackRegion?(observedRegion) + self.startObservation(writerDB, observedRegion: observedRegion) + } + + // Reduce + // + // Reducing is performed asynchronously, so that we do not lock + // the writer dispatch queue longer than necessary. + // + // Important: reduceQueue.async guarantees the same ordering + // between transactions and notifications! + self.reduceQueue.async { + let isNotifying = self.lock.synchronized { + self.notificationCallbacks != nil } - } catch { - let dbPool = self.lock.synchronized { self.databaseAccess?.dbPool } - dbPool?.asyncWriteWithoutTransaction { writerDB in - self.stopDatabaseObservation(writerDB) + guard isNotifying else { return /* Cancelled */ } + + do { + let value = try self.reducer._value(fetchedValue) + + // Notify + if let value { + self.scheduler.schedule { + let onChange = self.lock.synchronized { + self.notificationCallbacks?.onChange + } + guard let onChange else { return /* Cancelled */ } + onChange(value) + } + } + } catch { + let dbPool = self.lock.synchronized { + self.databaseAccess?.dbPool + } + dbPool?.asyncWriteWithoutTransaction { writerDB in + self.stopDatabaseObservation(writerDB) + } + self.notifyError(error) } - self.notifyError(error) } + } else { + events.willTrackRegion?(initialRegion) + self.startObservation(writerDB, observedRegion: initialRegion) } - } else { - events.willTrackRegion?(initialRegion) - self.startObservation(writerDB, observedRegion: initialRegion) } + } catch { + self.notifyError(error) } - } catch { - self.notifyError(error) } } } -} #else -extension ValueConcurrentObserver { - private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value { - try syncStartWithoutWALSnapshot(from: databaseAccess) - } - - private func asyncStart(from databaseAccess: DatabaseAccess) { - asyncStartWithoutWALSnapshot(from: databaseAccess) + extension ValueConcurrentObserver { + private func syncStart(from databaseAccess: DatabaseAccess) throws -> Reducer.Value { + try syncStartWithoutWALSnapshot(from: databaseAccess) + } + + private func asyncStart(from databaseAccess: DatabaseAccess) { + asyncStartWithoutWALSnapshot(from: databaseAccess) + } } -} #endif extension ValueConcurrentObserver { /// Synchronously starts the observation, and returns the initial value. /// /// Unlike `asyncStartWithoutWALSnapshot()`, this method does not notify the initial value or error. - private func syncStartWithoutWALSnapshot(from databaseAccess: DatabaseAccess) throws -> Reducer.Value { + private func syncStartWithoutWALSnapshot(from databaseAccess: DatabaseAccess) throws + -> Reducer.Value + { // Start from a read access. The whole point of using a DatabasePool // for observing the database is to be able to fetch the initial value // without having to wait for an eventual long-running write @@ -549,19 +569,20 @@ extension ValueConcurrentObserver { let initialRegion: DatabaseRegion (fetchedValue, initialRegion) = try databaseAccess.dbPool.read { db in switch trackingMode { - case let .constantRegion(regions): + case .constantRegion(let regions): let fetchedValue = try databaseAccess.fetch(db) let region = try DatabaseRegion.union(regions)(db) let initialRegion = try region.observableRegion(db) return (fetchedValue, initialRegion) - + case .constantRegionRecordedFromSelection, - .nonConstantRegionRecordedFromSelection: - let (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db) + .nonConstantRegionRecordedFromSelection: + let (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion( + db) return (fetchedValue, initialRegion) } } - + // Reduce let initialValue = try reduceQueue.sync { guard let initialValue = try reducer._value(fetchedValue) else { @@ -569,15 +590,15 @@ extension ValueConcurrentObserver { } return initialValue } - + // Start observation asyncStartObservationWithoutWALSnapshot( from: databaseAccess, initialRegion: initialRegion) - + return initialValue } - + /// Asynchronously starts the observation /// /// Unlike `syncStartWithoutWALSnapshot()`, this method does notify the initial value or error. @@ -589,7 +610,7 @@ extension ValueConcurrentObserver { databaseAccess.dbPool.asyncRead { dbResult in let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } guard isNotifying else { return /* Cancelled */ } - + do { // Fetch // Assume this value can safely be sent to the reduce queue. @@ -597,16 +618,17 @@ extension ValueConcurrentObserver { let initialRegion: DatabaseRegion let db = try dbResult.get() switch self.trackingMode { - case let .constantRegion(regions): + case .constantRegion(let regions): fetchedValue = try databaseAccess.fetch(db) let region = try DatabaseRegion.union(regions)(db) initialRegion = try region.observableRegion(db) - + case .constantRegionRecordedFromSelection, - .nonConstantRegionRecordedFromSelection: - (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion(db) + .nonConstantRegionRecordedFromSelection: + (fetchedValue, initialRegion) = try databaseAccess.fetchRecordingObservedRegion( + db) } - + // Reduce // // Reducing is performed asynchronously, so that we do not lock @@ -614,15 +636,17 @@ extension ValueConcurrentObserver { self.reduceQueue.async { let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } guard isNotifying else { return /* Cancelled */ } - + do { guard let initialValue = try self.reducer._value(fetchedValue) else { fatalError("Broken contract: reducer has no initial value") } - + // Notify self.scheduler.schedule { - let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange } + let onChange = self.lock.synchronized { + self.notificationCallbacks?.onChange + } guard let onChange else { return /* Cancelled */ } onChange(initialValue) } @@ -630,7 +654,7 @@ extension ValueConcurrentObserver { self.notifyError(error) } } - + // Start observation self.asyncStartObservationWithoutWALSnapshot( from: databaseAccess, @@ -640,16 +664,16 @@ extension ValueConcurrentObserver { } } } - + private func asyncStartObservationWithoutWALSnapshot( from databaseAccess: DatabaseAccess, - initialRegion: DatabaseRegion) - { + initialRegion: DatabaseRegion + ) { databaseAccess.dbPool.asyncWriteWithoutTransaction { writerDB in let events = self.lock.synchronized { self.notificationCallbacks?.events } guard let events else { return /* Cancelled */ } events.databaseDidChange?() - + do { try writerDB.isolated(readOnly: true) { // Fetch @@ -662,14 +686,15 @@ extension ValueConcurrentObserver { observedRegion = initialRegion events.willTrackRegion?(initialRegion) self.startObservation(writerDB, observedRegion: initialRegion) - + case .constantRegionRecordedFromSelection, - .nonConstantRegionRecordedFromSelection: - (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB) + .nonConstantRegionRecordedFromSelection: + (fetchedValue, observedRegion) = + try databaseAccess.fetchRecordingObservedRegion(writerDB) events.willTrackRegion?(observedRegion) self.startObservation(writerDB, observedRegion: observedRegion) } - + // Reduce // // Reducing is performed asynchronously, so that we do not lock @@ -678,16 +703,20 @@ extension ValueConcurrentObserver { // Important: reduceQueue.async guarantees the same ordering // between transactions and notifications! self.reduceQueue.async { - let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } + let isNotifying = self.lock.synchronized { + self.notificationCallbacks != nil + } guard isNotifying else { return /* Cancelled */ } - + do { let value = try self.reducer._value(fetchedValue) - + // Notify if let value { self.scheduler.schedule { - let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange } + let onChange = self.lock.synchronized { + self.notificationCallbacks?.onChange + } guard let onChange else { return /* Cancelled */ } onChange(value) } @@ -718,14 +747,14 @@ extension ValueConcurrentObserver: TransactionObserver { return false } } - + func databaseDidChange() { // Database was modified! observationState.isModified = true // We can stop observing the current transaction stopObservingDatabaseChangesUntilNextTransaction() } - + func databaseDidChange(with event: DatabaseEvent) { if let region = observationState.region, region.isModified(by: event) { // Database was modified! @@ -734,28 +763,28 @@ extension ValueConcurrentObserver: TransactionObserver { stopObservingDatabaseChangesUntilNextTransaction() } } - + func databaseDidCommit(_ writerDB: Database) { // Ignore transaction unless database was modified guard observationState.isModified else { return } - + // Reset the isModified flag until next transaction observationState.isModified = false - + // Ignore transaction unless we are still notifying database events, and // we can still access the database. let (events, databaseAccess) = lock.synchronized { (notificationCallbacks?.events, self.databaseAccess) } guard let events, let databaseAccess else { return /* Cancelled */ } - + events.databaseDidChange?() - + // Fetch switch trackingMode { case .constantRegion, .constantRegionRecordedFromSelection: setNeedsFetching(databaseAccess: databaseAccess) - + case .nonConstantRegionRecordedFromSelection: // When the tracked region is not constant, we can't perform // concurrent fetches of observed values. @@ -772,13 +801,16 @@ extension ValueConcurrentObserver: TransactionObserver { // Conclusion: fetch from the writer connection, and update the // tracked region. do { - let (fetchedValue, observedRegion) = try databaseAccess.fetchRecordingObservedRegion(writerDB) - + let (fetchedValue, observedRegion) = + try databaseAccess.fetchRecordingObservedRegion(writerDB) + // Don't spam the user with region tracking events: wait for an actual change - if let willTrackRegion = events.willTrackRegion, observedRegion != observationState.region { + if let willTrackRegion = events.willTrackRegion, + observedRegion != observationState.region + { willTrackRegion(observedRegion) } - + observationState.region = observedRegion reduce(.success(fetchedValue)) } catch { @@ -787,43 +819,43 @@ extension ValueConcurrentObserver: TransactionObserver { } } } - + private func setNeedsFetching(databaseAccess: DatabaseAccess) { fetchingStateMutex.withLock { state in switch state { case .idle: state = .fetching asyncFetch(databaseAccess: databaseAccess) - + case .fetching: state = .fetchingAndNeedsFetch - + case .fetchingAndNeedsFetch: break } } } - + private func asyncFetch(databaseAccess: DatabaseAccess) { databaseAccess.dbPool.asyncRead { [self] dbResult in let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } guard isNotifying else { return /* Cancelled */ } - + let fetchResult = dbResult.flatMap { db in Result { try databaseAccess.fetch(db) } } - + self.reduce(fetchResult) - + fetchingStateMutex.withLock { state in switch state { case .idle: // GRDB bug preconditionFailure() - + case .fetching: state = .idle - + case .fetchingAndNeedsFetch: state = .fetching asyncFetch(databaseAccess: databaseAccess) @@ -831,24 +863,26 @@ extension ValueConcurrentObserver: TransactionObserver { } } } - + private func reduce(_ fetchResult: Result) { // Assume this value can safely be sent to the reduce queue. nonisolated(unsafe) let fetchResult = fetchResult - + reduceQueue.async { do { let fetchedValue = try fetchResult.get() - + let isNotifying = self.lock.synchronized { self.notificationCallbacks != nil } guard isNotifying else { return /* Cancelled */ } - + let value = try self.reducer._value(fetchedValue) - + // Notify value if let value { self.scheduler.schedule { - let onChange = self.lock.synchronized { self.notificationCallbacks?.onChange } + let onChange = self.lock.synchronized { + self.notificationCallbacks?.onChange + } guard let onChange else { return /* Cancelled */ } onChange(value) } @@ -862,7 +896,7 @@ extension ValueConcurrentObserver: TransactionObserver { } } } - + func databaseDidRollback(_ db: Database) { // Reset the isModified flag until next transaction observationState.isModified = false @@ -881,10 +915,10 @@ extension ValueConcurrentObserver: DatabaseCancellable { notificationCallbacks = nil return (events, databaseAccess?.dbPool) } - + guard let events else { return /* Cancelled or failed */ } events.didCancel?() - + // Stop observing the database // Do it asynchronously, so that we do not block the current thread: // cancellation may be triggered while a long write access is executing. @@ -893,7 +927,7 @@ extension ValueConcurrentObserver: DatabaseCancellable { self.stopDatabaseObservation(db) } } - + func notifyError(_ error: Error) { scheduler.schedule { let events = self.lock.synchronized { @@ -905,7 +939,7 @@ extension ValueConcurrentObserver: DatabaseCancellable { events.didFail?(error) } } - + private func stopDatabaseObservation(_ writerDB: Database) { writerDB.remove(transactionObserver: self) observationState = .notObserving diff --git a/Tests/GRDBCombineTests/DatabaseReaderReadPublisherTests.swift b/Tests/GRDBCombineTests/DatabaseReaderReadPublisherTests.swift index 9d7f4a813f..525d92ea03 100644 --- a/Tests/GRDBCombineTests/DatabaseReaderReadPublisherTests.swift +++ b/Tests/GRDBCombineTests/DatabaseReaderReadPublisherTests.swift @@ -1,282 +1,308 @@ #if canImport(Combine) -import Combine -import GRDB -import XCTest + 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) - } - } -} + private struct Player: Codable, FetchableRecord, PersistableRecord { + var id: Int64 + var name: String + var score: Int? -class DatabaseReaderReadPublisherTests : XCTestCase { - - // MARK: - - - func testReadPublisher() throws { - func setUp(_ 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) - 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::initializeWithCopy(swift::OpaqueValue*, swift::OpaqueValue*, swift::TargetMetadata const*) + 17 - // frame #2: 0x000000010d926309 GRDB`outlined init with copy of Subscribers.Completion at :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(publisherExpectation=GRDBOSXTests.PublisherExpectations.Recording, 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 :0 - // frame #17: 0x000000010915c334 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error) at :0 - // frame #18: 0x000000010915e014 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error)partial apply at :0 - // frame #19: 0x00000001095a85f8 GRDBOSXTests`closure #1 in Test.init(context=0x000000010672f210, _0=, 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 ) at Support.swift:13:41 - // frame #20: 0x00000001095a86af GRDBOSXTests`partial apply for closure #1 in Test.init(repeatCount:_:) at :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 , 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 at )) 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 :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 { - 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") + 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) } } - - 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) - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshotPool() } -#endif } - - // MARK: - - - func testReadPublisherIsAsynchronous() throws { - func setUp(_ 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 + + class DatabaseReaderReadPublisherTests: XCTestCase { + + // MARK: - + + func testReadPublisher() throws { + func setUp(_ 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) }) - .sink( - receiveCompletion: { _ in }, - receiveValue: { _ in - semaphore.wait() - expectation.fulfill() + 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) && !os(Linux) + 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::initializeWithCopy(swift::OpaqueValue*, swift::OpaqueValue*, swift::TargetMetadata const*) + 17 + // frame #2: 0x000000010d926309 GRDB`outlined init with copy of Subscribers.Completion at :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(publisherExpectation=GRDBOSXTests.PublisherExpectations.Recording, 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 :0 + // frame #17: 0x000000010915c334 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error) at :0 + // frame #18: 0x000000010915e014 GRDBOSXTests`thunk for @escaping @callee_guaranteed (@guaranteed DatabaseReader) -> (@error @owned Error)partial apply at :0 + // frame #19: 0x00000001095a85f8 GRDBOSXTests`closure #1 in Test.init(context=0x000000010672f210, _0=, 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 ) at Support.swift:13:41 + // frame #20: 0x00000001095a86af GRDBOSXTests`partial apply for closure #1 in Test.init(repeatCount:_:) at :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 , 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 at )) 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 :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 { + func test(reader: some DatabaseReader) throws { + let publisher = reader.readPublisher(value: { db in + try Row.fetchAll(db, sql: "THIS IS NOT SQL") }) - - semaphore.signal() - waitForExpectations(timeout: 5, handler: nil) - cancellable.cancel() + 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) && !os(Linux) + try Test(test).runAtTemporaryDatabasePath { + try DatabasePool(path: $0).makeSnapshotPool() + } + #endif } - - 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) - try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() } -#endif - } - - // MARK: - - - func testReadPublisherDefaultScheduler() throws { - func setUp(_ writer: Writer) throws -> Writer { - try writer.write(Player.createTable) - return writer + + // MARK: - + + func testReadPublisherIsAsynchronous() throws { + func setUp(_ 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) && !os(Linux) + try Test(test).runAtTemporaryDatabasePath { + try setUp(DatabasePool(path: $0)).makeSnapshotPool() + } + #endif } - - 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() + + // MARK: - + + func testReadPublisherDefaultScheduler() throws { + func setUp(_ 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) && !os(Linux) + try Test(test).runAtTemporaryDatabasePath { + try setUp(DatabasePool(path: $0)).makeSnapshotPool() + } + #endif } - - 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) - try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() } -#endif - } - - // MARK: - - - func testReadPublisherCustomScheduler() throws { - func setUp(_ writer: Writer) throws -> Writer { - try writer.write(Player.createTable) - return writer + + // MARK: - + + func testReadPublisherCustomScheduler() throws { + func setUp(_ 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) && !os(Linux) + try Test(test).runAtTemporaryDatabasePath { + try setUp(DatabasePool(path: $0)).makeSnapshotPool() + } + #endif } - - 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)) + + // MARK: - + + func testReadPublisherIsReadonly() throws { + func test(reader: some DatabaseReader) throws { + let publisher = reader.readPublisher(value: { db in + try Player.createTable(db) }) - - 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) - try Test(test).runAtTemporaryDatabasePath { try setUp(DatabasePool(path: $0)).makeSnapshotPool() } -#endif - } - - // MARK: - - - func testReadPublisherIsReadonly() throws { - 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) + 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) && !os(Linux) + try Test(test).runAtTemporaryDatabasePath { + try DatabasePool(path: $0).makeSnapshotPool() + } + #endif } - - 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) - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0).makeSnapshotPool() } -#endif } -} #endif diff --git a/Tests/GRDBCombineTests/Support.swift b/Tests/GRDBCombineTests/Support.swift index 6d35dd7718..22bd0de663 100644 --- a/Tests/GRDBCombineTests/Support.swift +++ b/Tests/GRDBCombineTests/Support.swift @@ -1,125 +1,135 @@ #if canImport(Combine) -import Combine -import Foundation -import XCTest - -final class Test { - // 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) + import Combine + import Foundation + import XCTest + + final class Test { + // Raise the repeatCount in order to help spotting flaky tests. + private let repeatCount: Int + private let test: (Context, Int) throws -> Void + + init(repeatCount: Int = 1, _ test: @escaping (Context) throws -> Void) { + self.repeatCount = repeatCount + self.test = { context, _ in try test(context) } } - 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) + + init(repeatCount: Int, _ test: @escaping (Context, Int) throws -> Void) { + self.repeatCount = repeatCount + self.test = test + } + + @discardableResult + func run(context: () throws -> Context) throws -> Self { + for i in 1...repeatCount { + try test(context(), i) } - - try test(context(directoryURL), i) + return self } - return self - } - - @discardableResult - func runAtTemporaryDatabasePath(context: (_ path: String) throws -> Context) throws -> Self { - try runInTemporaryDirectory { url in - try context(url.appendingPathComponent("db.sqlite").path) + + @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 } - } -} - -final class AsyncTest { - // 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) + + @discardableResult + func runAtTemporaryDatabasePath(context: (_ path: String) throws -> Context) throws -> Self + { + try runInTemporaryDirectory { url in + try context(url.appendingPathComponent("db.sqlite").path) + } } - 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) + + final class AsyncTest { + // Raise the repeatCount in order to help spotting flaky tests. + private let repeatCount: Int + private let test: (Context, Int) async throws -> Void + + init(repeatCount: Int = 1, _ test: @escaping (Context) async throws -> Void) { + self.repeatCount = repeatCount + self.test = { context, _ in try await test(context) } + } + + init(repeatCount: Int, _ test: @escaping (Context, Int) async throws -> Void) { + 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) } - - try await test(context(directoryURL), i) + return self } - 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) + + @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) + } } } -} - -public func assertNoFailure( - _ completion: Subscribers.Completion, - file: StaticString = #file, - line: UInt = #line) -{ - if case let .failure(error) = completion { - XCTFail("Unexpected completion failure: \(error)", file: file, line: line) + + public func assertNoFailure( + _ completion: Subscribers.Completion, + file: StaticString = #file, + line: UInt = #line + ) { + if case .failure(let error) = completion { + XCTFail("Unexpected completion failure: \(error)", file: file, line: line) + } } -} - -public func assertFailure( - _ completion: Subscribers.Completion, - 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) + + public func assertFailure( + _ completion: Subscribers.Completion, + file: StaticString = #file, + line: UInt = #line, + test: (ExpectedFailure) -> Void + ) { + if case .failure(let error) = completion, let expectedError = error as? ExpectedFailure { + test(expectedError) + } else { + XCTFail("Expected \(ExpectedFailure.self), got \(completion)", file: file, line: line) + } } -} #endif - diff --git a/Tests/GRDBTests/CGFloatTests.swift b/Tests/GRDBTests/CGFloatTests.swift index 1bcfaaa582..467b2ee220 100644 --- a/Tests/GRDBTests/CGFloatTests.swift +++ b/Tests/GRDBTests/CGFloatTests.swift @@ -1,18 +1,23 @@ -import XCTest -import CoreGraphics import GRDB +import XCTest + +#if canImport(CoreGraphics) + import CoreGraphics +#elseif !canImport(Darwin) + import Foundation +#endif class CGFloatTests: GRDBTestCase { - + func testCGFLoat() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.execute(sql: "CREATE TABLE points (x DOUBLE, y DOUBLE)") - + let x: CGFloat = 1 let y: CGFloat? = nil try db.execute(sql: "INSERT INTO points VALUES (?,?)", arguments: [x, y]) - + let row = try Row.fetchOne(db, sql: "SELECT * FROM points")! let fetchedX: CGFloat = row["x"] let fetchedY: CGFloat? = row["y"] diff --git a/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift b/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift index 1ac85a08fe..282471727b 100644 --- a/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift +++ b/Tests/GRDBTests/DatabaseAbortedTransactionTests.swift @@ -1,21 +1,22 @@ -import XCTest import GRDB +import XCTest + +class DatabaseAbortedTransactionTests: GRDBTestCase { -class DatabaseAbortedTransactionTests : GRDBTestCase { - func testReadTransactionAbortedByInterrupt() throws { func test(_ dbReader: some DatabaseReader) throws { let semaphore1 = DispatchSemaphore(value: 0) let semaphore2 = DispatchSemaphore(value: 0) - + let block1 = { do { try dbReader.read { db in - db.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in - semaphore1.signal() - semaphore2.wait() - return nil - }) + db.add( + function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) _ = try Row.fetchAll(db, sql: "SELECT wait()") } XCTFail("Expected error") @@ -35,28 +36,29 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { blocks[index]() } } - + try test(DatabaseQueue()) try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testReadTransactionAbortedByInterruptDoesNotPreventFurtherRead() throws { func test(_ dbReader: some DatabaseReader) throws { let semaphore1 = DispatchSemaphore(value: 0) let semaphore2 = DispatchSemaphore(value: 0) - + let block1 = { try! dbReader.read { db in - db.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in - semaphore1.signal() - semaphore2.wait() - return nil - }) + db.add( + function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) let wasInTransaction = db.isInsideTransaction do { _ = try Row.fetchAll(db, sql: "SELECT wait()") @@ -80,16 +82,16 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { blocks[index]() } } - + try test(DatabaseQueue()) try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testWriteTransactionAbortedByInterrupt() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -100,15 +102,16 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { func test(_ dbWriter: some DatabaseWriter) throws { let semaphore1 = DispatchSemaphore(value: 0) let semaphore2 = DispatchSemaphore(value: 0) - + let block1 = { do { try dbWriter.write { db in - db.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in - semaphore1.signal() - semaphore2.wait() - return nil - }) + db.add( + function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) try db.execute(sql: "INSERT INTO t SELECT wait()") } XCTFail("Expected error") @@ -131,12 +134,12 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { blocks[index]() } } - + try test(setup(DatabaseQueue())) try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) } - + func testWriteTransactionAbortedByInterruptPreventsFurtherDatabaseAccess() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -147,16 +150,18 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { func test(_ dbWriter: some DatabaseWriter) throws { let semaphore1 = DispatchSemaphore(value: 0) let semaphore2 = DispatchSemaphore(value: 0) - + let block1 = { do { try dbWriter.write { db in do { - db.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in - semaphore1.signal() - semaphore2.wait() - return nil - }) + db.add( + function: DatabaseFunction("wait", argumentCount: 0, pure: true) { + _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) try db.execute(sql: "INSERT INTO t SELECT wait()") XCTFail("Expected error") } catch let error as DatabaseError { @@ -164,9 +169,9 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { } catch { XCTFail("Unexpected error: \(error)") } - + XCTAssertFalse(db.isInsideTransaction) - + do { _ = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t") XCTFail("Expected error") @@ -177,7 +182,7 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { } catch { XCTFail("Unexpected error: \(error)") } - + do { try db.execute(sql: "INSERT INTO t (a) VALUES (0)") XCTFail("Expected error") @@ -209,12 +214,12 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { blocks[index]() } } - + try test(setup(DatabaseQueue())) try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) } - + func testWriteTransactionAbortedByInterruptDoesNotPreventRollback() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -225,14 +230,15 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { func test(_ dbWriter: some DatabaseWriter) throws { let semaphore1 = DispatchSemaphore(value: 0) let semaphore2 = DispatchSemaphore(value: 0) - + let block1 = { try! dbWriter.writeWithoutTransaction { db in - db.add(function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in - semaphore1.signal() - semaphore2.wait() - return nil - }) + db.add( + function: DatabaseFunction("wait", argumentCount: 0, pure: true) { _ in + semaphore1.signal() + semaphore2.wait() + return nil + }) try db.inTransaction { do { try db.execute(sql: "INSERT INTO t SELECT wait()") @@ -242,7 +248,7 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { } catch { XCTFail("Unexpected error: \(error)") } - + XCTAssertFalse(db.isInsideTransaction) return .rollback } @@ -258,18 +264,19 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { blocks[index]() } } - + try test(setup(DatabaseQueue())) try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) } - + func testTransactionAbortedByConflictPreventsFurtherDatabaseAccess() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in - try db.execute(sql: """ - CREATE TABLE t(a UNIQUE ON CONFLICT ROLLBACK); - """) + try db.execute( + sql: """ + CREATE TABLE t(a UNIQUE ON CONFLICT ROLLBACK); + """) } return dbWriter } @@ -277,10 +284,11 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { do { try dbWriter.write { db in do { - try db.execute(sql: """ - INSERT INTO t (a) VALUES (1); - INSERT INTO t (a) VALUES (1); - """) + try db.execute( + sql: """ + INSERT INTO t (a) VALUES (1); + INSERT INTO t (a) VALUES (1); + """) XCTFail("Expected error") } catch let error as DatabaseError { XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) @@ -289,9 +297,9 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { } catch { XCTFail("Unexpected error: \(error)") } - + XCTAssertFalse(db.isInsideTransaction) - + try db.execute(sql: "INSERT INTO t (a) VALUES (2)") } XCTFail("Expected error") @@ -303,12 +311,12 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { XCTFail("Unexpected error: \(error)") } } - + try test(setup(DatabaseQueue())) try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) } - + func testTransactionAbortedByUser() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -320,11 +328,12 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { do { try dbReader.unsafeRead { db in try db.inTransaction { - try db.execute(sql: """ - SELECT * FROM t; - ROLLBACK; - SELECT * FROM t; - """) + try db.execute( + sql: """ + SELECT * FROM t; + ROLLBACK; + SELECT * FROM t; + """) return .commit } } @@ -337,37 +346,39 @@ class DatabaseAbortedTransactionTests : GRDBTestCase { XCTFail("Unexpected error: \(error)") } } - + try test(setup(DatabaseQueue())) try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) } - + func testReadTransactionRestartHack() throws { // Here we test that the "ROLLBACK; BEGIN TRANSACTION;" hack which // "refreshes" a DatabaseSnaphot works. // See https://github.com/groue/GRDB.swift/issues/619 // This hack puts temporarily the transaction in the aborded // state. Here we test that we don't throw SQLITE_ABORT. - + let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE t(a);") } let snapshot = try dbPool.makeSnapshot() try snapshot.read { db in - try db.execute(sql: """ - ROLLBACK; - BEGIN TRANSACTION; - """) + try db.execute( + sql: """ + ROLLBACK; + BEGIN TRANSACTION; + """) } try snapshot.read { db in - try db.execute(sql: """ - SELECT * FROM t; - ROLLBACK; - BEGIN TRANSACTION; - SELECT * FROM t; - """) + try db.execute( + sql: """ + SELECT * FROM t; + ROLLBACK; + BEGIN TRANSACTION; + SELECT * FROM t; + """) } } } diff --git a/Tests/GRDBTests/DatabaseConfigurationTests.swift b/Tests/GRDBTests/DatabaseConfigurationTests.swift index 64a09cf513..d3e23fc3ef 100644 --- a/Tests/GRDBTests/DatabaseConfigurationTests.swift +++ b/Tests/GRDBTests/DatabaseConfigurationTests.swift @@ -1,9 +1,9 @@ -import XCTest import GRDB +import XCTest class DatabaseConfigurationTests: GRDBTestCase { // MARK: - prepareDatabase - + func testPrepareDatabase() throws { // prepareDatabase is called when connection opens let connectionCountMutex = Mutex(0) @@ -11,126 +11,128 @@ class DatabaseConfigurationTests: GRDBTestCase { configuration.prepareDatabase { db in connectionCountMutex.increment() } - + _ = try DatabaseQueue(configuration: configuration) XCTAssertEqual(connectionCountMutex.load(), 1) - + _ = try makeDatabaseQueue(configuration: configuration) XCTAssertEqual(connectionCountMutex.load(), 2) - + let pool = try makeDatabasePool(configuration: configuration) XCTAssertEqual(connectionCountMutex.load(), 3) - + try pool.read { _ in } XCTAssertEqual(connectionCountMutex.load(), 4) - + try pool.makeSnapshot().read { _ in } XCTAssertEqual(connectionCountMutex.load(), 5) - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try pool.makeSnapshotPool().read { _ in } - XCTAssertEqual(connectionCountMutex.load(), 6) -#endif + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try pool.makeSnapshotPool().read { _ in } + XCTAssertEqual(connectionCountMutex.load(), 6) + #endif } - + func testPrepareDatabaseError() throws { - struct TestError: Error { } + struct TestError: Error {} let errorMutex: Mutex = Mutex(nil) - + var configuration = Configuration() configuration.prepareDatabase { db in if let error = errorMutex.load() { throw error } } - + // TODO: what about in-memory DatabaseQueue??? - + do { errorMutex.store(TestError()) _ = try makeDatabaseQueue(configuration: configuration) XCTFail("Expected TestError") - } catch is TestError { } - + } catch is TestError {} + do { errorMutex.store(TestError()) _ = try makeDatabasePool(configuration: configuration) XCTFail("Expected TestError") - } catch is TestError { } - + } catch is TestError {} + do { errorMutex.store(nil) let pool = try makeDatabasePool(configuration: configuration) - + do { errorMutex.store(TestError()) try pool.read { _ in } XCTFail("Expected TestError") - } catch is TestError { } - + } catch is TestError {} + do { errorMutex.store(TestError()) _ = try pool.makeSnapshot() XCTFail("Expected TestError") - } catch is TestError { } - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - do { - errorMutex.store(TestError()) - _ = try pool.makeSnapshotPool() - XCTFail("Expected TestError") - } catch is TestError { } -#endif + } catch is TestError {} + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + do { + errorMutex.store(TestError()) + _ = try pool.makeSnapshotPool() + XCTFail("Expected TestError") + } catch is TestError {} + #endif } } - + // MARK: - acceptsDoubleQuotedStringLiterals - + func testAcceptsDoubleQuotedStringLiteralsDefault() throws { let configuration = Configuration() XCTAssertFalse(configuration.acceptsDoubleQuotedStringLiterals) } - + func testAcceptsDoubleQuotedStringLiteralsTrue() throws { var configuration = Configuration() configuration.acceptsDoubleQuotedStringLiterals = true let dbQueue = try makeDatabaseQueue(configuration: configuration) try dbQueue.inDatabase { db in - try db.execute(sql: """ - CREATE TABLE player(name TEXT); - INSERT INTO player DEFAULT VALUES; - """) + try db.execute( + sql: """ + CREATE TABLE player(name TEXT); + INSERT INTO player DEFAULT VALUES; + """) } - + // Test SQLITE_DBCONFIG_DQS_DML let foo = try dbQueue.inDatabase { db in try String.fetchOne(db, sql: "SELECT \"foo\" FROM player") } XCTAssertEqual(foo, "foo") - + // Test SQLITE_DBCONFIG_DQS_DDL try dbQueue.inDatabase { db in try db.execute(sql: "CREATE INDEX i ON player(\"foo\")") } } - + func testAcceptsDoubleQuotedStringLiteralsFalse() throws { var configuration = Configuration() configuration.acceptsDoubleQuotedStringLiterals = false let dbQueue = try makeDatabaseQueue(configuration: configuration) try dbQueue.inDatabase { db in - try db.execute(sql: """ - CREATE TABLE player(name TEXT); - INSERT INTO player DEFAULT VALUES; - """) + try db.execute( + sql: """ + CREATE TABLE player(name TEXT); + INSERT INTO player DEFAULT VALUES; + """) } - + // Test SQLITE_DBCONFIG_DQS_DML do { let foo = try dbQueue.inDatabase { db in try String.fetchOne(db, sql: "SELECT \"foo\" FROM player") } - if Database.sqliteLibVersionNumber >= 3029000 { + if Database.sqliteLibVersionNumber >= 3_029_000 { XCTFail("Expected error") } else { XCTAssertEqual(foo, "foo") @@ -139,13 +141,13 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTAssertEqual(error.resultCode, .SQLITE_ERROR) XCTAssertEqual(error.sql, "SELECT \"foo\" FROM player") } - + // Test SQLITE_DBCONFIG_DQS_DDL do { try dbQueue.inDatabase { db in try db.execute(sql: "CREATE INDEX i ON player(\"foo\")") } - if Database.sqliteLibVersionNumber >= 3029000 { + if Database.sqliteLibVersionNumber >= 3_029_000 { XCTFail("Expected error") } } catch let error as DatabaseError { @@ -153,29 +155,29 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTAssertEqual(error.sql, "CREATE INDEX i ON player(\"foo\")") } } - + // MARK: - busyMode - + func testBusyModeImmediate() throws { let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") #if GRDBCIPHER_USE_ENCRYPTION - // Work around SQLCipher bug when two connections are open to the - // same empty database: make sure the database is not empty before - // running this test - try dbQueue1.inDatabase { db in - try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") - } + // Work around SQLCipher bug when two connections are open to the + // same empty database: make sure the database is not empty before + // running this test + try dbQueue1.inDatabase { db in + try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") + } #endif - + var configuration2 = dbQueue1.configuration configuration2.busyMode = .immediateError let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite", configuration: configuration2) - + let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let queue = DispatchQueue.global(qos: .default) let group = DispatchGroup() - + queue.async(group: group) { do { try dbQueue1.inTransaction(.exclusive) { db in @@ -190,7 +192,7 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTFail("\(error)") } } - + queue.async(group: group) { do { _ = s2.wait(timeout: .distantFuture) @@ -201,19 +203,19 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTFail("\(error)") } } - + _ = group.wait(timeout: .distantFuture) } - + func testBusyModeTimeoutTooShort() throws { let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") #if GRDBCIPHER_USE_ENCRYPTION - // Work around SQLCipher bug when two connections are open to the - // same empty database: make sure the database is not empty before - // running this test - try dbQueue1.inDatabase { db in - try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") - } + // Work around SQLCipher bug when two connections are open to the + // same empty database: make sure the database is not empty before + // running this test + try dbQueue1.inDatabase { db in + try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") + } #endif var configuration2 = dbQueue1.configuration @@ -255,27 +257,27 @@ class DatabaseConfigurationTests: GRDBTestCase { _ = group.wait(timeout: .distantFuture) } - + func testBusyModeTimeoutTooLong() throws { let dbQueue1 = try makeDatabaseQueue(filename: "test.sqlite") #if GRDBCIPHER_USE_ENCRYPTION - // Work around SQLCipher bug when two connections are open to the - // same empty database: make sure the database is not empty before - // running this test - try dbQueue1.inDatabase { db in - try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") - } + // Work around SQLCipher bug when two connections are open to the + // same empty database: make sure the database is not empty before + // running this test + try dbQueue1.inDatabase { db in + try db.execute(sql: "CREATE TABLE SQLCipherWorkAround (foo INTEGER)") + } #endif - + var configuration2 = dbQueue1.configuration configuration2.busyMode = .timeout(1) let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite", configuration: configuration2) - + let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let queue = DispatchQueue.global(qos: .default) let group = DispatchGroup() - + queue.async(group: group) { do { try dbQueue1.inTransaction(.exclusive) { db in @@ -292,7 +294,7 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTFail("\(error)") } } - + queue.async(group: group) { do { // Wait for dbQueue1 to start an exclusive transaction @@ -302,7 +304,7 @@ class DatabaseConfigurationTests: GRDBTestCase { XCTFail("\(error)") } } - + _ = group.wait(timeout: .distantFuture) } } diff --git a/Tests/GRDBTests/DatabaseErrorTests.swift b/Tests/GRDBTests/DatabaseErrorTests.swift index e968b44074..fdf65b5935 100644 --- a/Tests/GRDBTests/DatabaseErrorTests.swift +++ b/Tests/GRDBTests/DatabaseErrorTests.swift @@ -1,8 +1,8 @@ -import XCTest import GRDB +import XCTest class DatabaseErrorTests: GRDBTestCase { - + func testDatabaseErrorMessage() { // We don't test for actual messages, since they may depend on SQLite version XCTAssertEqual(DatabaseError().resultCode, .SQLITE_ERROR) @@ -10,25 +10,35 @@ class DatabaseErrorTests: GRDBTestCase { XCTAssertNotNil(DatabaseError(resultCode: .SQLITE_BUSY).message) XCTAssertNotEqual(DatabaseError().message, DatabaseError(resultCode: .SQLITE_BUSY).message) } - + func testDatabaseErrorInTransaction() throws { let dbQueue = try makeDatabaseQueue() do { try dbQueue.inTransaction { db in try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) clearSQLQueries() - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) XCTFail() return .commit } } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) XCTAssertEqual(sqlQueries.count, 2) XCTAssertEqual(sqlQueries[0], "INSERT INTO pets (masterId, name) VALUES (1, 'Bobby')") XCTAssertEqual(sqlQueries[1], "ROLLBACK TRANSACTION") @@ -43,9 +53,14 @@ class DatabaseErrorTests: GRDBTestCase { try db.inSavepoint { XCTAssertTrue(db.isInsideTransaction) try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) clearSQLQueries() - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", + arguments: [1, "Bobby"]) XCTFail() return .commit } @@ -58,10 +73,16 @@ class DatabaseErrorTests: GRDBTestCase { } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } @@ -69,54 +90,78 @@ class DatabaseErrorTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) } - + // db.execute(sql, arguments) try dbQueue.inDatabase { db in do { - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } - + // statement.execute(arguments) try dbQueue.inDatabase { db in do { - let statement = try db.makeStatement(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") + let statement = try db.makeStatement( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") try statement.execute(arguments: [1, "Bobby"]) XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } - + // statement.execute() try dbQueue.inDatabase { db in do { - let statement = try db.makeStatement(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") + let statement = try db.makeStatement( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") statement.arguments = [1, "Bobby"] try statement.execute() XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } } @@ -124,58 +169,82 @@ class DatabaseErrorTests: GRDBTestCase { func testDatabaseErrorThrownByUpdateStatementContainSQLAndPublicArguments() throws { // Opt in for public statement arguments dbConfiguration.publicStatementArguments = true - + let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) } - + // db.execute(sql, arguments) try dbQueue.inDatabase { db in do { - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [1, "Bobby"]) XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } - + // statement.execute(arguments) try dbQueue.inDatabase { db in do { - let statement = try db.makeStatement(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") + let statement = try db.makeStatement( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") try statement.execute(arguments: [1, "Bobby"]) XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } - + // statement.execute() try dbQueue.inDatabase { db in do { - let statement = try db.makeStatement(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") + let statement = try db.makeStatement( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)") statement.arguments = [1, "Bobby"] try statement.execute() XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (?, ?)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (?, ?)` with arguments [1, \"bobby\"]" + ) } } } @@ -184,19 +253,26 @@ class DatabaseErrorTests: GRDBTestCase { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in do { - try db.execute(sql: """ - CREATE TABLE persons (id INTEGER PRIMARY KEY, name TEXT, age INT); - CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT); - INSERT INTO pets (masterId, name) VALUES (1, 'Bobby') - """) + try db.execute( + sql: """ + CREATE TABLE persons (id INTEGER PRIMARY KEY, name TEXT, age INT); + CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT); + INSERT INTO pets (masterId, name) VALUES (1, 'Bobby') + """) XCTFail() } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "foreign key constraint failed") // lowercased: accept multiple SQLite version XCTAssertEqual(error.sql!, "INSERT INTO pets (masterId, name) VALUES (1, 'Bobby')") - XCTAssertEqual(error.description.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (1, 'bobby')`") - XCTAssertEqual(error.expandedDescription.lowercased(), "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (1, 'bobby')`") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (1, 'bobby')`" + ) + XCTAssertEqual( + error.expandedDescription.lowercased(), + "sqlite error 19: foreign key constraint failed - while executing `insert into pets (masterid, name) values (1, 'bobby')`" + ) } } } @@ -210,24 +286,28 @@ class DatabaseErrorTests: GRDBTestCase { try db.execute(sql: "INSERT INTO children (parentId) VALUES (1)") } catch let error as DatabaseError { XCTAssert(error.extendedResultCode == .SQLITE_CONSTRAINT_FOREIGNKEY) - XCTAssertEqual(error.resultCode.rawValue, 19) // primary SQLITE_CONSTRAINT + XCTAssertEqual(error.resultCode.rawValue, 19) // primary SQLITE_CONSTRAINT } } } - + func testNSErrorBridging() throws { - let dbQueue = try makeDatabaseQueue() - try dbQueue.inDatabase { db in - try db.create(table: "parents") { $0.column("id", .integer).primaryKey() } - try db.create(table: "children") { $0.belongsTo("parent") } - do { - try db.execute(sql: "INSERT INTO children (parentId) VALUES (1)") - } catch let error as NSError { - XCTAssertEqual(DatabaseError.errorDomain, "GRDB.DatabaseError") - XCTAssertEqual(error.domain, DatabaseError.errorDomain) - XCTAssertEqual(error.code, 787) - XCTAssertNotNil(error.localizedFailureReason) + #if !canImport(Darwin) + throw XCTSkip("NSError bridging not available on non-Darwin platforms") + #else + let dbQueue = try makeDatabaseQueue() + try dbQueue.inDatabase { db in + try db.create(table: "parents") { $0.column("id", .integer).primaryKey() } + try db.create(table: "children") { $0.belongsTo("parent") } + do { + try db.execute(sql: "INSERT INTO children (parentId) VALUES (1)") + } catch let error as NSError { + XCTAssertEqual(DatabaseError.errorDomain, "GRDB.DatabaseError") + XCTAssertEqual(error.domain, DatabaseError.errorDomain) + XCTAssertEqual(error.code, 787) + XCTAssertNotNil(error.localizedFailureReason) + } } - } + #endif } } diff --git a/Tests/GRDBTests/DatabaseMigratorTests.swift b/Tests/GRDBTests/DatabaseMigratorTests.swift index 1d94465c54..b64941e05c 100644 --- a/Tests/GRDBTests/DatabaseMigratorTests.swift +++ b/Tests/GRDBTests/DatabaseMigratorTests.swift @@ -1,299 +1,352 @@ -import XCTest import GRDB +import XCTest -class DatabaseMigratorTests : GRDBTestCase { +class DatabaseMigratorTests: GRDBTestCase { // Test passes if it compiles. // See func testMigrateAnyDatabaseWriter(writer: any DatabaseWriter) throws { let migrator = DatabaseMigrator() try migrator.migrate(writer) } - + func testEmptyMigratorSync() throws { - func test(writer: some DatabaseWriter) throws { - let migrator = DatabaseMigrator() - try migrator.migrate(writer) - } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: some DatabaseWriter) throws { + let migrator = DatabaseMigrator() + try migrator.migrate(writer) + } + + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testEmptyMigratorAsync() throws { func test(writer: some DatabaseWriter) throws { let expectation = self.expectation(description: "") let migrator = DatabaseMigrator() - migrator.asyncMigrate(writer, completion: { dbResult in - // No migration error - let db = try! dbResult.get() - - // Write access - try! db.execute(sql: "CREATE TABLE t(a)") - expectation.fulfill() - }) + migrator.asyncMigrate( + writer, + completion: { dbResult in + // No migration error + let db = try! dbResult.get() + + // Write access + try! db.execute(sql: "CREATE TABLE t(a)") + expectation.fulfill() + }) waitForExpectations(timeout: 5, handler: nil) } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testEmptyMigratorPublisher() throws { - func test(writer: some DatabaseWriter) throws { - let migrator = DatabaseMigrator() - let publisher = migrator.migratePublisher(writer) - let recorder = publisher.record() - try wait(for: recorder.single, timeout: 1) - } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: some DatabaseWriter) throws { + let migrator = DatabaseMigrator() + let publisher = migrator.migratePublisher(writer) + let recorder = publisher.record() + try wait(for: recorder.single, timeout: 1) + } + + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testNonEmptyMigratorSync() throws { func test(writer: some DatabaseWriter) throws { var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in - try db.execute(sql: """ - CREATE TABLE persons ( - id INTEGER PRIMARY KEY, - name TEXT) - """) + try db.execute( + sql: """ + CREATE TABLE persons ( + id INTEGER PRIMARY KEY, + name TEXT) + """) } migrator.registerMigration("createPets") { db in - try db.execute(sql: """ - CREATE TABLE pets ( - id INTEGER PRIMARY KEY, - masterID INTEGER NOT NULL - REFERENCES persons(id) - ON DELETE CASCADE ON UPDATE CASCADE, - name TEXT) - """) + try db.execute( + sql: """ + CREATE TABLE pets ( + id INTEGER PRIMARY KEY, + masterID INTEGER NOT NULL + REFERENCES persons(id) + ON DELETE CASCADE ON UPDATE CASCADE, + name TEXT) + """) } - + var migrator2 = migrator migrator2.registerMigration("destroyPersons") { db in try db.execute(sql: "DROP TABLE pets") } - + try migrator.migrate(writer) try writer.read { db in XCTAssertTrue(try db.tableExists("persons")) XCTAssertTrue(try db.tableExists("pets")) } - + try migrator2.migrate(writer) try writer.read { db in XCTAssertTrue(try db.tableExists("persons")) XCTAssertFalse(try db.tableExists("pets")) } } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testNonEmptyMigratorAsync() throws { func test(writer: some DatabaseWriter) throws { var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in - try db.execute(sql: """ - CREATE TABLE persons ( - id INTEGER PRIMARY KEY, - name TEXT) - """) + try db.execute( + sql: """ + CREATE TABLE persons ( + id INTEGER PRIMARY KEY, + name TEXT) + """) } migrator.registerMigration("createPets") { db in - try db.execute(sql: """ - CREATE TABLE pets ( - id INTEGER PRIMARY KEY, - masterID INTEGER NOT NULL - REFERENCES persons(id) - ON DELETE CASCADE ON UPDATE CASCADE, - name TEXT) - """) + try db.execute( + sql: """ + CREATE TABLE pets ( + id INTEGER PRIMARY KEY, + masterID INTEGER NOT NULL + REFERENCES persons(id) + ON DELETE CASCADE ON UPDATE CASCADE, + name TEXT) + """) } - + var migrator2 = migrator migrator2.registerMigration("destroyPersons") { db in try db.execute(sql: "DROP TABLE pets") } - + let expectation = self.expectation(description: "") - migrator.asyncMigrate(writer, completion: { [migrator2] dbResult in - // No migration error - let db = try! dbResult.get() - - XCTAssertTrue(try! db.tableExists("persons")) - XCTAssertTrue(try! db.tableExists("pets")) - - migrator2.asyncMigrate(writer, completion: { dbResult in + migrator.asyncMigrate( + writer, + completion: { [migrator2] dbResult in // No migration error let db = try! dbResult.get() - + XCTAssertTrue(try! db.tableExists("persons")) - XCTAssertFalse(try! db.tableExists("pets")) - expectation.fulfill() + XCTAssertTrue(try! db.tableExists("pets")) + + migrator2.asyncMigrate( + writer, + completion: { dbResult in + // No migration error + let db = try! dbResult.get() + + XCTAssertTrue(try! db.tableExists("persons")) + XCTAssertFalse(try! db.tableExists("pets")) + expectation.fulfill() + }) }) - }) waitForExpectations(timeout: 5, handler: nil) } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testNonEmptyMigratorPublisher() throws { - func test(writer: some DatabaseWriter) throws { - var migrator = DatabaseMigrator() - migrator.registerMigration("createPersons") { db in - try db.execute(sql: """ - CREATE TABLE persons ( - id INTEGER PRIMARY KEY, - name TEXT) - """) - } - migrator.registerMigration("createPets") { db in - try db.execute(sql: """ - CREATE TABLE pets ( - id INTEGER PRIMARY KEY, - masterID INTEGER NOT NULL - REFERENCES persons(id) - ON DELETE CASCADE ON UPDATE CASCADE, - name TEXT) - """) - } - - var migrator2 = migrator - migrator2.registerMigration("destroyPersons") { db in - try db.execute(sql: "DROP TABLE pets") - } - - do { - let publisher = migrator.migratePublisher(writer) - let recorder = publisher.record() - try wait(for: recorder.single, timeout: 1) - try writer.read { db in - XCTAssertTrue(try db.tableExists("persons")) - XCTAssertTrue(try db.tableExists("pets")) + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + + func test(writer: some DatabaseWriter) throws { + var migrator = DatabaseMigrator() + migrator.registerMigration("createPersons") { db in + try db.execute( + sql: """ + CREATE TABLE persons ( + id INTEGER PRIMARY KEY, + name TEXT) + """) } - } - - do { - let publisher = migrator2.migratePublisher(writer) - let recorder = publisher.record() - try wait(for: recorder.single, timeout: 1) - try writer.read { db in - XCTAssertTrue(try db.tableExists("persons")) - XCTAssertFalse(try db.tableExists("pets")) + migrator.registerMigration("createPets") { db in + try db.execute( + sql: """ + CREATE TABLE pets ( + id INTEGER PRIMARY KEY, + masterID INTEGER NOT NULL + REFERENCES persons(id) + ON DELETE CASCADE ON UPDATE CASCADE, + name TEXT) + """) + } + + var migrator2 = migrator + migrator2.registerMigration("destroyPersons") { db in + try db.execute(sql: "DROP TABLE pets") + } + + do { + let publisher = migrator.migratePublisher(writer) + let recorder = publisher.record() + try wait(for: recorder.single, timeout: 1) + try writer.read { db in + XCTAssertTrue(try db.tableExists("persons")) + XCTAssertTrue(try db.tableExists("pets")) + } + } + + do { + let publisher = migrator2.migratePublisher(writer) + let recorder = publisher.record() + try wait(for: recorder.single, timeout: 1) + try writer.read { db in + XCTAssertTrue(try db.tableExists("persons")) + XCTAssertFalse(try db.tableExists("pets")) + } } } - } - - 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).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } func testEmptyMigratorPublisherIsAsynchronous() throws { - func test(writer: some DatabaseWriter) throws { - let migrator = DatabaseMigrator() - let expectation = self.expectation(description: "") - let semaphore = DispatchSemaphore(value: 0) - let cancellable = migrator.migratePublisher(writer).sink( - receiveCompletion: { _ in }, - receiveValue: { _ in - semaphore.wait() - expectation.fulfill() - }) - - semaphore.signal() - waitForExpectations(timeout: 5, handler: nil) - cancellable.cancel() - } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: some DatabaseWriter) throws { + let migrator = DatabaseMigrator() + let expectation = self.expectation(description: "") + let semaphore = DispatchSemaphore(value: 0) + let cancellable = migrator.migratePublisher(writer).sink( + receiveCompletion: { _ in }, + receiveValue: { _ in + semaphore.wait() + expectation.fulfill() + }) + + semaphore.signal() + waitForExpectations(timeout: 5, handler: nil) + cancellable.cancel() + } + + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testNonEmptyMigratorPublisherIsAsynchronous() throws { - func test(writer: some DatabaseWriter) throws { - var migrator = DatabaseMigrator() - migrator.registerMigration("first", migrate: { _ in }) - let expectation = self.expectation(description: "") - let semaphore = DispatchSemaphore(value: 0) - let cancellable = migrator.migratePublisher(writer).sink( - receiveCompletion: { _ in }, - receiveValue: { _ in - semaphore.wait() - expectation.fulfill() - }) - - semaphore.signal() - waitForExpectations(timeout: 5, handler: nil) - cancellable.cancel() - } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: some DatabaseWriter) throws { + var migrator = DatabaseMigrator() + migrator.registerMigration("first", migrate: { _ in }) + let expectation = self.expectation(description: "") + let semaphore = DispatchSemaphore(value: 0) + let cancellable = migrator.migratePublisher(writer).sink( + receiveCompletion: { _ in }, + receiveValue: { _ in + semaphore.wait() + expectation.fulfill() + }) + + semaphore.signal() + waitForExpectations(timeout: 5, handler: nil) + cancellable.cancel() + } + + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testMigratorPublisherDefaultScheduler() throws { - func test(writer: Writer) { - var migrator = DatabaseMigrator() - migrator.registerMigration("first", migrate: { _ in }) - let expectation = self.expectation(description: "") - expectation.expectedFulfillmentCount = 2 // value + completion - let cancellable = migrator.migratePublisher(writer).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 DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: Writer) { + var migrator = DatabaseMigrator() + migrator.registerMigration("first", migrate: { _ in }) + let expectation = self.expectation(description: "") + expectation.expectedFulfillmentCount = 2 // value + completion + let cancellable = migrator.migratePublisher(writer).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 DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testMigratorPublisherCustomScheduler() throws { - func test(writer: Writer) { - var migrator = DatabaseMigrator() - migrator.registerMigration("first", migrate: { _ in }) - let queue = DispatchQueue(label: "test") - let expectation = self.expectation(description: "") - expectation.expectedFulfillmentCount = 2 // value + completion - let cancellable = migrator.migratePublisher(writer, receiveOn: queue).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 DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + func test(writer: Writer) { + var migrator = DatabaseMigrator() + migrator.registerMigration("first", migrate: { _ in }) + let queue = DispatchQueue(label: "test") + let expectation = self.expectation(description: "") + expectation.expectedFulfillmentCount = 2 // value + completion + let cancellable = migrator.migratePublisher(writer, receiveOn: queue).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 DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } func testMigrateUpTo() throws { @@ -308,21 +361,21 @@ class DatabaseMigratorTests : GRDBTestCase { migrator.registerMigration("c") { db in try db.execute(sql: "CREATE TABLE c (id INTEGER PRIMARY KEY)") } - + // one step try migrator.migrate(writer, upTo: "a") try writer.read { db in XCTAssertTrue(try db.tableExists("a")) XCTAssertFalse(try db.tableExists("b")) } - + // zero step try migrator.migrate(writer, upTo: "a") try writer.read { db in XCTAssertTrue(try db.tableExists("a")) XCTAssertFalse(try db.tableExists("b")) } - + // two steps try migrator.migrate(writer, upTo: "c") try writer.read { db in @@ -330,36 +383,44 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertTrue(try db.tableExists("b")) XCTAssertTrue(try db.tableExists("c")) } - + // zero step try migrator.migrate(writer, upTo: "c") try migrator.migrate(writer) - + // fatal error: undefined migration: "missing" // try migrator.migrate(writer, upTo: "missing") - + // fatal error: database is already migrated beyond migration "b" // try migrator.migrate(writer, upTo: "b") } - - try Test(test).run { try DatabaseQueue() } - try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + + #if !canImport(Combine) + throw XCTSkip("Combine not supported on this platform") + #else + try Test(test).run { try DatabaseQueue() } + try Test(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try Test(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + #endif } - + func testMigrationFailureTriggersRollback() throws { var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY, name TEXT)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) try db.execute(sql: "INSERT INTO persons (name) VALUES ('Arthur')") } migrator.registerMigration("foreignKeyError") { db in try db.execute(sql: "INSERT INTO persons (name) VALUES ('Barbara')") // triggers foreign key error: - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [123, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [123, "Bobby"]) } - + // Sync do { let dbQueue = try makeDatabaseQueue() @@ -369,55 +430,69 @@ class DatabaseMigratorTests : GRDBTestCase { } catch let error as DatabaseError { // The first migration should be committed. // The second migration should be rollbacked. - + XCTAssertEqual(error.extendedResultCode, .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message, #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"#) - + XCTAssertEqual( + error.message, + #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"# + ) + let names = try dbQueue.inDatabase { db in try String.fetchAll(db, sql: "SELECT name FROM persons") } XCTAssertEqual(names, ["Arthur"]) } } - + // Async do { let expectation = self.expectation(description: "") let dbQueue = try makeDatabaseQueue() - migrator.asyncMigrate(dbQueue, completion: { dbResult in - // The first migration should be committed. - // The second migration should be rollbacked. - - guard case let .failure(error as DatabaseError) = dbResult else { - XCTFail("Expected DatabaseError") - expectation.fulfill() - return - } - - XCTAssertEqual(error.extendedResultCode, .SQLITE_CONSTRAINT_FOREIGNKEY) - XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message, #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"#) - - dbQueue.asyncRead { dbResult in - let names = try! String.fetchAll(dbResult.get(), sql: "SELECT name FROM persons") - XCTAssertEqual(names, ["Arthur"]) - - expectation.fulfill() - } - }) + migrator.asyncMigrate( + dbQueue, + completion: { dbResult in + // The first migration should be committed. + // The second migration should be rollbacked. + + guard case .failure(let error as DatabaseError) = dbResult else { + XCTFail("Expected DatabaseError") + expectation.fulfill() + return + } + + XCTAssertEqual(error.extendedResultCode, .SQLITE_CONSTRAINT_FOREIGNKEY) + XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) + XCTAssertEqual( + error.message, + #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"# + ) + + dbQueue.asyncRead { dbResult in + let names = try! String.fetchAll( + dbResult.get(), sql: "SELECT name FROM persons") + XCTAssertEqual(names, ["Arthur"]) + + expectation.fulfill() + } + }) waitForExpectations(timeout: 5, handler: nil) } } - + func testForeignKeyViolation() throws { var migrator = DatabaseMigrator() migrator.registerMigration("createPersons") { db in - try db.execute(sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY, name TEXT, tmp TEXT)") - try db.execute(sql: "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)") + try db.execute( + sql: "CREATE TABLE persons (id INTEGER PRIMARY KEY, name TEXT, tmp TEXT)") + try db.execute( + sql: + "CREATE TABLE pets (masterId INTEGER NOT NULL REFERENCES persons(id), name TEXT)" + ) try db.execute(sql: "INSERT INTO persons (name) VALUES ('Arthur')") let personId = db.lastInsertedRowID - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, 'Bobby')", arguments:[personId]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, 'Bobby')", arguments: [personId]) } migrator.registerMigration("removePersonTmpColumn") { db in // Test the technique described at https://www.sqlite.org/lang_altertable.html#otheralter @@ -429,9 +504,10 @@ class DatabaseMigratorTests : GRDBTestCase { migrator.registerMigration("foreignKeyError") { db in try db.execute(sql: "INSERT INTO persons (name) VALUES ('Barbara')") // triggers foreign key error: - try db.execute(sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [123, "Bobby"]) + try db.execute( + sql: "INSERT INTO pets (masterId, name) VALUES (?, ?)", arguments: [123, "Bobby"]) } - + let dbQueue = try makeDatabaseQueue() do { try migrator.migrate(dbQueue) @@ -439,21 +515,24 @@ class DatabaseMigratorTests : GRDBTestCase { } catch let error as DatabaseError { // Migration 1 and 2 should be committed. // Migration 3 should not be committed. - + XCTAssertEqual(error.extendedResultCode, .SQLITE_CONSTRAINT_FOREIGNKEY) XCTAssertEqual(error.resultCode, .SQLITE_CONSTRAINT) - XCTAssertEqual(error.message, #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"#) - + XCTAssertEqual( + error.message, + #"FOREIGN KEY constraint violation - from pets(masterId) to persons(id), in [masterId:123 name:"Bobby"]"# + ) + try dbQueue.inDatabase { db in // Arthur inserted (migration 1), Barbara (migration 3) not inserted. var rows = try Row.fetchAll(db, sql: "SELECT * FROM persons") XCTAssertEqual(rows.count, 1) var row = rows.first! XCTAssertEqual(row["name"] as String, "Arthur") - + // persons table has no "tmp" column (migration 2) XCTAssertEqual(Array(row.columnNames), ["id", "name"]) - + // Bobby inserted (migration 1), not deleted by migration 2. rows = try Row.fetchAll(db, sql: "SELECT * FROM pets") XCTAssertEqual(rows.count, 1) @@ -462,31 +541,31 @@ class DatabaseMigratorTests : GRDBTestCase { } } } - + func testAppliedMigrations() throws { var migrator = DatabaseMigrator() - + // No migration do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) } - + // One migration - + migrator.registerMigration("1", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) try migrator.migrate(dbQueue, upTo: "1") try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), ["1"]) } - + // Two migrations - + migrator.registerMigration("2", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), []) @@ -496,21 +575,21 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertEqual(dbQueue.read(migrator.appliedMigrations), ["1", "2"]) } } - + func testCompletedMigrations() throws { var migrator = DatabaseMigrator() - + // No migration do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) } - + // One migration - + migrator.registerMigration("1", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) @@ -519,11 +598,11 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), ["1"]) try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) } - + // Two migrations - + migrator.registerMigration("2", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertEqual(dbQueue.read(migrator.completedMigrations), []) @@ -536,31 +615,31 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertTrue(dbQueue.read(migrator.hasCompletedMigrations)) } } - + func testSuperseded() throws { var migrator = DatabaseMigrator() - + // No migration do { let dbQueue = try makeDatabaseQueue() try XCTAssertFalse(dbQueue.read(migrator.hasBeenSuperseded)) } - + // One migration - + migrator.registerMigration("1", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertFalse(dbQueue.read(migrator.hasBeenSuperseded)) try migrator.migrate(dbQueue, upTo: "1") try XCTAssertFalse(dbQueue.read(migrator.hasBeenSuperseded)) } - + // Two migrations - + migrator.registerMigration("2", migrate: { _ in }) - + do { let dbQueue = try makeDatabaseQueue() try XCTAssertFalse(dbQueue.read(migrator.hasBeenSuperseded)) @@ -570,115 +649,115 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertFalse(dbQueue.read(migrator.hasBeenSuperseded)) } } - + func testMergedMigrators() throws { // Migrate a database var migrator1 = DatabaseMigrator() migrator1.registerMigration("1", migrate: { _ in }) migrator1.registerMigration("3", migrate: { _ in }) - + let dbQueue = try makeDatabaseQueue() try migrator1.migrate(dbQueue) - + try XCTAssertEqual(dbQueue.read(migrator1.appliedMigrations), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator1.appliedIdentifiers), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator1.completedMigrations), ["1", "3"]) try XCTAssertTrue(dbQueue.read(migrator1.hasCompletedMigrations)) try XCTAssertFalse(dbQueue.read(migrator1.hasBeenSuperseded)) - + // --- // A source code merge inserts a migration between "1" and "3" var migrator2 = DatabaseMigrator() migrator2.registerMigration("1", migrate: { _ in }) migrator2.registerMigration("2", migrate: { _ in }) migrator2.registerMigration("3", migrate: { _ in }) - + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator2.appliedIdentifiers), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator2.completedMigrations), ["1"]) try XCTAssertFalse(dbQueue.read(migrator2.hasCompletedMigrations)) try XCTAssertFalse(dbQueue.read(migrator2.hasBeenSuperseded)) - + // The new source code migrates the database try migrator2.migrate(dbQueue) - + try XCTAssertEqual(dbQueue.read(migrator1.appliedMigrations), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator1.appliedIdentifiers), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator1.completedMigrations), ["1", "3"]) try XCTAssertTrue(dbQueue.read(migrator1.hasCompletedMigrations)) try XCTAssertTrue(dbQueue.read(migrator1.hasBeenSuperseded)) - + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator2.appliedIdentifiers), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator2.completedMigrations), ["1", "2", "3"]) try XCTAssertTrue(dbQueue.read(migrator2.hasCompletedMigrations)) try XCTAssertFalse(dbQueue.read(migrator2.hasBeenSuperseded)) - + // --- // A source code merge appends a migration var migrator3 = migrator2 migrator3.registerMigration("4", migrate: { _ in }) - + try XCTAssertEqual(dbQueue.read(migrator3.appliedMigrations), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator3.appliedIdentifiers), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator3.completedMigrations), ["1", "2", "3"]) try XCTAssertFalse(dbQueue.read(migrator3.hasCompletedMigrations)) try XCTAssertFalse(dbQueue.read(migrator3.hasBeenSuperseded)) - + // The new source code migrates the database try migrator3.migrate(dbQueue) - + try XCTAssertEqual(dbQueue.read(migrator1.appliedMigrations), ["1", "3"]) try XCTAssertEqual(dbQueue.read(migrator1.appliedIdentifiers), ["1", "2", "3", "4"]) try XCTAssertEqual(dbQueue.read(migrator1.completedMigrations), ["1", "3"]) try XCTAssertTrue(dbQueue.read(migrator1.hasCompletedMigrations)) try XCTAssertTrue(dbQueue.read(migrator1.hasBeenSuperseded)) - + try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2", "3"]) try XCTAssertEqual(dbQueue.read(migrator2.appliedIdentifiers), ["1", "2", "3", "4"]) try XCTAssertEqual(dbQueue.read(migrator2.completedMigrations), ["1", "2", "3"]) try XCTAssertTrue(dbQueue.read(migrator2.hasCompletedMigrations)) try XCTAssertTrue(dbQueue.read(migrator2.hasBeenSuperseded)) - + try XCTAssertEqual(dbQueue.read(migrator3.appliedMigrations), ["1", "2", "3", "4"]) try XCTAssertEqual(dbQueue.read(migrator3.appliedIdentifiers), ["1", "2", "3", "4"]) try XCTAssertEqual(dbQueue.read(migrator3.completedMigrations), ["1", "2", "3", "4"]) try XCTAssertTrue(dbQueue.read(migrator3.hasCompletedMigrations)) try XCTAssertFalse(dbQueue.read(migrator3.hasBeenSuperseded)) } - + // Regression test for https://github.com/groue/GRDB.swift/issues/741 func testEraseDatabaseOnSchemaChangeDoesNotDeadLockOnTargetQueue() throws { dbConfiguration.targetQueue = DispatchQueue(label: "target") let dbQueue = try makeDatabaseQueue() - + var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true migrator.registerMigration("1", migrate: { _ in }) try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) - + migrator.registerMigration("2", migrate: { _ in }) try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) } - + // Regression test for https://github.com/groue/GRDB.swift/issues/741 func testEraseDatabaseOnSchemaChangeDoesNotDeadLockOnWriteTargetQueue() throws { dbConfiguration.writeTargetQueue = DispatchQueue(label: "writerTarget") let dbQueue = try makeDatabaseQueue() - + var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true migrator.registerMigration("1", migrate: { _ in }) try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) - + migrator.registerMigration("2", migrate: { _ in }) try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) } - + func testHasSchemaChangesWorksWithReadonlyConfig() throws { // 1st version of the migrator var migrator1 = DatabaseMigrator() @@ -688,33 +767,33 @@ class DatabaseMigratorTests : GRDBTestCase { t.column("name", .text) } } - + // 2nd version of the migrator var migrator2 = DatabaseMigrator() migrator2.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") t.column("name", .text) - t.column("score", .integer) // <- schema change, because reasons (development) + t.column("score", .integer) // <- schema change, because reasons (development) } } - + let dbName = ProcessInfo.processInfo.globallyUniqueString let dbQueue = try makeDatabaseQueue(filename: dbName) - + try XCTAssertFalse(dbQueue.read(migrator1.hasSchemaChanges)) try migrator1.migrate(dbQueue) try XCTAssertFalse(dbQueue.read(migrator1.hasSchemaChanges)) try dbQueue.close() - + // check that the migrator doesn't fail for a readonly connection dbConfiguration.readonly = true let readonlyQueue = try makeDatabaseQueue(filename: dbName) - + try XCTAssertFalse(readonlyQueue.read(migrator1.hasSchemaChanges)) try XCTAssertTrue(readonlyQueue.read(migrator2.hasSchemaChanges)) } - + func testEraseDatabaseOnSchemaChange() throws { // 1st version of the migrator var migrator1 = DatabaseMigrator() @@ -724,24 +803,25 @@ class DatabaseMigratorTests : GRDBTestCase { t.column("name", .text) } } - + // 2nd version of the migrator var migrator2 = DatabaseMigrator() migrator2.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") t.column("name", .text) - t.column("score", .integer) // <- schema change, because reasons (development) + t.column("score", .integer) // <- schema change, because reasons (development) } } migrator2.registerMigration("2") { db in - try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") + try db.execute( + sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") } - + // Apply 1st migrator let dbQueue = try makeDatabaseQueue() try migrator1.migrate(dbQueue) - + // Test than 2nd migrator can't run... do { try migrator2.migrate(dbQueue) @@ -751,7 +831,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(error.message, "table player has no column named score") } try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) - + // ... unless database gets erased migrator2.eraseDatabaseOnSchemaChange = true try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) @@ -759,7 +839,7 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } - + func testManualEraseDatabaseOnSchemaChange() throws { // 1st version of the migrator var migrator1 = DatabaseMigrator() @@ -769,24 +849,25 @@ class DatabaseMigratorTests : GRDBTestCase { t.column("name", .text) } } - + // 2nd version of the migrator var migrator2 = DatabaseMigrator() migrator2.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") t.column("name", .text) - t.column("score", .integer) // <- schema change, because reasons (development) + t.column("score", .integer) // <- schema change, because reasons (development) } } migrator2.registerMigration("2") { db in - try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") + try db.execute( + sql: "INSERT INTO player (id, name, score) VALUES (NULL, 'Arthur', 1000)") } - + // Apply 1st migrator let dbQueue = try makeDatabaseQueue() try migrator1.migrate(dbQueue) - + // Test than 2nd migrator can't run... do { try migrator2.migrate(dbQueue) @@ -796,7 +877,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(error.message, "table player has no column named score") } try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) - + // ... unless database gets erased if try dbQueue.read(migrator2.hasSchemaChanges) { try dbQueue.erase() @@ -816,29 +897,33 @@ class DatabaseMigratorTests : GRDBTestCase { } try db.execute(sql: "INSERT INTO player (id, name) VALUES (NULL, testFunction())") } - + // 2nd version of the migrator var migrator2 = DatabaseMigrator() migrator2.registerMigration("1") { db in try db.create(table: "player") { t in t.autoIncrementedPrimaryKey("id") t.column("name", .text) - t.column("score", .integer) // <- schema change + t.column("score", .integer) // <- schema change } - try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, testFunction(), 1000)") + try db.execute( + sql: "INSERT INTO player (id, name, score) VALUES (NULL, testFunction(), 1000)") } migrator2.registerMigration("2") { db in - try db.execute(sql: "INSERT INTO player (id, name, score) VALUES (NULL, testFunction(), 2000)") + try db.execute( + sql: "INSERT INTO player (id, name, score) VALUES (NULL, testFunction(), 2000)") } - + // Apply 1st migrator dbConfiguration.prepareDatabase { db in - let function = DatabaseFunction("testFunction", argumentCount: 0, pure: true) { _ in "Arthur" } + let function = DatabaseFunction("testFunction", argumentCount: 0, pure: true) { _ in + "Arthur" + } db.add(function: function) } let dbQueue = try makeDatabaseQueue() try migrator1.migrate(dbQueue) - + // Test than 2nd migrator can't run... do { try migrator2.migrate(dbQueue) @@ -848,7 +933,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(error.message, "table player has no column named score") } try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1"]) - + // ... unless database gets erased migrator2.eraseDatabaseOnSchemaChange = true try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) @@ -856,38 +941,40 @@ class DatabaseMigratorTests : GRDBTestCase { try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read(migrator2.appliedMigrations), ["1", "2"]) } - + func testEraseDatabaseOnSchemaChangeDoesNotEraseDatabaseOnAddedMigration() throws { var migrator = DatabaseMigrator() migrator.eraseDatabaseOnSchemaChange = true - + let mutex = Mutex(0) migrator.registerMigration("1") { db in let value = mutex.increment() - try db.execute(sql: """ - CREATE TABLE t1(id INTEGER PRIMARY KEY); - INSERT INTO t1(id) VALUES (?) - """, arguments: [value]) + try db.execute( + sql: """ + CREATE TABLE t1(id INTEGER PRIMARY KEY); + INSERT INTO t1(id) VALUES (?) + """, arguments: [value]) } - + let dbQueue = try makeDatabaseQueue() - + // 1st migration try migrator.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) - + // 2nd migration does not erase database migrator.registerMigration("2") { db in - try db.execute(sql: """ - CREATE TABLE t2(id INTEGER PRIMARY KEY); - """) + try db.execute( + sql: """ + CREATE TABLE t2(id INTEGER PRIMARY KEY); + """) } try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) try XCTAssertTrue(dbQueue.read { try $0.tableExists("t2") }) } - + // Regression test for func testEraseDatabaseOnSchemaChangeIgnoresInternalSchemaObjects() throws { // Given a migrator with eraseDatabaseOnSchemaChange @@ -898,51 +985,54 @@ class DatabaseMigratorTests : GRDBTestCase { } let dbQueue = try makeDatabaseQueue() try migrator.migrate(dbQueue) - + // When we add an internal schema object (sqlite_stat1) try dbQueue.write { db in - try db.execute(sql: """ - INSERT INTO t DEFAULT VALUES; - ANALYZE; - """) + try db.execute( + sql: """ + INSERT INTO t DEFAULT VALUES; + ANALYZE; + """) try XCTAssertTrue(db.tableExists("sqlite_stat1")) } - + // Then 2nd migration does not erase database try XCTAssertFalse(dbQueue.read(migrator.hasSchemaChanges)) try migrator.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t") }, 1) } - + func testEraseDatabaseOnSchemaChangeWithRenamedMigration() throws { let dbQueue = try makeDatabaseQueue() - + // 1st migration var migrator1 = DatabaseMigrator() migrator1.registerMigration("1") { db in - try db.execute(sql: """ - CREATE TABLE t1(id INTEGER PRIMARY KEY); - INSERT INTO t1(id) VALUES (1) - """) + try db.execute( + sql: """ + CREATE TABLE t1(id INTEGER PRIMARY KEY); + INSERT INTO t1(id) VALUES (1) + """) } try migrator1.migrate(dbQueue) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 1) - + // 2nd migration does not erase database var migrator2 = DatabaseMigrator() migrator2.eraseDatabaseOnSchemaChange = true migrator2.registerMigration("2") { db in - try db.execute(sql: """ - CREATE TABLE t1(id INTEGER PRIMARY KEY); - INSERT INTO t1(id) VALUES (2) - """) + try db.execute( + sql: """ + CREATE TABLE t1(id INTEGER PRIMARY KEY); + INSERT INTO t1(id) VALUES (2) + """) } try XCTAssertTrue(dbQueue.read(migrator2.hasSchemaChanges)) try migrator2.migrate(dbQueue) try XCTAssertFalse(dbQueue.read(migrator2.hasSchemaChanges)) try XCTAssertEqual(dbQueue.read { try Int.fetchOne($0, sql: "SELECT id FROM t1") }, 2) } - + func testMigrations() throws { do { let migrator = DatabaseMigrator() @@ -960,7 +1050,7 @@ class DatabaseMigratorTests : GRDBTestCase { XCTAssertEqual(migrator.migrations, ["foo", "bar"]) } } - + func testMigrationForeignKeyChecks() throws { let foreignKeyViolation = """ CREATE TABLE parent(id INTEGER NOT NULL PRIMARY KEY); @@ -973,7 +1063,7 @@ class DatabaseMigratorTests : GRDBTestCase { INSERT INTO child (parentId) VALUES (1); DELETE FROM child; """ - + // Foreign key violation do { var migrator = DatabaseMigrator() @@ -984,7 +1074,7 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} try dbQueue.read { try $0.checkForeignKeys() } } do { @@ -996,10 +1086,10 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} try dbQueue.read { try $0.checkForeignKeys() } } - + // Transient foreign key violation do { var migrator = DatabaseMigrator() @@ -1019,11 +1109,11 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} try dbQueue.read { try $0.checkForeignKeys() } } } - + func testDisablingDeferredForeignKeyChecks() throws { let foreignKeyViolation = """ CREATE TABLE parent(id INTEGER NOT NULL PRIMARY KEY); @@ -1036,7 +1126,7 @@ class DatabaseMigratorTests : GRDBTestCase { INSERT INTO child (parentId) VALUES (1); DELETE FROM child; """ - + // Foreign key violation do { var migrator = DatabaseMigrator().disablingDeferredForeignKeyChecks() @@ -1049,7 +1139,7 @@ class DatabaseMigratorTests : GRDBTestCase { // The unique opportunity for corrupt data! try dbQueue.read { try $0.checkForeignKeys() } XCTFail("Expected foreign key violation") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} } do { var migrator = DatabaseMigrator().disablingDeferredForeignKeyChecks() @@ -1060,10 +1150,10 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} try dbQueue.read { try $0.checkForeignKeys() } } - + // Transient foreign key violation do { var migrator = DatabaseMigrator().disablingDeferredForeignKeyChecks() @@ -1083,18 +1173,19 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} try dbQueue.read { try $0.checkForeignKeys() } } } - - func test_disablingDeferredForeignKeyChecks_applies_to_newly_registered_migrations_only() throws { + + func test_disablingDeferredForeignKeyChecks_applies_to_newly_registered_migrations_only() throws + { let foreignKeyViolation = """ CREATE TABLE parent(id INTEGER NOT NULL PRIMARY KEY); CREATE TABLE child(parentId INTEGER REFERENCES parent(id)); INSERT INTO child (parentId) VALUES (1); """ - + var migrator = DatabaseMigrator() migrator.registerMigration("A") { db in try db.execute(sql: foreignKeyViolation) @@ -1107,20 +1198,22 @@ class DatabaseMigratorTests : GRDBTestCase { do { try migrator.migrate(dbQueue) XCTFail("Expected error") - } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY { } + } catch DatabaseError.SQLITE_CONSTRAINT_FOREIGNKEY {} } - + func test_schemaSource_is_disabled_during_migrations() throws { struct SchemaSource: DatabaseSchemaSource { - func columnsForPrimaryKey(_ db: Database, inView view: DatabaseObjectID) throws -> [String]? { + func columnsForPrimaryKey(_ db: Database, inView view: DatabaseObjectID) throws + -> [String]? + { ["id"] } } - + dbConfiguration.schemaSource = SchemaSource() let dbQueue = try makeDatabaseQueue() var migrator = DatabaseMigrator() - + do { migrator.registerMigration("A") { db in try db.execute(sql: "CREATE VIEW myView as SELECT 1 AS id") @@ -1130,13 +1223,13 @@ class DatabaseMigratorTests : GRDBTestCase { } try migrator.migrate(dbQueue) } - + try dbQueue.inDatabase { db in // Cache was cleared, and schemaSource is active. XCTAssertNotNil(db.schemaSource) XCTAssertNoThrow(try db.primaryKey("myView")) } - + do { migrator.registerMigration("B") { db in // Cache was cleared again, and schemaSource is disabled. @@ -1146,18 +1239,20 @@ class DatabaseMigratorTests : GRDBTestCase { try migrator.migrate(dbQueue) } } - + func test_schemaSource_can_be_restored_during_migrations() throws { struct SchemaSource: DatabaseSchemaSource { - func columnsForPrimaryKey(_ db: Database, inView view: DatabaseObjectID) throws -> [String]? { + func columnsForPrimaryKey(_ db: Database, inView view: DatabaseObjectID) throws + -> [String]? + { ["id"] } } - + dbConfiguration.schemaSource = SchemaSource() let dbQueue = try makeDatabaseQueue() var migrator = DatabaseMigrator() - + migrator.registerMigration("A") { db in try db.execute(sql: "CREATE VIEW myView as SELECT 1 AS id") try db.withSchemaSource(SchemaSource()) { @@ -1166,7 +1261,7 @@ class DatabaseMigratorTests : GRDBTestCase { } try migrator.migrate(dbQueue) } - + func test_merged_migrations_named_like_the_last() throws { // Original migrator var oldMigrator = DatabaseMigrator() @@ -1185,7 +1280,7 @@ class DatabaseMigratorTests : GRDBTestCase { oldMigrator.registerMigration("v5") { db in try db.execute(sql: "CREATE TABLE t5(a)") } - + // New migrator merges v2, v3, and v4 into v4 var newMigrator = DatabaseMigrator() newMigrator.registerMigration("v1") { db in @@ -1203,85 +1298,91 @@ class DatabaseMigratorTests : GRDBTestCase { newMigrator.registerMigration("v5") { db in try db.execute(sql: "CREATE TABLE t5(a)") } - + do { let dbQueue = try makeDatabaseQueue() - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v1") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v2") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v3") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v4") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v5") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } } - + func test_merged_migrations_with_a_new_name() throws { // Original migrator var oldMigrator = DatabaseMigrator() @@ -1300,7 +1401,7 @@ class DatabaseMigratorTests : GRDBTestCase { oldMigrator.registerMigration("v5") { db in try db.execute(sql: "CREATE TABLE t5(a)") } - + // New migrator merges v2, v3, and v4 into v4bis var newMigrator = DatabaseMigrator() newMigrator.registerMigration("v1") { db in @@ -1320,81 +1421,87 @@ class DatabaseMigratorTests : GRDBTestCase { newMigrator.registerMigration("v5") { db in try db.execute(sql: "CREATE TABLE t5(a)") } - + do { let dbQueue = try makeDatabaseQueue() - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v1") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v2") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v3") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v4") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } - + do { let dbQueue = try makeDatabaseQueue() try oldMigrator.migrate(dbQueue, upTo: "v5") - + try newMigrator.migrate(dbQueue) try dbQueue.read { db in try XCTAssertEqual(newMigrator.appliedIdentifiers(db), ["v1", "v4bis", "v5"]) - try XCTAssertTrue(String - .fetchSet(db, sql: "SELECT name FROM sqlite_master") - .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) + try XCTAssertTrue( + String + .fetchSet(db, sql: "SELECT name FROM sqlite_master") + .isSuperset(of: ["t1", "t2", "t3", "t4", "t5"])) } } } diff --git a/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift b/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift index 7d2478545b..25fb277e48 100644 --- a/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift +++ b/Tests/GRDBTests/DatabasePoolConcurrencyTests.swift @@ -1,10 +1,11 @@ -import XCTest import Dispatch import Foundation +import XCTest + @testable import GRDB class DatabasePoolConcurrencyTests: GRDBTestCase { - + func testDatabasePoolFundamental1() throws { // Constraint: the sum of values, the balance, must remain zero. // @@ -24,7 +25,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s4 = DispatchSemaphore(value: 0) // SELECT SUM(value) AS balance FROM moves // END - + do { let dbQueue = try makeDatabaseQueue(filename: "test.sqlite") try dbQueue.writeWithoutTransaction { db in @@ -37,20 +38,22 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s0 = DispatchSemaphore(value: 0) let block1 = { () in let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") - s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time + s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time try! dbQueue.writeWithoutTransaction { db in try db.execute(sql: "BEGIN DEFERRED TRANSACTION") s1.signal() _ = s2.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 1) // Non-zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 1) // Non-zero balance s3.signal() _ = s4.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 1) // Non-zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 1) // Non-zero balance try db.execute(sql: "END") } } let block2 = { () in - _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time + _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") try! dbQueue.writeWithoutTransaction { db in _ = s1.wait(timeout: .distantFuture) @@ -66,7 +69,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testDatabasePoolFundamental2() throws { // Constraint: the sum of values, the balance, must remain zero. // @@ -86,7 +89,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s5 = DispatchSemaphore(value: 0) // SELECT SUM(value) AS balance FROM moves // END END - + do { let dbQueue = try makeDatabaseQueue(filename: "test.sqlite") try dbQueue.writeWithoutTransaction { db in @@ -99,21 +102,23 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s0 = DispatchSemaphore(value: 0) let block1 = { () in let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") - s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time + s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time try! dbQueue.writeWithoutTransaction { db in _ = s1.wait(timeout: .distantFuture) try db.execute(sql: "BEGIN DEFERRED TRANSACTION") s2.signal() _ = s3.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance s4.signal() _ = s5.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance try db.execute(sql: "END") } } let block2 = { () in - _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time + _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") try! dbQueue.writeWithoutTransaction { db in try db.execute(sql: "BEGIN DEFERRED TRANSACTION") @@ -132,7 +137,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testDatabasePoolFundamental3() throws { // Constraint: the sum of values, the balance, must remain zero. // @@ -151,7 +156,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s4 = DispatchSemaphore(value: 0) // SELECT SUM(value) AS balance FROM moves END // END - + do { let dbQueue = try makeDatabaseQueue(filename: "test.sqlite") try dbQueue.writeWithoutTransaction { db in @@ -164,20 +169,22 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s0 = DispatchSemaphore(value: 0) let block1 = { () in let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") - s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time + s0.signal() // Avoid "database is locked" error: don't open the two databases at the same time try! dbQueue.writeWithoutTransaction { db in try db.execute(sql: "BEGIN DEFERRED TRANSACTION") s1.signal() _ = s2.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance s3.signal() _ = s4.wait(timeout: .distantFuture) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance + XCTAssertEqual( + try Int.fetchOne(db, sql: "SELECT SUM(value) AS balance FROM moves"), 0) // Zero balance try db.execute(sql: "END") } } let block2 = { () in - _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time + _ = s0.wait(timeout: .distantFuture) // Avoid "database is locked" error: don't open the two databases at the same time let dbQueue = try! self.makeDatabaseQueue(filename: "test.sqlite") try! dbQueue.writeWithoutTransaction { db in _ = s1.wait(timeout: .distantFuture) @@ -195,7 +202,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testWrappedReadWrite() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -207,7 +214,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { } XCTAssertEqual(id, 1) } - + func testReadFromPreviousNonWALDatabase() throws { do { let dbQueue = try makeDatabaseQueue(filename: "test.sqlite") @@ -224,7 +231,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { XCTAssertEqual(id, 1) } } - + func testWriteOpensATransaction() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -236,11 +243,14 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { XCTAssertEqual(error.resultCode, .SQLITE_ERROR) XCTAssertEqual(error.message!, "cannot start a transaction within a transaction") XCTAssertEqual(error.sql!, "BEGIN DEFERRED TRANSACTION") - XCTAssertEqual(error.description, "SQLite error 1: cannot start a transaction within a transaction - while executing `BEGIN DEFERRED TRANSACTION`") + XCTAssertEqual( + error.description, + "SQLite error 1: cannot start a transaction within a transaction - while executing `BEGIN DEFERRED TRANSACTION`" + ) } } } - + func testWriteWithoutTransactionDoesNotOpenATransaction() throws { let dbPool = try makeDatabasePool() try dbPool.writeWithoutTransaction { db in @@ -249,7 +259,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.commit() } } - + func testReadOpensATransaction() throws { let dbPool = try makeDatabasePool() try dbPool.read { db in @@ -261,23 +271,26 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { XCTAssertEqual(error.resultCode, .SQLITE_ERROR) XCTAssertEqual(error.message!, "cannot start a transaction within a transaction") XCTAssertEqual(error.sql!, "BEGIN DEFERRED TRANSACTION") - XCTAssertEqual(error.description, "SQLite error 1: cannot start a transaction within a transaction - while executing `BEGIN DEFERRED TRANSACTION`") + XCTAssertEqual( + error.description, + "SQLite error 1: cannot start a transaction within a transaction - while executing `BEGIN DEFERRED TRANSACTION`" + ) } } } - + func testReadError() throws { // Necessary for this test to run as quickly as possible dbConfiguration.readonlyBusyMode = .immediateError let dbPool = try makeDatabasePool() - + // Block 1 Block 2 // PRAGMA locking_mode=EXCLUSIVE // CREATE TABLE // > let s1 = DispatchSemaphore(value: 0) // dbPool.read // throws - + let block1 = { () in try! dbPool.writeWithoutTransaction { db in try db.execute(sql: "PRAGMA locking_mode=EXCLUSIVE") @@ -302,7 +315,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testConcurrentRead() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -311,7 +324,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") } } - + // Block 1 Block 2 // dbPool.read { dbPool.read { // SELECT * FROM items SELECT * FROM items @@ -325,7 +338,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // step end // end } // } - + let block1 = { () in try! dbPool.read { db in let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM items") @@ -353,7 +366,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadMethodIsolationOfStatement() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -362,7 +375,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") } } - + // Block 1 Block 2 // dbPool.read { // SELECT * FROM items @@ -375,7 +388,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // step // end // } - + let block1 = { () in try! dbPool.read { db in let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM items") @@ -402,7 +415,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadMethodIsolationOfStatementWithCheckpoint() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -411,7 +424,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") } } - + // Block 1 Block 2 // dbPool.read { // SELECT * FROM items @@ -425,7 +438,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // step // end // } - + let block1 = { () in try! dbPool.read { db in let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM items") @@ -453,13 +466,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadBlockIsolationStartingWithRead() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // dbPool.read { // > @@ -475,7 +488,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s4 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 0 // } - + let block1 = { () in try! dbPool.read { db in s1.signal() @@ -508,13 +521,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadBlockIsolationStartingWithSelect() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // dbPool.read { // SELECT COUNT(*) FROM items -> 0 @@ -526,7 +539,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s2 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 0 // } - + let block1 = { () in try! dbPool.read { db in XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 0) @@ -552,13 +565,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadBlockIsolationStartingWithWrite() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // INSERT INTO items (id) VALUES (NULL) // > @@ -577,7 +590,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s5 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 1 // } - + let block1 = { () in do { try dbPool.writeWithoutTransaction { db in @@ -610,13 +623,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testReadBlockIsolationStartingWithWriteTransaction() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // BEGIN IMMEDIATE TRANSACTION // INSERT INTO items (id) VALUES (NULL) @@ -637,7 +650,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s5 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 0 // } - + let block1 = { () in do { try dbPool.writeInTransaction(.immediate) { db in @@ -671,7 +684,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testUnsafeReadMethodIsolationOfStatement() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -680,7 +693,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") } } - + // Block 1 Block 2 // dbPool.unsafeRead { // SELECT * FROM items @@ -694,7 +707,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // end // SELECT COUNT(*) FROM items -> 0 // } - + let block1 = { () in try! dbPool.unsafeRead { db in let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM items") @@ -722,7 +735,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testUnsafeReadMethodIsolationOfStatementWithCheckpoint() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -731,7 +744,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") } } - + // Block 1 Block 2 // dbPool.unsafeRead { // SELECT * FROM items @@ -745,7 +758,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // step // end // } - + let block1 = { () in try! dbPool.unsafeRead { db in let cursor = try Row.fetchCursor(db, sql: "SELECT * FROM items") @@ -773,13 +786,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testUnsafeReadMethodIsolationOfBlock() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // dbPool.unsafeRead { // SELECT COUNT(*) FROM items -> 0 @@ -791,7 +804,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s2 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 1 // } - + let block1 = { () in try! dbPool.unsafeRead { db in XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM items")!, 0) @@ -817,7 +830,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testLongRunningReadTransaction() throws { // A test for a "user-defined" DatabaseSnapshot based on DatabaseQueue let dbName = "test.sqlite" @@ -825,31 +838,31 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { dbConfiguration.allowsUnsafeTransactions = true dbConfiguration.readonly = true let dbQueue = try makeDatabaseQueue(filename: dbName) - + try dbPool.write { db in try db.create(table: "t") { $0.primaryKey("id", .integer) } try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } - + try dbQueue.writeWithoutTransaction { db in try db.beginTransaction(.deferred) } - + try dbQueue.writeWithoutTransaction { db in try XCTAssertEqual(Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")!, 1) } - + try dbPool.write { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } - + try dbQueue.writeWithoutTransaction { db in try XCTAssertEqual(Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")!, 1) try db.rollback() try XCTAssertEqual(Int.fetchOne(db, sql: "SELECT COUNT(*) FROM t")!, 2) } } - + func testIssue80() throws { // See https://github.com/groue/GRDB.swift/issues/80 // @@ -861,7 +874,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // search virtual table, and another connection for reading. // // This is the tested setup: don't change it. - + let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE VIRTUAL TABLE search USING fts3(title)") @@ -870,31 +883,35 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { _ = try Row.fetchAll(db, sql: "SELECT * FROM search") } } - + func testDefaultLabel() throws { let dbPool = try makeDatabasePool() dbPool.writeWithoutTransaction { db in XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabasePool.writer") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabasePool.writer") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabasePool.writer") + #endif } - + let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let block1 = { () in try! dbPool.read { db in XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabasePool.reader.1") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabasePool.reader.1") - + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabasePool.reader.1") + #endif + _ = s1.signal() _ = s2.wait(timeout: .distantFuture) } @@ -905,11 +922,14 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { _ = s2.signal() XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabasePool.reader.2") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabasePool.reader.2") + + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabasePool.reader.2") + #endif } } let blocks = [block1, block2] @@ -917,32 +937,37 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testCustomLabel() throws { dbConfiguration.label = "Toreador" let dbPool = try makeDatabasePool() dbPool.writeWithoutTransaction { db in XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador.writer") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador.writer") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador.writer") + #endif } - + let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let block1 = { () in try! dbPool.read { db in XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador.reader.1") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador.reader.1") - + + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador.reader.1") + #endif + _ = s1.signal() _ = s2.wait(timeout: .distantFuture) } @@ -953,11 +978,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { _ = s2.signal() XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador.reader.2") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador.reader.2") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador.reader.2") + #endif } } let blocks = [block1, block2] @@ -965,7 +992,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { blocks[index]() } } - + func testTargetQueue() throws { func test(targetQueue: DispatchQueue) throws { dbConfiguration.targetQueue = targetQueue @@ -977,10 +1004,10 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(targetQueue)) } } - + // background queue try test(targetQueue: .global(qos: .background)) - + // main queue let expectation = self.expectation(description: "main") DispatchQueue.global(qos: .default).async { @@ -989,7 +1016,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { } waitForExpectations(timeout: 2, handler: nil) } - + func testWriteTargetQueue() throws { func test(targetQueue: DispatchQueue, writeTargetQueue: DispatchQueue) throws { dbConfiguration.targetQueue = targetQueue @@ -1002,10 +1029,12 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(targetQueue)) } } - + // background queue - try test(targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer")) - + try test( + targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer") + ) + // main queue let expectation = self.expectation(description: "main") DispatchQueue.global(qos: .default).async { @@ -1014,15 +1043,15 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { } waitForExpectations(timeout: 2, handler: nil) } - + func testWriteTargetQueueReadOnly() throws { // Create a database before we perform read-only accesses _ = try makeDatabasePool(filename: "test") - + func test(targetQueue: DispatchQueue, writeTargetQueue: DispatchQueue) throws { dbConfiguration.readonly = true dbConfiguration.targetQueue = targetQueue - dbConfiguration.writeTargetQueue = writeTargetQueue // unused + dbConfiguration.writeTargetQueue = writeTargetQueue // unused let dbPool = try makeDatabasePool(filename: "test") try dbPool.write { _ in // Not on writeTargetQueue because read-only @@ -1032,50 +1061,58 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(targetQueue)) } } - - try test(targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer")) + + try test( + targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer") + ) } func testQoS() throws { - func test(qos: DispatchQoS) throws { - // https://forums.swift.org/t/what-is-the-default-target-queue-for-a-serial-queue/18094/5 - // - // > [...] the default target queue [for a serial queue] is the - // > [default] overcommit [global concurrent] queue. - // - // We want this default target queue in order to test database QoS - // with dispatchPrecondition(condition:). - // - // > [...] You can get a reference to the overcommit queue by - // > dropping down to the C function dispatch_get_global_queue - // > (available in Swift with a __ prefix) and passing the private - // > value of DISPATCH_QUEUE_OVERCOMMIT. - // > - // > [...] Of course you should not do this in production code, - // > because DISPATCH_QUEUE_OVERCOMMIT is not a public API. I don't - // > know of a way to get a reference to the overcommit queue using - // > only public APIs. - let DISPATCH_QUEUE_OVERCOMMIT: UInt = 2 - let targetQueue = __dispatch_get_global_queue( - Int(qos.qosClass.rawValue.rawValue), - DISPATCH_QUEUE_OVERCOMMIT) - - dbConfiguration.qos = qos - let dbPool = try makeDatabasePool() - try dbPool.write { _ in - dispatchPrecondition(condition: .onQueue(targetQueue)) - } - try dbPool.read { _ in - dispatchPrecondition(condition: .onQueue(targetQueue)) + #if !canImport(Darwin) + throw XCTSkip("__dispatch_get_global_queue unavailable") + #else + + func test(qos: DispatchQoS) throws { + // https://forums.swift.org/t/what-is-the-default-target-queue-for-a-serial-queue/18094/5 + // + // > [...] the default target queue [for a serial queue] is the + // > [default] overcommit [global concurrent] queue. + // + // We want this default target queue in order to test database QoS + // with dispatchPrecondition(condition:). + // + // > [...] You can get a reference to the overcommit queue by + // > dropping down to the C function dispatch_get_global_queue + // > (available in Swift with a __ prefix) and passing the private + // > value of DISPATCH_QUEUE_OVERCOMMIT. + // > + // > [...] Of course you should not do this in production code, + // > because DISPATCH_QUEUE_OVERCOMMIT is not a public API. I don't + // > know of a way to get a reference to the overcommit queue using + // > only public APIs. + let DISPATCH_QUEUE_OVERCOMMIT: UInt = 2 + + let targetQueue = __dispatch_get_global_queue( + Int(qos.qosClass.rawValue.rawValue), + DISPATCH_QUEUE_OVERCOMMIT) + + dbConfiguration.qos = qos + let dbPool = try makeDatabasePool() + try dbPool.write { _ in + dispatchPrecondition(condition: .onQueue(targetQueue)) + } + try dbPool.read { _ in + dispatchPrecondition(condition: .onQueue(targetQueue)) + } } - } - - try test(qos: .background) - try test(qos: .userInitiated) + + try test(qos: .background) + try test(qos: .userInitiated) + #endif } - + // MARK: - AsyncConcurrentRead - + func testAsyncConcurrentReadOpensATransaction() throws { let dbPool = try makeDatabasePool() let isInsideTransactionMutex: Mutex = Mutex(nil) @@ -1099,7 +1136,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(isInsideTransactionMutex.load(), true) } - + func testAsyncConcurrentReadOutsideOfTransaction() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -1107,7 +1144,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { t.primaryKey("id", .integer) } } - + // Writer Reader // dbPool.writeWithoutTransaction { // > @@ -1119,7 +1156,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // } SELECT COUNT(*) FROM persons -> 0 // < // } - + let countMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "read") try dbPool.writeWithoutTransaction { db in @@ -1139,7 +1176,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(countMutex.load(), 0) } - + func testAsyncConcurrentReadError() throws { // Necessary for this test to run as quickly as possible dbConfiguration.readonlyBusyMode = .immediateError @@ -1150,11 +1187,11 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { try db.execute(sql: "PRAGMA locking_mode=EXCLUSIVE") try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") dbPool.asyncConcurrentRead { dbResult in - guard case let .failure(error) = dbResult, + guard case .failure(let error) = dbResult, let dbError = error as? DatabaseError - else { - XCTFail("Unexpected result: \(dbResult)") - return + else { + XCTFail("Unexpected result: \(dbResult)") + return } readErrorMutex.store(dbError) expectation.fulfill() @@ -1164,13 +1201,13 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { XCTAssertEqual(readErrorMutex.load()!.message!, "database is locked") } } - + // MARK: - Barrier - + func testBarrierLocksReads() throws { let expectation = self.expectation(description: "lock") expectation.isInverted = true - + let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") @@ -1179,7 +1216,7 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let s3 = DispatchSemaphore(value: 0) - + DispatchQueue.global().async { try! dbPool.barrierWriteWithoutTransaction { db in try db.execute(sql: "INSERT INTO items (id) VALUES (NULL)") @@ -1187,43 +1224,43 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { s2.wait() } } - + DispatchQueue.global().async { // Wait for barrier to start s1.wait() - + fetchedValue = try! dbPool.read { db in try Int.fetchOne(db, sql: "SELECT id FROM items")! } expectation.fulfill() s3.signal() } - + // Assert that read is blocked waitForExpectations(timeout: 1) - + // Release barrier s2.signal() - + // Wait for read to complete s3.wait() XCTAssertEqual(fetchedValue, 1) } - + func testBarrierIsLockedByOneUnfinishedRead() throws { let expectation = self.expectation(description: "lock") expectation.isInverted = true - + let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + let s1 = DispatchSemaphore(value: 0) let s2 = DispatchSemaphore(value: 0) let s3 = DispatchSemaphore(value: 0) let s4 = DispatchSemaphore(value: 0) - + DispatchQueue.global().async { try! dbPool.read { _ in s1.signal() @@ -1231,76 +1268,90 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { s3.wait() } } - + DispatchQueue.global().async { // Wait for read to start s1.wait() - + try! dbPool.barrierWriteWithoutTransaction { _ in } expectation.fulfill() s4.signal() } - + // Assert that barrier is blocked waitForExpectations(timeout: 1) - + // Release read s3.signal() - + // Wait for barrier to complete s4.wait() } - + // MARK: - Concurrent opening - + func testConcurrentOpening() throws { - for _ in 0..<50 { - let dbDirectoryName = "DatabasePoolConcurrencyTests-\(ProcessInfo.processInfo.globallyUniqueString)" - let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent(dbDirectoryName, isDirectory: true) - let dbURL = directoryURL.appendingPathComponent("db.sqlite") - try FileManager.default.createDirectory(atPath: directoryURL.path, withIntermediateDirectories: true, attributes: nil) - defer { try! FileManager.default.removeItem(at: directoryURL) } - DispatchQueue.concurrentPerform(iterations: 10) { n in - // WTF I could never Google for the proper correct error handling - // of NSFileCoordinator. What a weird API. - let coordinator = NSFileCoordinator(filePresenter: nil) - var coordinatorError: NSError? - var poolError: Error? - coordinator.coordinate(writingItemAt: dbURL, options: .forMerging, error: &coordinatorError) { url in - do { - _ = try DatabasePool(path: url.path) - } catch { - poolError = error + #if !canImport(Darwin) + throw XCTSkip("NSFileCoordinator unavailable") + #else + for _ in 0..<50 { + let dbDirectoryName = + "DatabasePoolConcurrencyTests-\(ProcessInfo.processInfo.globallyUniqueString)" + let directoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(dbDirectoryName, isDirectory: true) + let dbURL = directoryURL.appendingPathComponent("db.sqlite") + try FileManager.default.createDirectory( + atPath: directoryURL.path, withIntermediateDirectories: true, attributes: nil) + defer { try! FileManager.default.removeItem(at: directoryURL) } + DispatchQueue.concurrentPerform(iterations: 10) { n in + // WTF I could never Google for the proper correct error handling + // of NSFileCoordinator. What a weird API. + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinatorError: NSError? + var poolError: Error? + coordinator.coordinate( + writingItemAt: dbURL, options: .forMerging, error: &coordinatorError + ) { url in + do { + _ = try DatabasePool(path: url.path) + } catch { + poolError = error + } } + XCTAssert(poolError ?? coordinatorError == nil) } - XCTAssert(poolError ?? coordinatorError == nil) } - } + #endif } - + // MARK: - NSFileCoordinator sample code tests - + // Test for sample code in Documentation.docc/DatabaseSharing.md. // This test passes if this method compiles private func openSharedDatabase(at databaseURL: URL) throws -> DatabasePool { - let coordinator = NSFileCoordinator(filePresenter: nil) - var coordinatorError: NSError? - var dbPool: DatabasePool? - var dbError: Error? - coordinator.coordinate(writingItemAt: databaseURL, options: .forMerging, error: &coordinatorError) { url in - do { - dbPool = try openDatabase(at: url) - } catch { - dbError = error + #if !canImport(Darwin) + throw XCTSkip("NSFileCoordinator unavailable") + #else + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinatorError: NSError? + var dbPool: DatabasePool? + var dbError: Error? + coordinator.coordinate( + writingItemAt: databaseURL, options: .forMerging, error: &coordinatorError + ) { url in + do { + dbPool = try openDatabase(at: url) + } catch { + dbError = error + } } - } - if let error = dbError ?? coordinatorError { - throw error - } - return dbPool! + if let error = dbError ?? coordinatorError { + throw error + } + return dbPool! + #endif } - + // Test for sample code in Documentation.docc/DatabaseSharing.md. // This test passes if this method compiles private func openDatabase(at databaseURL: URL) throws -> DatabasePool { @@ -1309,27 +1360,33 @@ class DatabasePoolConcurrencyTests: GRDBTestCase { // the database schema with a DatabaseMigrator. return dbPool } - + // Test for sample code in Documentation.docc/DatabaseSharing.md. // This test passes if this method compiles private func openSharedReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { - let coordinator = NSFileCoordinator(filePresenter: nil) - var coordinatorError: NSError? - var dbPool: DatabasePool? - var dbError: Error? - coordinator.coordinate(readingItemAt: databaseURL, options: .withoutChanges, error: &coordinatorError) { url in - do { - dbPool = try openReadOnlyDatabase(at: url) - } catch { - dbError = error + #if !canImport(Darwin) + throw XCTSkip("NSFileCoordinator unavailable") + #else + let coordinator = NSFileCoordinator(filePresenter: nil) + var coordinatorError: NSError? + var dbPool: DatabasePool? + var dbError: Error? + coordinator.coordinate( + readingItemAt: databaseURL, options: .withoutChanges, error: &coordinatorError + ) { url in + do { + dbPool = try openReadOnlyDatabase(at: url) + } catch { + dbError = error + } } - } - if let error = dbError ?? coordinatorError { - throw error - } - return dbPool + if let error = dbError ?? coordinatorError { + throw error + } + return dbPool + #endif } - + // Test for sample code in Documentation.docc/DatabaseSharing.md. // This test passes if this method compiles private func openReadOnlyDatabase(at databaseURL: URL) throws -> DatabasePool? { diff --git a/Tests/GRDBTests/DatabasePoolTests.swift b/Tests/GRDBTests/DatabasePoolTests.swift index e1ab57bbd5..13c44d8988 100644 --- a/Tests/GRDBTests/DatabasePoolTests.swift +++ b/Tests/GRDBTests/DatabasePoolTests.swift @@ -1,15 +1,15 @@ +import GRDB +import XCTest + // Import C SQLite functions #if SWIFT_PACKAGE -import GRDBSQLite + import GRDBSQLite #elseif GRDBCIPHER -import SQLCipher + import SQLCipher #elseif !GRDBCUSTOMSQLITE && !GRDBCIPHER -import SQLite3 + import SQLite3 #endif -import XCTest -import GRDB - class DatabasePoolTests: GRDBTestCase { func testJournalModeConfiguration() throws { do { @@ -42,7 +42,7 @@ class DatabasePoolTests: GRDBTestCase { XCTAssertEqual(journalMode, "wal") } } - + func testDatabasePoolCreatesWalShm() throws { let dbPool = try makeDatabasePool(filename: "test") try withExtendedLifetime(dbPool) { @@ -50,16 +50,16 @@ class DatabasePoolTests: GRDBTestCase { XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-wal")) XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-shm")) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - // A non-empty wal file makes sure ValueObservation can use wal snapshots. - // See - let walURL = URL(fileURLWithPath: dbPool.path + "-wal") - let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! - XCTAssertGreaterThan(walSize, 0) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // A non-empty wal file makes sure ValueObservation can use wal snapshots. + // See + let walURL = URL(fileURLWithPath: dbPool.path + "-wal") + let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! + XCTAssertGreaterThan(walSize, 0) + #endif } } - + func testDatabasePoolCreatesWalShmFromNonWalDatabase() throws { do { let dbQueue = try makeDatabaseQueue(filename: "test") @@ -73,14 +73,14 @@ class DatabasePoolTests: GRDBTestCase { let fm = FileManager() XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-wal")) XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-shm")) - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - // A non-empty wal file makes sure ValueObservation can use wal snapshots. - // See - let walURL = URL(fileURLWithPath: dbPool.path + "-wal") - let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! - XCTAssertGreaterThan(walSize, 0) -#endif + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // A non-empty wal file makes sure ValueObservation can use wal snapshots. + // See + let walURL = URL(fileURLWithPath: dbPool.path + "-wal") + let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! + XCTAssertGreaterThan(walSize, 0) + #endif } } } @@ -99,14 +99,14 @@ class DatabasePoolTests: GRDBTestCase { let fm = FileManager() XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-wal")) XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-shm")) - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - // A non-empty wal file makes sure ValueObservation can use wal snapshots. - // See - let walURL = URL(fileURLWithPath: dbPool.path + "-wal") - let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! - XCTAssertGreaterThan(walSize, 0) -#endif + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // A non-empty wal file makes sure ValueObservation can use wal snapshots. + // See + let walURL = URL(fileURLWithPath: dbPool.path + "-wal") + let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! + XCTAssertGreaterThan(walSize, 0) + #endif } } } @@ -114,30 +114,32 @@ class DatabasePoolTests: GRDBTestCase { func testDatabasePoolCreatesWalShmFromIssue1383() 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")) + 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) try withExtendedLifetime(dbPool) { let fm = FileManager() XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-wal")) XCTAssertTrue(fm.fileExists(atPath: dbPool.path + "-shm")) - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - // A non-empty wal file makes sure ValueObservation can use wal snapshots. - // See - let walURL = URL(fileURLWithPath: dbPool.path + "-wal") - let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! - XCTAssertGreaterThan(walSize, 0) -#endif + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // A non-empty wal file makes sure ValueObservation can use wal snapshots. + // See + let walURL = URL(fileURLWithPath: dbPool.path + "-wal") + let walSize = try walURL.resourceValues(forKeys: [.fileSizeKey]).fileSize! + XCTAssertGreaterThan(walSize, 0) + #endif } } - + func testCanReadFromNewInstance() throws { let dbPool = try makeDatabasePool() try dbPool.read { _ in } } - + func testCanReadFromTruncatedWalFile() throws { do { let dbPool = try makeDatabasePool(filename: "test") @@ -152,7 +154,7 @@ class DatabasePoolTests: GRDBTestCase { XCTAssertEqual(count, 0) } } - + func testPersistentWALModeEnabled() throws { let path: String do { @@ -173,7 +175,7 @@ class DatabasePoolTests: GRDBTestCase { XCTAssertTrue(fm.fileExists(atPath: path + "-wal")) XCTAssertTrue(fm.fileExists(atPath: path + "-shm")) } - + func testPersistentWALModeDisabled() throws { let path: String do { @@ -194,7 +196,7 @@ class DatabasePoolTests: GRDBTestCase { XCTAssertFalse(fm.fileExists(atPath: path + "-wal")) XCTAssertFalse(fm.fileExists(atPath: path + "-shm")) } - + // Regression test func testIssue931() throws { dbConfiguration.prepareDatabase { db in @@ -207,122 +209,122 @@ class DatabasePoolTests: GRDBTestCase { } } let dbQueue = try makeDatabaseQueue() - + var migrator = DatabaseMigrator() migrator.registerMigration("v1", migrate: { _ in }) migrator.eraseDatabaseOnSchemaChange = true try migrator.migrate(dbQueue) - + // Trigger #931: the migrator creates a temporary database and // calls `sqlite3_file_control` as part of the preparation function. try migrator.migrate(dbQueue) } - + func testNumberOfThreads_asyncUnsafeRead() throws { -#if SWIFT_PACKAGE - // Can't access getThreadsCount() C function - throw XCTSkip("Thread count is not available") -#else - if getThreadsCount() < 0 { + #if SWIFT_PACKAGE + // Can't access getThreadsCount() C function throw XCTSkip("Thread count is not available") - } - - let pool = try makeDatabasePool() - - // Keep this number big, so that we have a good chance to detect - // thread explosion. - let numberOfConcurrentReads = 10000 - - // Wait for all concurrent reads to end - let group = DispatchGroup() - - // The maximum number of threads we could witness - let maxThreadCountMutex: Mutex = Mutex(0) - for _ in (0.. = Mutex(0) + for _ in (0.. = Mutex(0) - for _ in (0.. = Mutex(0) + for _ in (0.. func test_releaseMemory_after_close() throws { let dbPool = try makeDatabasePool() - try dbPool.read { _ in } // Create a reader + try dbPool.read { _ in } // Create a reader try dbPool.close() dbPool.releaseMemory() } diff --git a/Tests/GRDBTests/DatabaseQueueTests.swift b/Tests/GRDBTests/DatabaseQueueTests.swift index c22be83261..f725dadc87 100644 --- a/Tests/GRDBTests/DatabaseQueueTests.swift +++ b/Tests/GRDBTests/DatabaseQueueTests.swift @@ -1,6 +1,6 @@ -import XCTest import Dispatch import GRDB +import XCTest class DatabaseQueueTests: GRDBTestCase { func testJournalModeConfiguration() throws { @@ -34,7 +34,7 @@ class DatabaseQueueTests: GRDBTestCase { XCTAssertEqual(journalMode, "wal") } } - + func testInvalidFileFormat() throws { do { let url = testBundle.url(forResource: "Betty", withExtension: "jpeg")! @@ -46,12 +46,14 @@ class DatabaseQueueTests: GRDBTestCase { XCTFail("Expected error") } catch let error as DatabaseError { XCTAssertEqual(error.resultCode, .SQLITE_NOTADB) - XCTAssert([ - "file is encrypted or is not a database", - "file is not a database"].contains(error.message!)) + XCTAssert( + [ + "file is encrypted or is not a database", + "file is not a database", + ].contains(error.message!)) } } - + func testAddRemoveFunction() throws { // Adding a function and then removing it should succeed let dbQueue = try makeDatabaseQueue() @@ -63,7 +65,7 @@ class DatabaseQueueTests: GRDBTestCase { } try dbQueue.inDatabase { db in db.add(function: fn) - XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT succ(1)"), 2) // 2 + XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT succ(1)"), 2) // 2 db.remove(function: fn) } do { @@ -72,16 +74,17 @@ class DatabaseQueueTests: GRDBTestCase { XCTFail("Expected Error") } XCTFail("Expected Error") - } - catch let error as DatabaseError { + } catch let error as DatabaseError { // expected error XCTAssertEqual(error.resultCode, .SQLITE_ERROR) - XCTAssertEqual(error.message!.lowercased(), "no such function: succ") // lowercaseString: accept multiple SQLite version + XCTAssertEqual(error.message!.lowercased(), "no such function: succ") // lowercaseString: accept multiple SQLite version XCTAssertEqual(error.sql!, "SELECT succ(1)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 1: no such function: succ - while executing `select succ(1)`") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 1: no such function: succ - while executing `select succ(1)`") } } - + func testAddRemoveCollation() throws { // Adding a collation and then removing it should succeed let dbQueue = try makeDatabaseQueue() @@ -95,47 +98,54 @@ class DatabaseQueueTests: GRDBTestCase { } do { try dbQueue.inDatabase { db in - try db.execute(sql: "CREATE TABLE files_fail (name TEXT COLLATE TEST_COLLATION_FOO)") + try db.execute( + sql: "CREATE TABLE files_fail (name TEXT COLLATE TEST_COLLATION_FOO)") XCTFail("Expected Error") } XCTFail("Expected Error") - } - catch let error as DatabaseError { + } catch let error as DatabaseError { // expected error XCTAssertEqual(error.resultCode, .SQLITE_ERROR) - XCTAssertEqual(error.message!.lowercased(), "no such collation sequence: test_collation_foo") // lowercaseString: accept multiple SQLite version - XCTAssertEqual(error.sql!, "CREATE TABLE files_fail (name TEXT COLLATE TEST_COLLATION_FOO)") - XCTAssertEqual(error.description.lowercased(), "sqlite error 1: no such collation sequence: test_collation_foo - while executing `create table files_fail (name text collate test_collation_foo)`") + XCTAssertEqual( + error.message!.lowercased(), "no such collation sequence: test_collation_foo") // lowercaseString: accept multiple SQLite version + XCTAssertEqual( + error.sql!, "CREATE TABLE files_fail (name TEXT COLLATE TEST_COLLATION_FOO)") + XCTAssertEqual( + error.description.lowercased(), + "sqlite error 1: no such collation sequence: test_collation_foo - while executing `create table files_fail (name text collate test_collation_foo)`" + ) } } - + func testAllowsUnsafeTransactions() throws { dbConfiguration.allowsUnsafeTransactions = true let dbQueue = try makeDatabaseQueue() - + try dbQueue.writeWithoutTransaction { db in try db.beginTransaction() } - + try dbQueue.writeWithoutTransaction { db in try db.commit() } } - + func testDefaultLabel() throws { let dbQueue = try makeDatabaseQueue() XCTAssertEqual(dbQueue.configuration.label, nil) dbQueue.inDatabase { db in XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabaseQueue") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabaseQueue") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabaseQueue") + #endif } } - + func testCustomLabel() throws { dbConfiguration.label = "Toreador" let dbQueue = try makeDatabaseQueue() @@ -143,14 +153,16 @@ class DatabaseQueueTests: GRDBTestCase { dbQueue.inDatabase { db in XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador") + #endif } } - + func testTargetQueue() throws { func test(targetQueue: DispatchQueue) throws { dbConfiguration.targetQueue = targetQueue @@ -162,10 +174,10 @@ class DatabaseQueueTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(targetQueue)) } } - + // background queue try test(targetQueue: .global(qos: .background)) - + // main queue let expectation = self.expectation(description: "main") DispatchQueue.global(qos: .default).async { @@ -174,10 +186,10 @@ class DatabaseQueueTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) } - + func testWriteTargetQueue() throws { func test(targetQueue: DispatchQueue, writeTargetQueue: DispatchQueue) throws { - dbConfiguration.targetQueue = targetQueue // unused + dbConfiguration.targetQueue = targetQueue // unused dbConfiguration.writeTargetQueue = writeTargetQueue let dbQueue = try makeDatabaseQueue() try dbQueue.write { _ in @@ -187,10 +199,12 @@ class DatabaseQueueTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(writeTargetQueue)) } } - + // background queue - try test(targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer")) - + try test( + targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer") + ) + // main queue let expectation = self.expectation(description: "main") DispatchQueue.global(qos: .default).async { @@ -199,15 +213,15 @@ class DatabaseQueueTests: GRDBTestCase { } waitForExpectations(timeout: 1, handler: nil) } - + func testWriteTargetQueueReadOnly() throws { // Create a database before we perform read-only accesses _ = try makeDatabaseQueue(filename: "test") - + func test(targetQueue: DispatchQueue, writeTargetQueue: DispatchQueue) throws { dbConfiguration.readonly = true dbConfiguration.targetQueue = targetQueue - dbConfiguration.writeTargetQueue = writeTargetQueue // unused + dbConfiguration.writeTargetQueue = writeTargetQueue // unused let dbQueue = try makeDatabaseQueue(filename: "test") try dbQueue.write { _ in dispatchPrecondition(condition: .onQueue(targetQueue)) @@ -216,59 +230,68 @@ class DatabaseQueueTests: GRDBTestCase { dispatchPrecondition(condition: .onQueue(targetQueue)) } } - - try test(targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer")) + + try test( + targetQueue: .global(qos: .background), writeTargetQueue: DispatchQueue(label: "writer") + ) } func testQoS() throws { - func test(qos: DispatchQoS) throws { - // https://forums.swift.org/t/what-is-the-default-target-queue-for-a-serial-queue/18094/5 - // - // > [...] the default target queue [for a serial queue] is the - // > [default] overcommit [global concurrent] queue. - // - // We want this default target queue in order to test database QoS - // with dispatchPrecondition(condition:). - // - // > [...] You can get a reference to the overcommit queue by - // > dropping down to the C function dispatch_get_global_queue - // > (available in Swift with a __ prefix) and passing the private - // > value of DISPATCH_QUEUE_OVERCOMMIT. - // > - // > [...] Of course you should not do this in production code, - // > because DISPATCH_QUEUE_OVERCOMMIT is not a public API. I don't - // > know of a way to get a reference to the overcommit queue using - // > only public APIs. - let DISPATCH_QUEUE_OVERCOMMIT: UInt = 2 - let targetQueue = __dispatch_get_global_queue( - Int(qos.qosClass.rawValue.rawValue), - DISPATCH_QUEUE_OVERCOMMIT) - - dbConfiguration.qos = qos - let dbQueue = try makeDatabaseQueue() - try dbQueue.write { _ in - dispatchPrecondition(condition: .onQueue(targetQueue)) - } - try dbQueue.read { _ in - dispatchPrecondition(condition: .onQueue(targetQueue)) + #if !canImport(Darwin) + throw XCTSkip("__dispatch_get_global_queue not available on non-Darwin platforms") + #else + func test(qos: DispatchQoS) throws { + // https://forums.swift.org/t/what-is-the-default-target-queue-for-a-serial-queue/18094/5 + // + // > [...] the default target queue [for a serial queue] is the + // > [default] overcommit [global concurrent] queue. + // + // We want this default target queue in order to test database QoS + // with dispatchPrecondition(condition:). + // + // > [...] You can get a reference to the overcommit queue by + // > dropping down to the C function dispatch_get_global_queue + // > (available in Swift with a __ prefix) and passing the private + // > value of DISPATCH_QUEUE_OVERCOMMIT. + // > + // > [...] Of course you should not do this in production code, + // > because DISPATCH_QUEUE_OVERCOMMIT is not a public API. I don't + // > know of a way to get a reference to the overcommit queue using + // > only public APIs. + let DISPATCH_QUEUE_OVERCOMMIT: UInt = 2 + let targetQueue = __dispatch_get_global_queue( + Int(qos.qosClass.rawValue.rawValue), + DISPATCH_QUEUE_OVERCOMMIT) + + dbConfiguration.qos = qos + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { _ in + dispatchPrecondition(condition: .onQueue(targetQueue)) + } + try dbQueue.read { _ in + dispatchPrecondition(condition: .onQueue(targetQueue)) + } } - } - - try test(qos: .background) - try test(qos: .userInitiated) + + try test(qos: .background) + try test(qos: .userInitiated) + #endif } - + // MARK: - SQLITE_BUSY prevention - + // See - func test_busy_timeout_does_not_prevent_SQLITE_BUSY_when_write_lock_is_acquired_by_other_connection() throws { + func + test_busy_timeout_does_not_prevent_SQLITE_BUSY_when_write_lock_is_acquired_by_other_connection() + throws + { var configuration = dbConfiguration! configuration.journalMode = .wal - configuration.busyMode = .timeout(1) // Does not help in this case - + configuration.busyMode = .timeout(1) // Does not help in this case + let dbQueue1 = try makeDatabaseQueue(filename: "test", configuration: configuration) let dbQueue2 = try makeDatabaseQueue(filename: "test", configuration: configuration) - + // DB1 DB2 // BEGIN DEFERRED TRANSACTION // READ @@ -297,7 +320,7 @@ class DatabaseQueueTests: GRDBTestCase { } } } - + let block2 = { try! dbQueue2.inDatabase { db in s1.wait() @@ -309,22 +332,25 @@ class DatabaseQueueTests: GRDBTestCase { } } } - + let blocks = [block1, block2] DispatchQueue.concurrentPerform(iterations: blocks.count) { index in blocks[index]() } } - + // See - func test_busy_timeout_does_not_prevent_SQLITE_BUSY_when_write_lock_was_acquired_by_other_connection() throws { + func + test_busy_timeout_does_not_prevent_SQLITE_BUSY_when_write_lock_was_acquired_by_other_connection() + throws + { var configuration = dbConfiguration! configuration.journalMode = .wal - configuration.busyMode = .timeout(1) // Does not help in this case - + configuration.busyMode = .timeout(1) // Does not help in this case + let dbQueue1 = try makeDatabaseQueue(filename: "test", configuration: configuration) let dbQueue2 = try makeDatabaseQueue(filename: "test", configuration: configuration) - + // DB1 DB2 // BEGIN DEFERRED TRANSACTION // READ @@ -350,7 +376,7 @@ class DatabaseQueueTests: GRDBTestCase { } } } - + let block2 = { try! dbQueue2.inDatabase { db in s1.wait() @@ -358,25 +384,25 @@ class DatabaseQueueTests: GRDBTestCase { s2.signal() } } - + let blocks = [block1, block2] DispatchQueue.concurrentPerform(iterations: blocks.count) { index in blocks[index]() } } - + // See func test_busy_timeout_and_IMMEDIATE_transactions_do_prevent_SQLITE_BUSY() throws { var configuration = dbConfiguration! // Test fails when this line is commented configuration.busyMode = .timeout(10) - + let dbQueue = try makeDatabaseQueue(filename: "test") try dbQueue.inDatabase { db in try db.execute(sql: "PRAGMA journal_mode = wal") try db.execute(sql: "CREATE TABLE test(a)") } - + let parallelWritesCount = 50 DispatchQueue.concurrentPerform(iterations: parallelWritesCount) { [configuration] index in let dbQueue = try! makeDatabaseQueue(filename: "test", configuration: configuration) @@ -385,87 +411,89 @@ class DatabaseQueueTests: GRDBTestCase { try db.execute(sql: "INSERT INTO test VALUES (1)") } } - + let count = try dbQueue.read(Table("test").fetchCount) XCTAssertEqual(count, parallelWritesCount) } // MARK: - Closing - + func testClose() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.close() - + // After close, access throws SQLITE_MISUSE do { try dbQueue.inDatabase { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try dbQueue.close() } - + func testCloseAfterUse() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.execute(sql: "SELECT * FROM sqlite_master") } try dbQueue.close() - + // After close, access throws SQLITE_MISUSE do { try dbQueue.inDatabase { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try dbQueue.close() } - + func testCloseWithCachedStatement() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in _ = try db.cachedStatement(sql: "SELECT * FROM sqlite_master") } try dbQueue.close() - + // After close, access throws SQLITE_MISUSE do { try dbQueue.inDatabase { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try dbQueue.close() } - + func testFailedClose() throws { let dbQueue = try makeDatabaseQueue() let statement = try dbQueue.inDatabase { db in try db.makeStatement(sql: "SELECT * FROM sqlite_master") } - + try withExtendedLifetime(statement) { do { try dbQueue.close() XCTFail("Expected Error") - } catch DatabaseError.SQLITE_BUSY { } + } catch DatabaseError.SQLITE_BUSY {} } - XCTAssert(lastSQLiteDiagnostic!.message.contains("unfinalized statement: SELECT * FROM sqlite_master")) - + XCTAssert( + lastSQLiteDiagnostic!.message.contains( + "unfinalized statement: SELECT * FROM sqlite_master")) + // Database is not closed: no error try dbQueue.inDatabase { db in try db.execute(sql: "SELECT * FROM sqlite_master") } } - + // Regression test for func test_releaseMemory_after_close() throws { let dbQueue = try makeDatabaseQueue() diff --git a/Tests/GRDBTests/DatabaseReaderTests.swift b/Tests/GRDBTests/DatabaseReaderTests.swift index ca5a103784..ec3eb1ca55 100644 --- a/Tests/GRDBTests/DatabaseReaderTests.swift +++ b/Tests/GRDBTests/DatabaseReaderTests.swift @@ -1,34 +1,34 @@ -import XCTest import GRDB +import XCTest @TaskLocal private var localUUID = UUID() -class DatabaseReaderTests : GRDBTestCase { +class DatabaseReaderTests: GRDBTestCase { func testAnyDatabaseReader() throws { // This test passes if this code compiles. let dbQueue = try DatabaseQueue() let _: any DatabaseReader = AnyDatabaseReader(dbQueue) } - + // Test passes if it compiles. func testInitFromGeneric(_ reader: some DatabaseReader) { _ = AnyDatabaseReader(reader) } - + // Test passes if it compiles. // See func testInitFromExistentialReader(_ reader: any DatabaseReader) { _ = AnyDatabaseReader(reader) } - + // Test passes if it compiles. // See func testInitFromExistentialWriter(_ writer: any DatabaseWriter) { _ = AnyDatabaseReader(writer) } - + // MARK: - Read - + func testReadCanRead() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -42,15 +42,15 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) try test(setup(makeDatabasePool()).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(setup(makeDatabasePool()).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(setup(makeDatabasePool()).makeSnapshotPool()) + #endif } - + func testAsyncAwait_ReadCanRead() async throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -64,15 +64,15 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + try await test(setup(makeDatabaseQueue())) try await test(setup(makeDatabasePool())) try await test(setup(makeDatabasePool()).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(setup(makeDatabasePool()).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(setup(makeDatabasePool()).makeSnapshotPool()) + #endif } - + func testReadPreventsDatabaseModification() throws { func test(_ dbReader: some DatabaseReader) throws { do { @@ -83,15 +83,15 @@ class DatabaseReaderTests : GRDBTestCase { } catch DatabaseError.SQLITE_READONLY { } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testAsyncAwait_ReadPreventsDatabaseModification() async throws { func test(_ dbReader: some DatabaseReader) async throws { do { @@ -102,17 +102,17 @@ class DatabaseReaderTests : GRDBTestCase { } catch DatabaseError.SQLITE_READONLY { } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif } - + // MARK: - UnsafeRead - + func testUnsafeReadCanRead() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -126,15 +126,15 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) try test(setup(makeDatabasePool()).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(setup(makeDatabasePool()).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(setup(makeDatabasePool()).makeSnapshotPool()) + #endif } - + func testAsyncAwait_UnsafeReadCanRead() async throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -148,17 +148,17 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + try await test(setup(makeDatabaseQueue())) try await test(setup(makeDatabasePool())) try await test(setup(makeDatabasePool()).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(setup(makeDatabasePool()).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(setup(makeDatabasePool()).makeSnapshotPool()) + #endif } - + // MARK: - UnsafeReentrantRead - + func testUnsafeReentrantReadCanRead() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -172,15 +172,15 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + try test(setup(makeDatabaseQueue())) try test(setup(makeDatabasePool())) try test(setup(makeDatabasePool()).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(setup(makeDatabasePool()).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(setup(makeDatabasePool()).makeSnapshotPool()) + #endif } - + func testUnsafeReentrantReadIsReentrant() throws { func test(_ dbReader: some DatabaseReader) throws { try dbReader.unsafeReentrantRead { db1 in @@ -192,15 +192,15 @@ class DatabaseReaderTests : GRDBTestCase { } } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testUnsafeReentrantReadIsReentrantFromWrite() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db1 in @@ -212,13 +212,13 @@ class DatabaseReaderTests : GRDBTestCase { } } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + // MARK: - AsyncRead - + func testAsyncRead() throws { func test(_ dbReader: some DatabaseReader) throws { let expectation = self.expectation(description: "updates") @@ -228,26 +228,27 @@ class DatabaseReaderTests : GRDBTestCase { // Make sure this block executes asynchronously semaphore.wait() do { - try countMutex.store(Int.fetchOne(dbResult.get(), sql: "SELECT COUNT(*) FROM sqlite_master")) + try countMutex.store( + Int.fetchOne(dbResult.get(), sql: "SELECT COUNT(*) FROM sqlite_master")) } catch { XCTFail("Unexpected error: \(error)") } expectation.fulfill() } semaphore.signal() - + waitForExpectations(timeout: 1, handler: nil) XCTAssertNotNil(countMutex.load()) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testAsyncReadPreventsDatabaseModification() throws { func test(_ dbReader: some DatabaseReader) throws { let expectation = self.expectation(description: "updates") @@ -256,7 +257,8 @@ class DatabaseReaderTests : GRDBTestCase { // Make sure this block executes asynchronously semaphore.wait() do { - try dbResult.get().execute(sql: "CREATE TABLE testAsyncReadPreventsDatabaseModification (a)") + try dbResult.get().execute( + sql: "CREATE TABLE testAsyncReadPreventsDatabaseModification (a)") XCTFail("Expected error") } catch let error as DatabaseError { XCTAssertEqual(error.resultCode, .SQLITE_READONLY) @@ -268,17 +270,17 @@ class DatabaseReaderTests : GRDBTestCase { semaphore.signal() waitForExpectations(timeout: 1, handler: nil) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + // MARK: - Function - + func testAddFunction() throws { func test(_ dbReader: some DatabaseReader) throws { let value = try dbReader.read { db -> Int? in @@ -288,17 +290,17 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(value, 0) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + // MARK: - Collation - + func testAddCollation() throws { func test(_ dbReader: some DatabaseReader) throws { let value = try dbReader.read { db -> Int? in @@ -308,17 +310,17 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(value, 0) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) try test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool().makeSnapshotPool()) + #endif } - + // MARK: - Backup - + func testBackup() throws { func setup(_ dbWriter: T) throws -> T { try dbWriter.write { db in @@ -334,18 +336,18 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(count, 0) } - + // SQLCipher can't backup encrypted databases: use a pristine Configuration try test(setup(makeDatabaseQueue(configuration: Configuration()))) try test(setup(makeDatabasePool(configuration: Configuration()))) try test(setup(makeDatabasePool(configuration: Configuration())).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(setup(makeDatabasePool(configuration: Configuration())).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(setup(makeDatabasePool(configuration: Configuration())).makeSnapshotPool()) + #endif } - + // MARK: - Task locals - + func testReadCanAccessTaskLocal() async throws { func test(_ dbReader: some DatabaseReader) async throws { let expectedUUID = UUID() @@ -354,15 +356,15 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(dbUUID, expectedUUID) } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif } - + func testUnsafeReadCanAccessTaskLocal() async throws { func test(_ dbReader: some DatabaseReader) async throws { let expectedUUID = UUID() @@ -371,18 +373,19 @@ class DatabaseReaderTests : GRDBTestCase { } XCTAssertEqual(dbUUID, expectedUUID) } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif } - + // MARK: - Task Cancellation - - func test_read_is_cancelled_by_Task_cancellation_performed_before_database_access() async throws { + + func test_read_is_cancelled_by_Task_cancellation_performed_before_database_access() async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let task = Task { @@ -393,31 +396,34 @@ class DatabaseReaderTests : GRDBTestCase { } task.cancel() semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.read { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - - func test_successful_read_is_not_cancelled_by_Task_cancellation_performed_after_database_access() async throws { + + func + test_successful_read_is_not_cancelled_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) @@ -430,27 +436,30 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + // Task has completed without any error try await task.value - + // Database access is restored after cancellation (no error is thrown) try await dbReader.read { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - func test_statement_execution_from_read_is_cancelled_by_Task_cancellation_performed_after_database_access() async throws { + func + test_statement_execution_from_read_is_cancelled_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) @@ -464,40 +473,45 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.read { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - - func test_cursor_iteration_from_read_is_interrupted_by_Task_cancellation_performed_after_database_access() async throws { + + func + test_cursor_iteration_from_read_is_interrupted_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) let task = Task { await semaphore.wait() try await dbReader.read { db in - let cursor = try Int.fetchCursor(db, sql: """ - SELECT 1 UNION ALL SELECT 2 - """) + let cursor = try Int.fetchCursor( + db, + sql: """ + SELECT 1 UNION ALL SELECT 2 + """) _ = try cursor.next() try XCTUnwrap(cancelledTaskMutex.load()).cancel() _ = try cursor.next() @@ -506,31 +520,33 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.read { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - - func test_unsafeRead_is_cancelled_by_Task_cancellation_performed_before_database_access() async throws { + + func test_unsafeRead_is_cancelled_by_Task_cancellation_performed_before_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let task = Task { @@ -541,31 +557,34 @@ class DatabaseReaderTests : GRDBTestCase { } task.cancel() semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.unsafeRead { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - - func test_successful_unsafeRead_is_not_cancelled_by_Task_cancellation_performed_after_database_access() async throws { + + func + test_successful_unsafeRead_is_not_cancelled_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) @@ -578,27 +597,30 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + // Task has completed without any error try await task.value - + // Database access is restored after cancellation (no error is thrown) try await dbReader.unsafeRead { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - func test_statement_execution_from_unsafeRead_is_cancelled_by_Task_cancellation_performed_after_database_access() async throws { + func + test_statement_execution_from_unsafeRead_is_cancelled_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) @@ -612,40 +634,45 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.unsafeRead { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - - func test_cursor_iteration_from_unsafeRead_is_interrupted_by_Task_cancellation_performed_after_database_access() async throws { + + func + test_cursor_iteration_from_unsafeRead_is_interrupted_by_Task_cancellation_performed_after_database_access() + async throws + { func test(_ dbReader: some DatabaseReader) async throws { let semaphore = AsyncSemaphore(value: 0) let cancelledTaskMutex = Mutex?>(nil) let task = Task { await semaphore.wait() try await dbReader.unsafeRead { db in - let cursor = try Int.fetchCursor(db, sql: """ - SELECT 1 UNION ALL SELECT 2 - """) + let cursor = try Int.fetchCursor( + db, + sql: """ + SELECT 1 UNION ALL SELECT 2 + """) _ = try cursor.next() try XCTUnwrap(cancelledTaskMutex.load()).cancel() _ = try cursor.next() @@ -654,30 +681,30 @@ class DatabaseReaderTests : GRDBTestCase { } cancelledTaskMutex.store(task) semaphore.signal() - + do { try await task.value XCTFail("Expected error") } catch { XCTAssert(error is CancellationError) } - + // Database access is restored after cancellation (no error is thrown) try await dbReader.unsafeRead { db in try db.execute(sql: "SELECT 0") } } - + try await test(makeDatabaseQueue()) try await test(makeDatabasePool()) try await test(makeDatabasePool().makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try await test(makeDatabasePool().makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try await test(makeDatabasePool().makeSnapshotPool()) + #endif try await test(AnyDatabaseReader(makeDatabaseQueue())) try await test(AnyDatabaseWriter(makeDatabaseQueue())) } - + // Regression test for https://github.com/groue/GRDB.swift/pull/1797 func test_cancellation_does_not_impact_other_tasks() async throws { func test(_ dbReader: some DatabaseReader) async throws { @@ -687,7 +714,7 @@ class DatabaseReaderTests : GRDBTestCase { let taskCount = 400 for _ in 0.. - func testAnyDatabaseWriter(writer: any DatabaseWriter) throws { - let observation = DatabaseRegionObservation(tracking: .fullDatabase) - - _ = observation.start(in: writer, onError: { _ in }, onChange: { _ in }) - _ = observation.publisher(in: writer) - } - + #if canImport(Combine) + func testAnyDatabaseWriter(writer: any DatabaseWriter) throws { + let observation = DatabaseRegionObservation(tracking: .fullDatabase) + + _ = observation.start(in: writer, onError: { _ in }, onChange: { _ in }) + _ = observation.publisher(in: writer) + } + #endif + func testDatabaseRegionObservation_FullDatabase() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t1(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") try $0.execute(sql: "CREATE TABLE t2(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 3 - + let observation = DatabaseRegionObservation(tracking: .fullDatabase) - + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, @@ -32,7 +34,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { countMutex.increment() notificationExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t1 (id, name) VALUES (1, 'foo')") @@ -45,7 +47,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t2 (id, name) VALUES (2, 'foo')") } waitForExpectations(timeout: 1, handler: nil) - + XCTAssertEqual(countMutex.load(), 3) } } @@ -55,12 +57,12 @@ class DatabaseRegionObservationTests: GRDBTestCase { try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + let notificationExpectation = expectation(description: "notification") notificationExpectation.isInverted = true - + let observation = DatabaseRegionObservation(tracking: .fullDatabase) - + let cancellable = observation.start( in: dbQueue, onError: { XCTFail("Unexpected error: \($0)") }, @@ -68,7 +70,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { notificationExpectation.fulfill() }) cancellable.cancel() - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") @@ -76,23 +78,23 @@ class DatabaseRegionObservationTests: GRDBTestCase { waitForExpectations(timeout: 0.1, handler: nil) } } - + func testDatabaseRegionObservationVariadic() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t1(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") try $0.execute(sql: "CREATE TABLE t2(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 3 - + let request1 = SQLRequest(sql: "SELECT * FROM t1 ORDER BY id") let request2 = SQLRequest(sql: "SELECT * FROM t2 ORDER BY id") - + let observation = DatabaseRegionObservation(tracking: request1, request2) - + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, @@ -101,7 +103,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { countMutex.increment() notificationExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t1 (id, name) VALUES (1, 'foo')") @@ -114,27 +116,27 @@ class DatabaseRegionObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t2 (id, name) VALUES (2, 'foo')") } waitForExpectations(timeout: 1, handler: nil) - + XCTAssertEqual(countMutex.load(), 3) } } - + func testDatabaseRegionObservationArray() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t1(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") try $0.execute(sql: "CREATE TABLE t2(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 3 - + let request1 = SQLRequest(sql: "SELECT * FROM t1 ORDER BY id") let request2 = SQLRequest(sql: "SELECT * FROM t2 ORDER BY id") - + let observation = DatabaseRegionObservation(tracking: [request1, request2]) - + let countMutex = Mutex(0) let cancellable = observation.start( in: dbQueue, @@ -143,7 +145,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { countMutex.increment() notificationExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t1 (id, name) VALUES (1, 'foo')") @@ -156,21 +158,24 @@ class DatabaseRegionObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t2 (id, name) VALUES (2, 'foo')") } waitForExpectations(timeout: 1, handler: nil) - + XCTAssertEqual(countMutex.load(), 3) } } - + func testDatabaseRegionDefaultCancellation() throws { let dbQueue = try makeDatabaseQueue() - try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + try dbQueue.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") + } + let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 2 - - let observation = DatabaseRegionObservation(tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) - + + let observation = DatabaseRegionObservation( + tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) + let countMutex = Mutex(0) do { let cancellable = observation.start( @@ -180,7 +185,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { countMutex.increment() notificationExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") @@ -195,20 +200,23 @@ class DatabaseRegionObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") } waitForExpectations(timeout: 1, handler: nil) - + XCTAssertEqual(countMutex.load(), 2) } - + func testDatabaseRegionExtentNextTransaction() throws { let dbQueue = try makeDatabaseQueue() - try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } - + try dbQueue.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") + } + let notificationExpectation = expectation(description: "notification") notificationExpectation.assertForOverFulfill = true notificationExpectation.expectedFulfillmentCount = 1 - - let observation = DatabaseRegionObservation(tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) - + + let observation = DatabaseRegionObservation( + tracking: SQLRequest(sql: "SELECT * FROM t ORDER BY id")) + let countMutex = Mutex(0) nonisolated(unsafe) var cancellable: AnyDatabaseCancellable? cancellable = observation.start( @@ -219,7 +227,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { countMutex.increment() notificationExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { try dbQueue.write { db in try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") @@ -229,7 +237,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") } waitForExpectations(timeout: 1, handler: nil) - + XCTAssertEqual(countMutex.load(), 1) } } @@ -239,12 +247,12 @@ class DatabaseRegionObservationTests: GRDBTestCase { try dbQueue1.write { db in try db.execute(sql: "CREATE TABLE test(a)") } - + let undetectedExpectation = expectation(description: "undetected") undetectedExpectation.isInverted = true let detectedExpectation = expectation(description: "detected") - + let observation = DatabaseRegionObservation(tracking: Table("test")) let cancellable = observation.start( in: dbQueue1, @@ -253,7 +261,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { undetectedExpectation.fulfill() detectedExpectation.fulfill() }) - + try withExtendedLifetime(cancellable) { // Change performed from external connection is not detected... let dbQueue2 = try makeDatabaseQueue(filename: "test.sqlite") @@ -261,7 +269,7 @@ class DatabaseRegionObservationTests: GRDBTestCase { 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")) @@ -269,47 +277,47 @@ class DatabaseRegionObservationTests: GRDBTestCase { wait(for: [detectedExpectation], timeout: 2) } } - + // Regression test for https://github.com/groue/GRDB.swift/issues/514 // TODO: uncomment and make this test pass. // Well, actually, selecting only the rowid has SQLite authorizer advertise // that we select the whole table. This creates undesired database // observation notifications. -// func testIssue514() throws { -// let dbQueue = try makeDatabaseQueue() -// try dbQueue.write { db in -// try db.create(table: "gallery") { t in -// t.primaryKey("id", .integer) -// t.column("status", .integer) -// } -// } -// -// struct Gallery: TableRecord { } -// let observation = DatabaseRegionObservation(tracking: Gallery.select(Column("id"))) -// -// var notificationCount = 0 -// let cancellable = observation.start( -// in: dbQueue, -// onError: { XCTFail("Unexpected error: \($0)") }, -// onChange: { _ in -// notificationCount += 1 -// }) -// -// try withExtendedLifetime(cancellable) { -// try dbQueue.write { db in -// try db.execute(sql: "INSERT INTO gallery (id, status) VALUES (NULL, 0)") -// } -// XCTAssertEqual(notificationCount, 1) -// -// try dbQueue.write { db in -// try db.execute(sql: "UPDATE gallery SET status = 1") -// } -// XCTAssertEqual(notificationCount, 1) // status is not observed -// -// try dbQueue.write { db in -// try db.execute(sql: "DELETE FROM gallery") -// } -// XCTAssertEqual(notificationCount, 2) -// } -// } + // func testIssue514() throws { + // let dbQueue = try makeDatabaseQueue() + // try dbQueue.write { db in + // try db.create(table: "gallery") { t in + // t.primaryKey("id", .integer) + // t.column("status", .integer) + // } + // } + // + // struct Gallery: TableRecord { } + // let observation = DatabaseRegionObservation(tracking: Gallery.select(Column("id"))) + // + // var notificationCount = 0 + // let cancellable = observation.start( + // in: dbQueue, + // onError: { XCTFail("Unexpected error: \($0)") }, + // onChange: { _ in + // notificationCount += 1 + // }) + // + // try withExtendedLifetime(cancellable) { + // try dbQueue.write { db in + // try db.execute(sql: "INSERT INTO gallery (id, status) VALUES (NULL, 0)") + // } + // XCTAssertEqual(notificationCount, 1) + // + // try dbQueue.write { db in + // try db.execute(sql: "UPDATE gallery SET status = 1") + // } + // XCTAssertEqual(notificationCount, 1) // status is not observed + // + // try dbQueue.write { db in + // try db.execute(sql: "DELETE FROM gallery") + // } + // XCTAssertEqual(notificationCount, 2) + // } + // } } diff --git a/Tests/GRDBTests/DatabaseSnapshotPoolTests.swift b/Tests/GRDBTests/DatabaseSnapshotPoolTests.swift index 3235f5db9e..ff321f6385 100644 --- a/Tests/GRDBTests/DatabaseSnapshotPoolTests.swift +++ b/Tests/GRDBTests/DatabaseSnapshotPoolTests.swift @@ -1,298 +1,300 @@ -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) -import XCTest -import GRDB - -// test create from non-wal (read-only) snapshot -final class DatabaseSnapshotPoolTests: GRDBTestCase { - /// A helper type - private struct Counter { - init(dbPool: DatabasePool) throws { - try dbPool.write { db in - try db.execute(sql: "CREATE TABLE counter(id INTEGER PRIMARY KEY)") +#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + import XCTest + import GRDB + + // test create from non-wal (read-only) snapshot + final class DatabaseSnapshotPoolTests: GRDBTestCase { + /// A helper type + private struct Counter { + init(dbPool: DatabasePool) throws { + try dbPool.write { db in + try db.execute(sql: "CREATE TABLE counter(id INTEGER PRIMARY KEY)") + } + } + + func increment(_ db: Database) throws { + try db.execute(sql: "INSERT INTO counter DEFAULT VALUES") + } + + func value(_ db: Database) throws -> Int { + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM counter")! } } - - func increment(_ db: Database) throws { - try db.execute(sql: "INSERT INTO counter DEFAULT VALUES") - } - - func value(_ db: Database) throws -> Int { - try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM counter")! + + func test_creation_from_new_DatabasePool() throws { + _ = try makeDatabasePool().makeSnapshotPool() } - } - - func test_creation_from_new_DatabasePool() throws { - _ = try makeDatabasePool().makeSnapshotPool() - } - - func test_creation_from_non_WAL_DatabasePool() throws { - let dbQueue = try makeDatabaseQueue() - - var config = Configuration() - config.readonly = true - let dbPool = try DatabasePool(path: dbQueue.path, configuration: config) - - do { - _ = try dbPool.makeSnapshotPool() - XCTFail("Expected error") - } catch DatabaseError.SQLITE_ERROR { } - } - - func test_creation_from_DatabasePool_write_and_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - // We can't open a DatabaseSnapshotPool from an IMMEDIATE - // transaction (as documented by sqlite3_snapshot_get). So we - // force a DEFERRED transaction: - var snapshot: DatabaseSnapshotPool! - try dbPool.writeInTransaction(.deferred) { db in - snapshot = try DatabaseSnapshotPool(db) // locked at 1 - return .commit + + func test_creation_from_non_WAL_DatabasePool() throws { + let dbQueue = try makeDatabaseQueue() + + var config = Configuration() + config.readonly = true + let dbPool = try DatabasePool(path: dbQueue.path, configuration: config) + + do { + _ = try dbPool.makeSnapshotPool() + XCTFail("Expected error") + } catch DatabaseError.SQLITE_ERROR {} } - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.read(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } - - func test_creation_from_DatabasePool_writeWithoutTransaction_and_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.writeWithoutTransaction { db in - try DatabaseSnapshotPool(db) // locked at 1 + + func test_creation_from_DatabasePool_write_and_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + // We can't open a DatabaseSnapshotPool from an IMMEDIATE + // transaction (as documented by sqlite3_snapshot_get). So we + // force a DEFERRED transaction: + var snapshot: DatabaseSnapshotPool! + try dbPool.writeInTransaction(.deferred) { db in + snapshot = try DatabaseSnapshotPool(db) // locked at 1 + return .commit + } + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.read(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) } - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.read(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } - - func test_creation_from_DatabasePool_uncommitted_write() throws { - let dbPool = try makeDatabasePool() - do { - try dbPool.write { db in - try db.execute(sql: "CREATE TABLE t(a)") - _ = try DatabaseSnapshotPool(db) + + func test_creation_from_DatabasePool_writeWithoutTransaction_and_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.writeWithoutTransaction { db in + try DatabaseSnapshotPool(db) // locked at 1 } - XCTFail("Expected error") - } catch DatabaseError.SQLITE_ERROR { } - } - - func test_creation_from_DatabasePool_read_and_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.read { db in try DatabaseSnapshotPool(db) } // locked at 1 - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.read(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } + try dbPool.write(counter.increment) // 2 - func test_creation_from_DatabasePool_unsafeRead_and_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.unsafeRead { db in try DatabaseSnapshotPool(db) } // locked at 1 - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.read(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.read(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) + } - func test_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.read(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } - - func test_discarded_transaction() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - try snapshot.read { db in - try XCTAssertEqual(counter.value(db), 1) - try db.commit() // lose snapshot - try XCTAssertEqual(counter.value(db), 2) + func test_creation_from_DatabasePool_uncommitted_write() throws { + let dbPool = try makeDatabasePool() + do { + try dbPool.write { db in + try db.execute(sql: "CREATE TABLE t(a)") + _ = try DatabaseSnapshotPool(db) + } + XCTFail("Expected error") + } catch DatabaseError.SQLITE_ERROR {} } - - // Try to invalidate the snapshot - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } - - // Snapshot is not lost, and previous connection is not reused. - try XCTAssertEqual(snapshot.read(counter.value), 1) - } - - func test_replaced_transaction() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - try snapshot.read { db in - try XCTAssertEqual(counter.value(db), 1) - try db.commit() // lose snapshot - try db.beginTransaction() - try XCTAssertEqual(counter.value(db), 2) + + func test_creation_from_DatabasePool_read_and_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.read { db in try DatabaseSnapshotPool(db) } // locked at 1 + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.read(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) } - - // Try to invalidate the snapshot - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } - - // Snapshot is not lost, and previous connection is not reused. - try XCTAssertEqual(snapshot.read(counter.value), 1) - } - - func test_concurrent_read() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - // Block 1 Block 2 - // snapshot.read { - // SELECT COUNT(*) FROM counter - // > - let s1 = DispatchSemaphore(value: 0) - // snapshot.read { - // SELECT COUNT(*) FROM counter - // < - let s2 = DispatchSemaphore(value: 0) - // end end - // } - - let block1: () -> Void = { - try! snapshot.read { db -> Void in + + func test_creation_from_DatabasePool_unsafeRead_and_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.unsafeRead { db in try DatabaseSnapshotPool(db) } // locked at 1 + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.read(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) + } + + func test_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.read(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) + } + + func test_discarded_transaction() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + try snapshot.read { db in try XCTAssertEqual(counter.value(db), 1) - s1.signal() - _ = s2.wait(timeout: .distantFuture) + try db.commit() // lose snapshot + try XCTAssertEqual(counter.value(db), 2) } + + // Try to invalidate the snapshot + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } + + // Snapshot is not lost, and previous connection is not reused. + try XCTAssertEqual(snapshot.read(counter.value), 1) } - let block2: () -> Void = { - _ = s1.wait(timeout: .distantFuture) - try! snapshot.read { db -> Void in + + func test_replaced_transaction() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + try snapshot.read { db in try XCTAssertEqual(counter.value(db), 1) - s2.signal() + try db.commit() // lose snapshot + try db.beginTransaction() + try XCTAssertEqual(counter.value(db), 2) } + + // Try to invalidate the snapshot + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } + + // Snapshot is not lost, and previous connection is not reused. + try XCTAssertEqual(snapshot.read(counter.value), 1) } - let blocks = [block1, block2] - DispatchQueue.concurrentPerform(iterations: blocks.count) { index in - blocks[index]() - } - } - - func test_unsafeRead() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.unsafeRead(counter.value), 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } - - func test_unsafeReentrantRead() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try dbPool.write(counter.increment) // 2 - - try XCTAssertEqual(dbPool.read(counter.value), 2) - try XCTAssertEqual(snapshot.unsafeReentrantRead { _ in try snapshot.unsafeReentrantRead(counter.value) }, 1) - // Reuse the last connection - try XCTAssertEqual(dbPool.read(counter.value), 2) - } - - func test_read_async() async throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try await dbPool.write { try counter.increment($0) } // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try await dbPool.write { try counter.increment($0) } // 2 - - do { - let count = try await dbPool.read { try counter.value($0) } - XCTAssertEqual(count, 2) + + func test_concurrent_read() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + // Block 1 Block 2 + // snapshot.read { + // SELECT COUNT(*) FROM counter + // > + let s1 = DispatchSemaphore(value: 0) + // snapshot.read { + // SELECT COUNT(*) FROM counter + // < + let s2 = DispatchSemaphore(value: 0) + // end end + // } + + let block1: () -> Void = { + try! snapshot.read { db -> Void in + try XCTAssertEqual(counter.value(db), 1) + s1.signal() + _ = s2.wait(timeout: .distantFuture) + } + } + let block2: () -> Void = { + _ = s1.wait(timeout: .distantFuture) + try! snapshot.read { db -> Void in + try XCTAssertEqual(counter.value(db), 1) + s2.signal() + } + } + let blocks = [block1, block2] + DispatchQueue.concurrentPerform(iterations: blocks.count) { index in + blocks[index]() + } } - do { - let count = try await snapshot.read { try counter.value($0) } - XCTAssertEqual(count, 1) + + func test_unsafeRead() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual(snapshot.unsafeRead(counter.value), 1) + // Reuse the last connection + try XCTAssertEqual(dbPool.read(counter.value), 2) } - do { + + func test_unsafeReentrantRead() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try dbPool.write(counter.increment) // 2 + + try XCTAssertEqual(dbPool.read(counter.value), 2) + try XCTAssertEqual( + snapshot.unsafeReentrantRead { _ in try snapshot.unsafeReentrantRead(counter.value) + }, 1) // Reuse the last connection - let count = try await dbPool.read { try counter.value($0) } - XCTAssertEqual(count, 2) + try XCTAssertEqual(dbPool.read(counter.value), 2) + } + + func test_read_async() async throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try await dbPool.write { try counter.increment($0) } // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try await dbPool.write { try counter.increment($0) } // 2 + + do { + let count = try await dbPool.read { try counter.value($0) } + XCTAssertEqual(count, 2) + } + do { + let count = try await snapshot.read { try counter.value($0) } + XCTAssertEqual(count, 1) + } + do { + // Reuse the last connection + let count = try await dbPool.read { try counter.value($0) } + XCTAssertEqual(count, 2) + } + } + + func testPassiveCheckpointDoesNotInvalidateSnapshotPool() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.passive) } // ignore if error or not, that's not the point + try XCTAssertEqual(snapshot.read(counter.value), 1) + try dbPool.write(counter.increment) // 2 + try XCTAssertEqual(snapshot.read(counter.value), 1) + } + + func testFullCheckpointDoesNotInvalidateSnapshotPool() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.full) } // ignore if error or not, that's not the point + try XCTAssertEqual(snapshot.read(counter.value), 1) + try dbPool.write(counter.increment) // 2 + try XCTAssertEqual(snapshot.read(counter.value), 1) + } + + func testRestartCheckpointDoesNotInvalidateSnapshotPool() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.restart) } // ignore if error or not, that's not the point + try XCTAssertEqual(snapshot.read(counter.value), 1) + try dbPool.write(counter.increment) // 2 + try XCTAssertEqual(snapshot.read(counter.value), 1) + } + + func testTruncateCheckpointDoesNotInvalidateSnapshotPool() throws { + let dbPool = try makeDatabasePool() + let counter = try Counter(dbPool: dbPool) // 0 + try dbPool.write(counter.increment) // 1 + let snapshot = try dbPool.makeSnapshotPool() // locked at 1 + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } // ignore if error or not, that's not the point + try XCTAssertEqual(snapshot.read(counter.value), 1) + try dbPool.write(counter.increment) // 2 + try XCTAssertEqual(snapshot.read(counter.value), 1) } } - - func testPassiveCheckpointDoesNotInvalidateSnapshotPool() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.passive) } // ignore if error or not, that's not the point - try XCTAssertEqual(snapshot.read(counter.value), 1) - try dbPool.write(counter.increment) // 2 - try XCTAssertEqual(snapshot.read(counter.value), 1) - } - - func testFullCheckpointDoesNotInvalidateSnapshotPool() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.full) } // ignore if error or not, that's not the point - try XCTAssertEqual(snapshot.read(counter.value), 1) - try dbPool.write(counter.increment) // 2 - try XCTAssertEqual(snapshot.read(counter.value), 1) - } - - func testRestartCheckpointDoesNotInvalidateSnapshotPool() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.restart) } // ignore if error or not, that's not the point - try XCTAssertEqual(snapshot.read(counter.value), 1) - try dbPool.write(counter.increment) // 2 - try XCTAssertEqual(snapshot.read(counter.value), 1) - } - - func testTruncateCheckpointDoesNotInvalidateSnapshotPool() throws { - let dbPool = try makeDatabasePool() - let counter = try Counter(dbPool: dbPool) // 0 - try dbPool.write(counter.increment) // 1 - let snapshot = try dbPool.makeSnapshotPool() // locked at 1 - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } // ignore if error or not, that's not the point - try XCTAssertEqual(snapshot.read(counter.value), 1) - try dbPool.write(counter.increment) // 2 - try XCTAssertEqual(snapshot.read(counter.value), 1) - } -} #endif diff --git a/Tests/GRDBTests/DatabaseSnapshotTests.swift b/Tests/GRDBTests/DatabaseSnapshotTests.swift index 1cb422ecdd..1cc450518d 100644 --- a/Tests/GRDBTests/DatabaseSnapshotTests.swift +++ b/Tests/GRDBTests/DatabaseSnapshotTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import GRDB class DatabaseSnapshotTests: GRDBTestCase { @@ -9,31 +10,31 @@ class DatabaseSnapshotTests: GRDBTestCase { try db.execute(sql: "CREATE TABLE counter(id INTEGER PRIMARY KEY)") } } - + func increment(_ db: Database) throws { try db.execute(sql: "INSERT INTO counter DEFAULT VALUES") } - + func value(_ db: Database) throws -> Int { try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM counter")! } } - + // MARK: - Creation - + func testSnapshotCanReadBeforeDatabaseModification() throws { let dbPool = try makeDatabasePool() let snapshot = try dbPool.makeSnapshot() try XCTAssertEqual(snapshot.read { try $0.tableExists("foo") }, false) } - + func testSnapshotCreatedFromMainQueueCanRead() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) let snapshot = try dbPool.makeSnapshot() try XCTAssertEqual(snapshot.read(counter.value), 0) } - + func testSnapshotCreatedFromWriterOutsideOfTransactionCanRead() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -45,7 +46,7 @@ class DatabaseSnapshotTests: GRDBTestCase { } try XCTAssertEqual(snapshot.read(counter.value), 0) } - + func testSnapshotCreatedFromReaderTransactionCanRead() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -55,7 +56,7 @@ class DatabaseSnapshotTests: GRDBTestCase { } try XCTAssertEqual(snapshot.read(counter.value), 0) } - + func testSnapshotCreatedFromReaderOutsideOfTransactionCanRead() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -65,7 +66,7 @@ class DatabaseSnapshotTests: GRDBTestCase { } try XCTAssertEqual(snapshot.read(counter.value), 0) } - + func testSnapshotCreatedFromTransactionObserver() throws { // Creating a snapshot from a didCommit callback is an important use // case. But we know SQLite snapshots created with @@ -80,13 +81,13 @@ class DatabaseSnapshotTests: GRDBTestCase { self.dbPool = dbPool self.snapshot = snapshot } - + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { false } - func databaseDidChange(with event: DatabaseEvent) { } + func databaseDidChange(with event: DatabaseEvent) {} func databaseDidCommit(_ db: Database) { snapshot = try! dbPool.makeSnapshot() } - func databaseDidRollback(_ db: Database) { } + func databaseDidRollback(_ db: Database) {} } let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -96,9 +97,9 @@ class DatabaseSnapshotTests: GRDBTestCase { try dbPool.write(counter.increment) try XCTAssertEqual(observer.snapshot.read(counter.value), 1) } - + // MARK: - Behavior - + func testSnapshotIsReadOnly() throws { let dbPool = try makeDatabasePool() let snapshot = try dbPool.makeSnapshot() @@ -107,9 +108,9 @@ class DatabaseSnapshotTests: GRDBTestCase { try db.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY") } XCTFail("Expected error") - } catch is DatabaseError { } + } catch is DatabaseError {} } - + func testSnapshotIsImmutable() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -125,54 +126,59 @@ class DatabaseSnapshotTests: GRDBTestCase { try XCTAssertEqual(dbPool.read(counter.value), 2) } } - + // MARK: - Functions - + func testSnapshotInheritPoolFunctions() throws { dbConfiguration.prepareDatabase { db in - let function = DatabaseFunction("foo", argumentCount: 0, pure: true) { _ in return "foo" } + let function = DatabaseFunction("foo", argumentCount: 0, pure: true) { _ in return "foo" + } db.add(function: function) } let dbPool = try makeDatabasePool() - + let snapshot = try dbPool.makeSnapshot() try snapshot.read { db in try XCTAssertEqual(String.fetchOne(db, sql: "SELECT foo()")!, "foo") } } - + // MARK: - Collations - + func testSnapshotInheritPoolCollations() throws { dbConfiguration.prepareDatabase { db in let collation = DatabaseCollation("reverse") { (string1, string2) in - return (string1 == string2) ? .orderedSame : ((string1 < string2) ? .orderedDescending : .orderedAscending) + return (string1 == string2) + ? .orderedSame : ((string1 < string2) ? .orderedDescending : .orderedAscending) } db.add(collation: collation) } let dbPool = try makeDatabasePool() - + try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (text TEXT)") try db.execute(sql: "INSERT INTO items (text) VALUES ('a')") try db.execute(sql: "INSERT INTO items (text) VALUES ('b')") try db.execute(sql: "INSERT INTO items (text) VALUES ('c')") } - + let snapshot = try dbPool.makeSnapshot() try snapshot.read { db in - XCTAssertEqual(try String.fetchAll(db, sql: "SELECT text FROM items ORDER BY text COLLATE reverse"), ["c", "b", "a"]) + XCTAssertEqual( + try String.fetchAll( + db, sql: "SELECT text FROM items ORDER BY text COLLATE reverse"), + ["c", "b", "a"]) } } - + // MARK: - Concurrency - + func testReadBlockIsolationStartingWithRead() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE items (id INTEGER PRIMARY KEY)") } - + // Block 1 Block 2 // dbSnapshot.read { // > @@ -188,7 +194,7 @@ class DatabaseSnapshotTests: GRDBTestCase { let s4 = DispatchSemaphore(value: 0) // SELECT COUNT(*) FROM items -> 0 // } - + let block1 = { () in let snapshot = try! dbPool.makeSnapshot() try! snapshot.read { db in @@ -224,59 +230,67 @@ class DatabaseSnapshotTests: GRDBTestCase { func testDefaultLabel() throws { let dbPool = try makeDatabasePool() - + let snapshot1 = try dbPool.makeSnapshot() snapshot1.unsafeRead { db in XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabasePool.snapshot.1") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.1") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.1") + #endif } - + let snapshot2 = try dbPool.makeSnapshot() snapshot2.unsafeRead { db in XCTAssertEqual(db.configuration.label, nil) XCTAssertEqual(db.description, "GRDB.DatabasePool.snapshot.2") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.2") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "GRDB.DatabasePool.snapshot.2") + #endif } } - + func testCustomLabel() throws { dbConfiguration.label = "Toreador" let dbPool = try makeDatabasePool() - + let snapshot1 = try dbPool.makeSnapshot() snapshot1.unsafeRead { db in XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador.snapshot.1") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador.snapshot.1") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador.snapshot.1") + #endif } - + let snapshot2 = try dbPool.makeSnapshot() snapshot2.unsafeRead { db in XCTAssertEqual(db.configuration.label, "Toreador") XCTAssertEqual(db.description, "Toreador.snapshot.2") - + // This test CAN break in future releases: the dispatch queue labels // are documented to be a debug-only tool. - let label = String(utf8String: __dispatch_queue_get_label(nil)) - XCTAssertEqual(label, "Toreador.snapshot.2") + #if canImport(Darwin) + let label = String(utf8String: __dispatch_queue_get_label(nil)) + XCTAssertEqual(label, "Toreador.snapshot.2") + #endif } } - + // MARK: - Checkpoints - + func testAutomaticCheckpointDoesNotInvalidateSnapshot() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) @@ -291,53 +305,53 @@ class DatabaseSnapshotTests: GRDBTestCase { } try XCTAssertEqual(snapshot.read(counter.value), 1) } - + func testPassiveCheckpointDoesNotInvalidateSnapshot() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) try dbPool.write(counter.increment) let snapshot = try dbPool.makeSnapshot() - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.passive) } // ignore if error or not, that's not the point + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.passive) } // ignore if error or not, that's not the point try XCTAssertEqual(snapshot.read(counter.value), 1) try dbPool.write(counter.increment) try XCTAssertEqual(snapshot.read(counter.value), 1) } - + func testFullCheckpointDoesNotInvalidateSnapshot() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) try dbPool.write(counter.increment) let snapshot = try dbPool.makeSnapshot() - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.full) } // ignore if error or not, that's not the point + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.full) } // ignore if error or not, that's not the point try XCTAssertEqual(snapshot.read(counter.value), 1) try dbPool.write(counter.increment) try XCTAssertEqual(snapshot.read(counter.value), 1) } - + func testRestartCheckpointDoesNotInvalidateSnapshot() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) try dbPool.write(counter.increment) let snapshot = try dbPool.makeSnapshot() - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.restart) } // ignore if error or not, that's not the point + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.restart) } // ignore if error or not, that's not the point try XCTAssertEqual(snapshot.read(counter.value), 1) try dbPool.write(counter.increment) try XCTAssertEqual(snapshot.read(counter.value), 1) } - + func testTruncateCheckpointDoesNotInvalidateSnapshot() throws { let dbPool = try makeDatabasePool() let counter = try Counter(dbPool: dbPool) try dbPool.write(counter.increment) let snapshot = try dbPool.makeSnapshot() - try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } // ignore if error or not, that's not the point + try? dbPool.writeWithoutTransaction { _ = try $0.checkpoint(.truncate) } // ignore if error or not, that's not the point try XCTAssertEqual(snapshot.read(counter.value), 1) try dbPool.write(counter.increment) try XCTAssertEqual(snapshot.read(counter.value), 1) } - + // MARK: - Schema Cache - + func testSnapshotSchemaCache() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in @@ -355,77 +369,79 @@ class DatabaseSnapshotTests: GRDBTestCase { XCTAssertNotNil(db.schemaCache[.main].primaryKey("t")) } } - + // MARK: - Closing - + func testClose() throws { let snapshot = try makeDatabasePool().makeSnapshot() try snapshot.close() - + // After close, access throws SQLITE_MISUSE do { try snapshot.read { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try snapshot.close() } - + func testCloseAfterUse() throws { let snapshot = try makeDatabasePool().makeSnapshot() try snapshot.read { db in try db.execute(sql: "SELECT * FROM sqlite_master") } try snapshot.close() - + // After close, access throws SQLITE_MISUSE do { try snapshot.read { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try snapshot.close() } - + func testCloseWithCachedStatement() throws { let snapshot = try makeDatabasePool().makeSnapshot() try snapshot.read { db in _ = try db.cachedStatement(sql: "SELECT * FROM sqlite_master") } try snapshot.close() - + // After close, access throws SQLITE_MISUSE do { try snapshot.read { db in try db.execute(sql: "SELECT * FROM sqlite_master") } XCTFail("Expected Error") - } catch DatabaseError.SQLITE_MISUSE { } - + } catch DatabaseError.SQLITE_MISUSE {} + // After close, closing is a noop try snapshot.close() } - + func testFailedClose() throws { let snapshot = try makeDatabasePool().makeSnapshot() let statement = try snapshot.read { db in try db.makeStatement(sql: "SELECT * FROM sqlite_master") } - + try withExtendedLifetime(statement) { do { try snapshot.close() XCTFail("Expected Error") - } catch DatabaseError.SQLITE_BUSY { } + } catch DatabaseError.SQLITE_BUSY {} } - XCTAssert(lastSQLiteDiagnostic!.message.contains("unfinalized statement: SELECT * FROM sqlite_master")) - + XCTAssert( + lastSQLiteDiagnostic!.message.contains( + "unfinalized statement: SELECT * FROM sqlite_master")) + // Database is not closed: no error try snapshot.read { db in try db.execute(sql: "SELECT * FROM sqlite_master") diff --git a/Tests/GRDBTests/FailureTestCase.swift b/Tests/GRDBTests/FailureTestCase.swift index 9dfb9770ba..8f0a9b7638 100644 --- a/Tests/GRDBTests/FailureTestCase.swift +++ b/Tests/GRDBTests/FailureTestCase.swift @@ -1,209 +1,217 @@ // Inspired by https://github.com/groue/CombineExpectations import XCTest -/// A XCTestCase subclass that can test its own failures. -class FailureTestCase: XCTestCase { - private struct Failure: Hashable { - let issue: XCTIssue - - func issue(prefix: String = "") -> XCTIssue { - if prefix.isEmpty { - return issue - } else { - return XCTIssue( - type: issue.type, - compactDescription: "\(prefix): \(issue.compactDescription)", - detailedDescription: issue.detailedDescription, - sourceCodeContext: issue.sourceCodeContext, - associatedError: issue.associatedError, - attachments: issue.attachments) +#if canImport(Darwin) + /// A XCTestCase subclass that can test its own failures. + class FailureTestCase: XCTestCase { + private struct Failure: Hashable { + let issue: XCTIssue + + func issue(prefix: String = "") -> XCTIssue { + if prefix.isEmpty { + return issue + } else { + return XCTIssue( + type: issue.type, + compactDescription: "\(prefix): \(issue.compactDescription)", + detailedDescription: issue.detailedDescription, + sourceCodeContext: issue.sourceCodeContext, + associatedError: issue.associatedError, + attachments: issue.attachments) + } + } + + private var description: String { + return issue.compactDescription + } + + func hash(into hasher: inout Hasher) { + hasher.combine(0) + } + + static func == (lhs: Failure, rhs: Failure) -> Bool { + lhs.description.hasPrefix(rhs.description) + || rhs.description.hasPrefix(lhs.description) } } - - private var description: String { - return issue.compactDescription - } - - func hash(into hasher: inout Hasher) { - hasher.combine(0) - } - - static func == (lhs: Failure, rhs: Failure) -> Bool { - lhs.description.hasPrefix(rhs.description) || rhs.description.hasPrefix(lhs.description) - } - } - - private var recordedFailures: [Failure] = [] - private var isRecordingFailures = false - - func assertFailure(_ prefixes: String..., file: StaticString = #file, line: UInt = #line, _ execute: () throws -> Void) rethrows { - let recordedFailures = try recordingFailures(execute) - if prefixes.isEmpty { - if recordedFailures.isEmpty { - record(XCTIssue( - type: .assertionFailure, - compactDescription: "No failure did happen", - detailedDescription: nil, - sourceCodeContext: XCTSourceCodeContext( - location: XCTSourceCodeLocation( - filePath: String(describing: file), - lineNumber: Int(line))), - associatedError: nil, - attachments: [])) - } - } else { - let expectedFailures = prefixes.map { prefix -> Failure in - return Failure(issue: XCTIssue( - type: .assertionFailure, - compactDescription: prefix, - detailedDescription: nil, - sourceCodeContext: XCTSourceCodeContext( - location: XCTSourceCodeLocation( - filePath: String(describing: file), - lineNumber: Int(line))), - associatedError: nil, - attachments: [])) - } - assertMatch( - recordedFailures: recordedFailures, - expectedFailures: expectedFailures) + + private var recordedFailures: [Failure] = [] + private var isRecordingFailures = false + + func assertFailure( + _ prefixes: String..., file: StaticString = #file, line: UInt = #line, + _ execute: () throws -> Void + ) rethrows { + let recordedFailures = try recordingFailures(execute) + if prefixes.isEmpty { + if recordedFailures.isEmpty { + record( + XCTIssue( + type: .assertionFailure, + compactDescription: "No failure did happen", + detailedDescription: nil, + sourceCodeContext: XCTSourceCodeContext( + location: XCTSourceCodeLocation( + filePath: String(describing: file), + lineNumber: Int(line))), + associatedError: nil, + attachments: [])) + } + } else { + let expectedFailures = prefixes.map { prefix -> Failure in + return Failure( + issue: XCTIssue( + type: .assertionFailure, + compactDescription: prefix, + detailedDescription: nil, + sourceCodeContext: XCTSourceCodeContext( + location: XCTSourceCodeLocation( + filePath: String(describing: file), + lineNumber: Int(line))), + associatedError: nil, + attachments: [])) + } + assertMatch( + recordedFailures: recordedFailures, + expectedFailures: expectedFailures) + } } - } - - override func setUp() { - super.setUp() - isRecordingFailures = false - recordedFailures = [] - } - - override func record(_ issue: XCTIssue) { - if isRecordingFailures { - recordedFailures.append(Failure(issue: issue)) - } else { - super.record(issue) + + override func setUp() { + super.setUp() + isRecordingFailures = false + recordedFailures = [] } - } - - private func recordingFailures(_ execute: () throws -> Void) rethrows -> [Failure] { - let oldRecordingFailures = isRecordingFailures - let oldRecordedFailures = recordedFailures - defer { - isRecordingFailures = oldRecordingFailures - recordedFailures = oldRecordedFailures - } - isRecordingFailures = true - recordedFailures = [] - try execute() - let result = recordedFailures - return result - } - - private func assertMatch(recordedFailures: [Failure], expectedFailures: [Failure]) { - var recordedFailures = recordedFailures - var expectedFailures = expectedFailures - - while !recordedFailures.isEmpty { - let failure = recordedFailures.removeFirst() - if let index = expectedFailures.firstIndex(of: failure) { - expectedFailures.remove(at: index) + + override func record(_ issue: XCTIssue) { + if isRecordingFailures { + recordedFailures.append(Failure(issue: issue)) } else { - record(failure.issue()) + super.record(issue) } } - - while !expectedFailures.isEmpty { - let failure = expectedFailures.removeFirst() - if let index = recordedFailures.firstIndex(of: failure) { - recordedFailures.remove(at: index) - } else { - record(failure.issue(prefix: "Failure did not happen")) + + private func recordingFailures(_ execute: () throws -> Void) rethrows -> [Failure] { + let oldRecordingFailures = isRecordingFailures + let oldRecordedFailures = recordedFailures + defer { + isRecordingFailures = oldRecordingFailures + recordedFailures = oldRecordedFailures } + isRecordingFailures = true + recordedFailures = [] + try execute() + let result = recordedFailures + return result } - } -} -// MARK: - Tests + private func assertMatch(recordedFailures: [Failure], expectedFailures: [Failure]) { + var recordedFailures = recordedFailures + var expectedFailures = expectedFailures -class FailureTestCaseTests: FailureTestCase { - func testEmptyTest() { - } - - func testExpectedAnyFailure() { - assertFailure { - XCTFail("foo") - } - assertFailure { - XCTFail("foo") - XCTFail("bar") - } - } - - func testMissingAnyFailure() { - assertFailure("No failure did happen") { - assertFailure { + while !recordedFailures.isEmpty { + let failure = recordedFailures.removeFirst() + if let index = expectedFailures.firstIndex(of: failure) { + expectedFailures.remove(at: index) + } else { + record(failure.issue()) + } + } + + while !expectedFailures.isEmpty { + let failure = expectedFailures.removeFirst() + if let index = recordedFailures.firstIndex(of: failure) { + recordedFailures.remove(at: index) + } else { + record(failure.issue(prefix: "Failure did not happen")) + } } } } - - func testExpectedFailure() { - assertFailure("failed - foo") { - XCTFail("foo") + + // MARK: - Tests + + class FailureTestCaseTests: FailureTestCase { + func testEmptyTest() { } - } - - func testExpectedFailureMatchesOnPrefix() { - assertFailure("failed - foo") { - XCTFail("foobarbaz") + + func testExpectedAnyFailure() { + assertFailure { + XCTFail("foo") + } + assertFailure { + XCTFail("foo") + XCTFail("bar") + } } - } - - func testOrderOfExpectedFailureIsIgnored() { - assertFailure("failed - foo", "failed - bar") { - XCTFail("foo") - XCTFail("bar") + + func testMissingAnyFailure() { + assertFailure("No failure did happen") { + assertFailure { + } + } } - assertFailure("failed - bar", "failed - foo") { - XCTFail("foo") - XCTFail("bar") + + func testExpectedFailure() { + assertFailure("failed - foo") { + XCTFail("foo") + } } - } - - func testExpectedFailureCanBeRepeated() { - assertFailure("failed - foo", "failed - foo", "failed - bar") { - XCTFail("foo") - XCTFail("bar") - XCTFail("foo") + + func testExpectedFailureMatchesOnPrefix() { + assertFailure("failed - foo") { + XCTFail("foobarbaz") + } } - } - - func testExactNumberOfRepetitionIsRequired() { - assertFailure("Failure did not happen: failed - foo") { - assertFailure("failed - foo", "failed - foo") { + + func testOrderOfExpectedFailureIsIgnored() { + assertFailure("failed - foo", "failed - bar") { XCTFail("foo") + XCTFail("bar") } - } - assertFailure("failed - foo") { - assertFailure("failed - foo", "failed - foo") { + assertFailure("failed - bar", "failed - foo") { XCTFail("foo") + XCTFail("bar") + } + } + + func testExpectedFailureCanBeRepeated() { + assertFailure("failed - foo", "failed - foo", "failed - bar") { XCTFail("foo") + XCTFail("bar") XCTFail("foo") } } - } - - func testUnexpectedFailure() { - assertFailure("Failure did not happen: failed - foo") { + + func testExactNumberOfRepetitionIsRequired() { + assertFailure("Failure did not happen: failed - foo") { + assertFailure("failed - foo", "failed - foo") { + XCTFail("foo") + } + } assertFailure("failed - foo") { + assertFailure("failed - foo", "failed - foo") { + XCTFail("foo") + XCTFail("foo") + XCTFail("foo") + } } } - } - - func testMissedFailure() { - assertFailure("failed - bar") { - assertFailure("failed - foo") { - XCTFail("foo") - XCTFail("bar") + + func testUnexpectedFailure() { + assertFailure("Failure did not happen: failed - foo") { + assertFailure("failed - foo") { + } + } + } + + func testMissedFailure() { + assertFailure("failed - bar") { + assertFailure("failed - foo") { + XCTFail("foo") + XCTFail("bar") + } } } } -} +#endif diff --git a/Tests/GRDBTests/ValueObservationPrintTests.swift b/Tests/GRDBTests/ValueObservationPrintTests.swift index 9ed1a44333..5599e3c324 100644 --- a/Tests/GRDBTests/ValueObservationPrintTests.swift +++ b/Tests/GRDBTests/ValueObservationPrintTests.swift @@ -1,5 +1,6 @@ -import XCTest import Dispatch +import XCTest + @testable import GRDB class ValueObservationPrintTests: GRDBTestCase { @@ -10,7 +11,7 @@ class ValueObservationPrintTests: GRDBTestCase { stringsMutex.withLock { $0.append(string) } } } - + /// Helps dealing with various SQLite versions private func region(sql: String, in dbReader: some DatabaseReader) throws -> String { try dbReader.read { db in @@ -20,21 +21,22 @@ class ValueObservationPrintTests: GRDBTestCase { .description } } - + // MARK: - Readonly - + func test_readonly_success_asynchronousScheduling() throws { let dbPool = try makeDatabasePool(filename: "test") try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, @@ -43,35 +45,39 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "value: nil"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "value: nil", + ]) } } - + var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) + #endif } - + func test_readonly_success_immediateScheduling() throws { let dbPool = try makeDatabasePool(filename: "test") try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, @@ -80,36 +86,40 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "value: nil"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "value: nil", + ]) } } - + var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) + #endif } - + func test_readonly_failure_asynchronousScheduling() throws { - struct TestError: Error { } + struct TestError: Error {} let dbPool = try makeDatabasePool(filename: "test") // workaround Xcode 14.1 compiler (???) bug: database is not in the WAL // mode unless the pool is used. try dbPool.write { _ in } - + func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { _ in throw TestError() } .print(to: logger) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, @@ -118,36 +128,40 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "failure: TestError()"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "failure: TestError()", + ]) } } - + var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) + #endif } - + func test_readonly_failure_immediateScheduling() throws { - struct TestError: Error { } + struct TestError: Error {} let dbPool = try makeDatabasePool(filename: "test") // workaround Xcode 14.1 compiler (???) bug: database is not in the WAL // mode unless the pool is used. try dbPool.write { _ in } - + func test(_ dbReader: some DatabaseReader) throws { let logger = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { _ in throw TestError() } .print(to: logger) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbReader, @@ -156,37 +170,41 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "failure: TestError()"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "failure: TestError()", + ]) } } - + var config = dbConfiguration! config.readonly = true try test(makeDatabaseQueue(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config)) try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshot()) -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) -#endif + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + try test(makeDatabasePool(filename: "test", configuration: config).makeSnapshotPool()) + #endif } - + // MARK: - Writeonly - + func test_writeonly_success_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 @@ -199,36 +217,40 @@ class ValueObservationPrintTests: GRDBTestCase { try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings.prefix(7), [ - "start", - "fetch", - "tracked region: \(expectedRegion)", - "value: nil", - "database did change", - "fetch", - "value: Optional(1)"]) + XCTAssertEqual( + logger.strings.prefix(7), + [ + "start", + "fetch", + "tracked region: \(expectedRegion)", + "value: nil", + "database did change", + "fetch", + "value: Optional(1)", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func test_writeonly_success_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 @@ -241,32 +263,36 @@ class ValueObservationPrintTests: GRDBTestCase { try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings.prefix(7), [ - "start", - "fetch", - "tracked region: \(expectedRegion)", - "value: nil", - "database did change", - "fetch", - "value: Optional(1)"]) + XCTAssertEqual( + logger.strings.prefix(7), + [ + "start", + "fetch", + "tracked region: \(expectedRegion)", + "value: nil", + "database did change", + "fetch", + "value: Optional(1)", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func test_writeonly_immediateFailure_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, @@ -275,25 +301,29 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func test_writeonly_immediateFailure_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbWriter, @@ -302,29 +332,33 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func test_writeonly_lateFailure_asynchronousScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") let cancellable = observation.start( @@ -333,41 +367,46 @@ class ValueObservationPrintTests: GRDBTestCase { onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbWriter.write { db in - try db.execute(sql: """ - INSERT INTO player DEFAULT VALUES; - DROP TABLE player; - """) + try db.execute( + sql: """ + INSERT INTO player DEFAULT VALUES; + DROP TABLE player; + """) } - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "tracked region: \(expectedRegion)", - "value: nil", - "database did change", - "fetch", - "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "tracked region: \(expectedRegion)", + "value: nil", + "database did change", + "fetch", + "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func test_writeonly_lateFailure_immediateScheduling() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() - var observation = ValueObservation + var observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger) observation.requiresWriteAccess = true - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbWriter) let expectation = self.expectation(description: "") let cancellable = observation.start( @@ -376,43 +415,48 @@ class ValueObservationPrintTests: GRDBTestCase { onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbWriter.write { db in - try db.execute(sql: """ - INSERT INTO player DEFAULT VALUES; - DROP TABLE player; - """) + try db.execute( + sql: """ + INSERT INTO player DEFAULT VALUES; + DROP TABLE player; + """) } - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "tracked region: \(expectedRegion)", - "value: nil", - "database did change", - "fetch", - "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "tracked region: \(expectedRegion)", + "value: nil", + "database did change", + "fetch", + "failure: SQLite error 1: no such table: player - while executing `SELECT MAX(id) FROM player`", + ]) } } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + // MARK: - Concurrent - + func test_concurrent_success_asynchronousScheduling() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. let needsChangeMutex = Mutex(true) - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { db -> Int? in let needsChange = needsChangeMutex.withLock { needed in let wasNeeded = needed @@ -421,16 +465,17 @@ class ValueObservationPrintTests: GRDBTestCase { } if needsChange { try dbPool.write { db in - try db.execute(sql: """ - INSERT INTO player DEFAULT VALUES; - DELETE FROM player; - """) + try db.execute( + sql: """ + INSERT INTO player DEFAULT VALUES; + DELETE FROM player; + """) } } return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM player") } .print(to: logger) - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbPool) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 @@ -445,29 +490,33 @@ class ValueObservationPrintTests: GRDBTestCase { // "value: nil" is notified concurrently (from the reduce queue) // with the first "database did change" (from the writer queue). // That's why we test the sorted output. - XCTAssertEqual(logger.strings.sorted(), [ - "database did change", - "fetch", - "fetch", - "start", - "tracked region: \(expectedRegion)", - "value: nil", - "value: nil"]) + XCTAssertEqual( + logger.strings.sorted(), + [ + "database did change", + "fetch", + "fetch", + "start", + "tracked region: \(expectedRegion)", + "value: nil", + "value: nil", + ]) } } - + func test_concurrent_success_immediateScheduling() throws { let dbPool = try makeDatabasePool() try dbPool.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger = TestStream() // Force DatabasePool to perform two initial fetches, because between // its first read access, and its write access that installs the // transaction observer, some write did happen. let needsChangeMutex = Mutex(true) - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { db -> Int? in let needsChange = needsChangeMutex.withLock { needed in let wasNeeded = needed @@ -476,16 +525,17 @@ class ValueObservationPrintTests: GRDBTestCase { } if needsChange { try dbPool.write { db in - try db.execute(sql: """ - INSERT INTO player DEFAULT VALUES; - DELETE FROM player; - """) + try db.execute( + sql: """ + INSERT INTO player DEFAULT VALUES; + DELETE FROM player; + """) } } return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM player") } .print(to: logger) - + let expectedRegion = try region(sql: "SELECT MAX(id) FROM player", in: dbPool) let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 @@ -496,38 +546,43 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings, [ - "start", - "fetch", - "value: nil", - "database did change", - "fetch", - "tracked region: \(expectedRegion)", - "value: nil"]) + XCTAssertEqual( + logger.strings, + [ + "start", + "fetch", + "value: nil", + "database did change", + "fetch", + "tracked region: \(expectedRegion)", + "value: nil", + ]) } } - + // MARK: - Varying Database Region - + func test_varyingRegion() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in - try db.execute(sql: """ - CREATE TABLE a(id INTEGER PRIMARY KEY); - CREATE TABLE b(id INTEGER PRIMARY KEY); - CREATE TABLE choice(t TEXT); - INSERT INTO choice (t) VALUES ('a'); - """) - } - + try db.execute( + sql: """ + CREATE TABLE a(id INTEGER PRIMARY KEY); + CREATE TABLE b(id INTEGER PRIMARY KEY); + CREATE TABLE choice(t TEXT); + INSERT INTO choice (t) VALUES ('a'); + """) + } + let logger = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .tracking { db -> Int? in let table = try String.fetchOne(db, sql: "SELECT t FROM choice")! return try Int.fetchOne(db, sql: "SELECT MAX(id) FROM \(table)") } .print(to: logger) - + let expectedRegionA = try region(sql: "SELECT MAX(id) FROM a", in: dbQueue) let expectedRegionB = try region(sql: "SELECT MAX(id) FROM b", in: dbQueue) let expectation = self.expectation(description: "") @@ -538,46 +593,51 @@ class ValueObservationPrintTests: GRDBTestCase { onError: { _ in }, onChange: { _ in try! dbQueue.write { db in - try db.execute(sql: """ - UPDATE choice SET t = 'b'; - INSERT INTO a DEFAULT VALUES; - INSERT INTO b DEFAULT VALUES; - """) + try db.execute( + sql: """ + UPDATE choice SET t = 'b'; + INSERT INTO a DEFAULT VALUES; + INSERT INTO b DEFAULT VALUES; + """) } expectation.fulfill() - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger.strings.prefix(11), [ - "start", - "fetch", - "tracked region: \(expectedRegionA),choice(t)", - "value: nil", - "database did change", - "fetch", - "tracked region: \(expectedRegionB),choice(t)", - "value: Optional(1)", - "database did change", - "fetch", - "value: Optional(2)"]) + XCTAssertEqual( + logger.strings.prefix(11), + [ + "start", + "fetch", + "tracked region: \(expectedRegionA),choice(t)", + "value: nil", + "database did change", + "fetch", + "tracked region: \(expectedRegionB),choice(t)", + "value: Optional(1)", + "database did change", + "fetch", + "value: Optional(2)", + ]) } } - + // MARK: - Variations - + func test_prefix() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger1 = TestStream() let logger2 = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print("", to: logger1) .print("log", to: logger2) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, @@ -586,16 +646,22 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger1.strings.prefix(4), [ - "start", - "fetch", - "tracked region: player(*)", - "value: nil"]) - XCTAssertEqual(logger2.strings.prefix(4), [ - "log: start", - "log: fetch", - "log: tracked region: player(*)", - "log: value: nil"]) + XCTAssertEqual( + logger1.strings.prefix(4), + [ + "start", + "fetch", + "tracked region: player(*)", + "value: nil", + ]) + XCTAssertEqual( + logger2.strings.prefix(4), + [ + "log: start", + "log: fetch", + "log: tracked region: player(*)", + "log: value: nil", + ]) } } @@ -604,16 +670,17 @@ class ValueObservationPrintTests: GRDBTestCase { try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let logger1 = TestStream() let logger2 = TestStream() - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } .print(to: logger1) .removeDuplicates() .map { _ in "foo" } .print(to: logger2) - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, @@ -622,26 +689,32 @@ class ValueObservationPrintTests: GRDBTestCase { onChange: { _ in expectation.fulfill() }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) - XCTAssertEqual(logger1.strings.prefix(4), [ - "start", - "fetch", - "tracked region: player(*)", - "value: nil"]) - XCTAssertEqual(logger2.strings.prefix(4), [ - "start", - "fetch", - "tracked region: player(*)", - "value: foo"]) + XCTAssertEqual( + logger1.strings.prefix(4), + [ + "start", + "fetch", + "tracked region: player(*)", + "value: nil", + ]) + XCTAssertEqual( + logger2.strings.prefix(4), + [ + "start", + "fetch", + "tracked region: player(*)", + "value: foo", + ]) } } - + func test_handleEvents() throws { func waitFor(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( @@ -653,18 +726,18 @@ class ValueObservationPrintTests: GRDBTestCase { try db.execute(sql: "INSERT INTO player DEFAULT VALUES") } expectation.fulfill() - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) } } - + func waitForError(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, @@ -672,23 +745,24 @@ class ValueObservationPrintTests: GRDBTestCase { onError: { _ in expectation.fulfill() }, onChange: { _ in try! dbQueue.write { db in - try db.execute(sql: """ - INSERT INTO player DEFAULT VALUES; - DROP TABLE player; - """) + try db.execute( + sql: """ + INSERT INTO player DEFAULT VALUES; + DROP TABLE player; + """) } - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) } } - + func waitForCancel(_ observation: ValueObservation) throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { db in try db.execute(sql: "CREATE TABLE player(id INTEGER PRIMARY KEY)") } - + let expectation = self.expectation(description: "") let cancellable = observation.start( in: dbQueue, @@ -696,17 +770,17 @@ class ValueObservationPrintTests: GRDBTestCase { onError: { _ in }, onChange: { _ in expectation.fulfill() - }) + }) withExtendedLifetime(cancellable) { waitForExpectations(timeout: 1, handler: nil) cancellable.cancel() } } - + let observation = ValueObservation.trackingConstantRegion { try Int.fetchOne($0, sql: "SELECT MAX(id) FROM player") } - + do { let logger = TestStream() try waitFor(observation.handleEvents(willStart: { logger.write("start") })) diff --git a/Tests/GRDBTests/ValueObservationRecorderTests.swift b/Tests/GRDBTests/ValueObservationRecorderTests.swift index 4b8e8a2ae0..e946619d49 100644 --- a/Tests/GRDBTests/ValueObservationRecorderTests.swift +++ b/Tests/GRDBTests/ValueObservationRecorderTests.swift @@ -1,783 +1,835 @@ import Dispatch import XCTest -class ValueObservationRecorderTests: FailureTestCase { - // MARK: - NextOne - - func testNextOneSuccess() throws { - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - try XCTAssertEqual(recorder.next().get(), "foo") - recorder.onChange("bar") - try XCTAssertEqual(recorder.next().get(), "bar") - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onChange("bar") - try XCTAssertEqual(recorder.next().get(), "foo") - try XCTAssertEqual(recorder.next().get(), "bar") - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onChange("bar") - try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") - try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "bar") - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { +#if canImport(Darwin) + class ValueObservationRecorderTests: FailureTestCase { + // MARK: - NextOne + + func testNextOneSuccess() throws { + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + try XCTAssertEqual(recorder.next().get(), "foo") + recorder.onChange("bar") + try XCTAssertEqual(recorder.next().get(), "bar") + } + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + recorder.onChange("bar") + try XCTAssertEqual(recorder.next().get(), "foo") + try XCTAssertEqual(recorder.next().get(), "bar") + } + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + recorder.onChange("bar") + try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") + try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "bar") + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } } + try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") + try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "bar") } - try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") - try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "bar") } - } - - func testNextOneError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try recorder.next().get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try wait(for: recorder.next(), timeout: 0.1) - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - try XCTAssertEqual(recorder.next().get(), "foo") + + func testNextOneError() throws { + struct CustomError: Error {} do { + let recorder = ValueObservationRecorder() + recorder.onError(CustomError()) _ = try recorder.next().get() XCTFail("Expected error") - } catch is CustomError { } - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() recorder.onError(CustomError()) - } - _ = try wait(for: recorder.next(), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + _ = try wait(for: recorder.next(), timeout: 0.1) + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + recorder.onError(CustomError()) + try XCTAssertEqual(recorder.next().get(), "foo") + do { + _ = try recorder.next().get() + XCTFail("Expected error") + } catch is CustomError {} + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { recorder.onError(CustomError()) } - } - try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") - do { _ = try wait(for: recorder.next(), timeout: 0.5) XCTFail("Expected error") - } catch is CustomError { } - } - } - - func testNextOneTimeout() throws { - try assertFailure("Asynchronous wait failed") { - do { - let recorder = ValueObservationRecorder() - _ = try wait(for: recorder.next(), timeout: 0.1) - XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - } - try assertFailure("Asynchronous wait failed") { + } catch is CustomError {} do { let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } } try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") do { _ = try wait(for: recorder.next(), timeout: 0.5) XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } + } catch is CustomError {} } } - } - - func testNextOneNotEnoughElement() throws { - do { - let recorder = ValueObservationRecorder() - _ = try recorder.next().get() - XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - try XCTAssertEqual(recorder.next().get(), "foo") + + func testNextOneTimeout() throws { + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + _ = try wait(for: recorder.next(), timeout: 0.1) + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + } + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + } + try XCTAssertEqual(wait(for: recorder.next(), timeout: 0.5), "foo") + do { + _ = try wait(for: recorder.next(), timeout: 0.5) + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + } + } + } + + func testNextOneNotEnoughElement() throws { do { + let recorder = ValueObservationRecorder() _ = try recorder.next().get() XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - } - } - - // MARK: - NextOne Inverted - - func testNextOneInvertedSuccess() throws { - do { - let recorder = ValueObservationRecorder() - try recorder.next().inverted.get() - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - _ = try recorder.next().get() - try recorder.next().inverted.get() - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + } catch ValueRecordingError.notEnoughValues {} + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + try XCTAssertEqual(recorder.next().get(), "foo") + do { + _ = try recorder.next().get() + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} } - _ = try wait(for: recorder.next(), timeout: 0.5) - try wait(for: recorder.next().inverted, timeout: 0.5) } - } - - func testNextOneInvertedError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - try recorder.next().inverted.get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - _ = try recorder.next().get() + + // MARK: - NextOne Inverted + + func testNextOneInvertedSuccess() throws { do { + let recorder = ValueObservationRecorder() try recorder.next().inverted.get() - XCTFail("Expected error") - } catch is CustomError { } - } - } - - func testNextOneInvertedTimeout() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") - } - try assertFailure("Fulfilled inverted expectation") { - try wait(for: recorder.next().inverted, timeout: 0.5) } - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + _ = try recorder.next().get() + try recorder.next().inverted.get() + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") + recorder.onChange("foo") } - } - _ = try wait(for: recorder.next(), timeout: 0.5) - try assertFailure("Fulfilled inverted expectation") { + _ = try wait(for: recorder.next(), timeout: 0.5) try wait(for: recorder.next().inverted, timeout: 0.5) } } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + func testNextOneInvertedError() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() recorder.onError(CustomError()) - } - try assertFailure("Fulfilled inverted expectation") { + try recorder.next().inverted.get() + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + recorder.onError(CustomError()) + _ = try recorder.next().get() do { - try wait(for: recorder.next().inverted, timeout: 0.5) + try recorder.next().inverted.get() XCTFail("Expected error") - } catch is CustomError { } + } catch is CustomError {} } } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") + + func testNextOneInvertedTimeout() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + recorder.onChange("foo") + } + try assertFailure("Fulfilled inverted expectation") { + try wait(for: recorder.next().inverted, timeout: 0.5) } } - _ = try wait(for: recorder.next(), timeout: 0.5) - try assertFailure("Fulfilled inverted expectation") { - do { + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } + } + _ = try wait(for: recorder.next(), timeout: 0.5) + try assertFailure("Fulfilled inverted expectation") { try wait(for: recorder.next().inverted, timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } + } } - } - } - - // MARK: - Next - - func testNextSuccess() throws { - do { - let recorder = ValueObservationRecorder() - try XCTAssertEqual(recorder.next(0).get(), []) - recorder.onChange("foo") - try XCTAssertEqual(recorder.next(1).get(), ["foo"]) - recorder.onChange("bar") - recorder.onChange("baz") - try XCTAssertEqual(recorder.next(2).get(), ["bar", "baz"]) - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onChange("bar") - try XCTAssertEqual(recorder.next(1).get(), ["foo"]) - recorder.onChange("baz") - try XCTAssertEqual(recorder.next(2).get(), ["bar", "baz"]) - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + try assertFailure("Fulfilled inverted expectation") { + do { + try wait(for: recorder.next().inverted, timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} + } + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + } + _ = try wait(for: recorder.next(), timeout: 0.5) + try assertFailure("Fulfilled inverted expectation") { + do { + try wait(for: recorder.next().inverted, timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} } } - try XCTAssertEqual(wait(for: recorder.next(2), timeout: 0.5), ["foo", "bar"]) } - } - - func testNextError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try recorder.next(2).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - _ = try recorder.next(2).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + + // MARK: - Next + + func testNextSuccess() throws { + do { + let recorder = ValueObservationRecorder() + try XCTAssertEqual(recorder.next(0).get(), []) + recorder.onChange("foo") + try XCTAssertEqual(recorder.next(1).get(), ["foo"]) + recorder.onChange("bar") + recorder.onChange("baz") + try XCTAssertEqual(recorder.next(2).get(), ["bar", "baz"]) } - _ = try wait(for: recorder.next(2), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + recorder.onChange("bar") + try XCTAssertEqual(recorder.next(1).get(), ["foo"]) + recorder.onChange("baz") + try XCTAssertEqual(recorder.next(2).get(), ["bar", "baz"]) + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } } + try XCTAssertEqual(wait(for: recorder.next(2), timeout: 0.5), ["foo", "bar"]) } - _ = try wait(for: recorder.next(2), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - } - - func testNextTimeout() throws { - try assertFailure("Asynchronous wait failed") { + } + + func testNextError() throws { + struct CustomError: Error {} do { let recorder = ValueObservationRecorder() - _ = try wait(for: recorder.next(2), timeout: 0.1) + recorder.onError(CustomError()) + _ = try recorder.next(2).get() XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - } - try assertFailure("Asynchronous wait failed") { + } catch is CustomError {} do { let recorder = ValueObservationRecorder() recorder.onChange("foo") - _ = try wait(for: recorder.next(2), timeout: 0.1) + recorder.onError(CustomError()) + _ = try recorder.next(2).get() XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - } - try assertFailure("Asynchronous wait failed") { + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + _ = try wait(for: recorder.next(2), timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} do { let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } } _ = try wait(for: recorder.next(2), timeout: 0.5) XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } + } catch is CustomError {} } - } - - func testNextNotEnoughElement() throws { - do { - let recorder = ValueObservationRecorder() - _ = try recorder.next(2).get() - XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - _ = try recorder.next(2).get() - XCTFail("Expected error") - } catch ValueRecordingError.notEnoughValues { } - } - - // MARK: - Prefix(maxLength) - - func testPrefixMaxLengthSuccess() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - try XCTAssertEqual(recorder.prefix(0).get(), []) - recorder.onChange("foo") - try XCTAssertEqual(recorder.prefix(0).get(), []) - try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) - recorder.onChange("bar") - recorder.onChange("baz") - try XCTAssertEqual(recorder.prefix(0).get(), []) - try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) - try XCTAssertEqual(recorder.prefix(2).get(), ["foo", "bar"]) - try XCTAssertEqual(recorder.prefix(3).get(), ["foo", "bar", "baz"]) - } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - try XCTAssertEqual(recorder.prefix(0).get(), []) + + func testNextTimeout() throws { + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + _ = try wait(for: recorder.next(2), timeout: 0.1) + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + } + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + _ = try wait(for: recorder.next(2), timeout: 0.1) + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + } + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + } + _ = try wait(for: recorder.next(2), timeout: 0.5) + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + } } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) + + func testNextNotEnoughElement() throws { + do { + let recorder = ValueObservationRecorder() + _ = try recorder.next(2).get() + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + _ = try recorder.next(2).get() + XCTFail("Expected error") + } catch ValueRecordingError.notEnoughValues {} } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + // MARK: - Prefix(maxLength) + + func testPrefixMaxLengthSuccess() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() + try XCTAssertEqual(recorder.prefix(0).get(), []) recorder.onChange("foo") - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") - } + try XCTAssertEqual(recorder.prefix(0).get(), []) + try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) + recorder.onChange("bar") + recorder.onChange("baz") + try XCTAssertEqual(recorder.prefix(0).get(), []) + try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) + try XCTAssertEqual(recorder.prefix(2).get(), ["foo", "bar"]) + try XCTAssertEqual(recorder.prefix(3).get(), ["foo", "bar", "baz"]) } - try XCTAssertEqual(wait(for: recorder.prefix(2), timeout: 0.5), ["foo", "bar"]) - } - } - - func testPrefixMaxLengthError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try recorder.prefix(2).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - _ = try recorder.prefix(2).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + do { + let recorder = ValueObservationRecorder() recorder.onError(CustomError()) + try XCTAssertEqual(recorder.prefix(0).get(), []) } - _ = try wait(for: recorder.prefix(2), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + recorder.onError(CustomError()) + try XCTAssertEqual(recorder.prefix(1).get(), ["foo"]) + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } } + try XCTAssertEqual(wait(for: recorder.prefix(2), timeout: 0.5), ["foo", "bar"]) } - _ = try wait(for: recorder.prefix(2), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - } - - func testPrefixMaxLengthTimeout() throws { - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - let values = try wait(for: recorder.prefix(2), timeout: 0.1) - XCTAssertEqual(values, []) } - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - let values = try wait(for: recorder.prefix(2), timeout: 0.1) - XCTAssertEqual(values, ["foo"]) - } - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + func testPrefixMaxLengthError() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() + recorder.onError(CustomError()) + _ = try recorder.prefix(2).get() + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") - } - let values = try wait(for: recorder.prefix(2), timeout: 0.5) - XCTAssertEqual(values, ["foo"]) - } - } - - // MARK: - Prefix(maxLength) Inverted - - func testPrefixMaxLengthInvertedSuccess() throws { - do { - let recorder = ValueObservationRecorder() - try XCTAssertEqual(recorder.prefix(1).inverted.get(), []) - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - try XCTAssertEqual(recorder.prefix(2).inverted.get(), ["foo"]) + recorder.onError(CustomError()) + _ = try recorder.prefix(2).get() + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + _ = try wait(for: recorder.prefix(2), timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + } + _ = try wait(for: recorder.prefix(2), timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") + + func testPrefixMaxLengthTimeout() throws { + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() + let values = try wait(for: recorder.prefix(2), timeout: 0.1) + XCTAssertEqual(values, []) } - try XCTAssertEqual(wait(for: recorder.prefix(2).inverted, timeout: 0.5), ["foo"]) - } - } - - func testPrefixMaxLengthInvertedError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try recorder.prefix(1).inverted.get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - _ = try recorder.prefix(2).inverted.get() - XCTFail("Expected error") - } catch is CustomError { } - } - - func testPrefixMaxLengthInvertedTimeout() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + let values = try wait(for: recorder.prefix(2), timeout: 0.1) + XCTAssertEqual(values, ["foo"]) } - try assertFailure("Fulfilled inverted expectation") { - _ = try wait(for: recorder.prefix(1).inverted, timeout: 0.5) + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + } + let values = try wait(for: recorder.prefix(2), timeout: 0.5) + XCTAssertEqual(values, ["foo"]) } } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + // MARK: - Prefix(maxLength) Inverted + + func testPrefixMaxLengthInvertedSuccess() throws { + do { + let recorder = ValueObservationRecorder() + try XCTAssertEqual(recorder.prefix(1).inverted.get(), []) + } + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + try XCTAssertEqual(recorder.prefix(2).inverted.get(), ["foo"]) + } + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") + recorder.onChange("foo") } - } - try assertFailure("Fulfilled inverted expectation") { - _ = try wait(for: recorder.prefix(2).inverted, timeout: 0.5) + try XCTAssertEqual(wait(for: recorder.prefix(2).inverted, timeout: 0.5), ["foo"]) } } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + func testPrefixMaxLengthInvertedError() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() recorder.onError(CustomError()) - } - try assertFailure("Fulfilled inverted expectation") { - do { - _ = try wait(for: recorder.prefix(1).inverted, timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - } - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + _ = try recorder.prefix(1).inverted.get() + XCTFail("Expected error") + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + recorder.onError(CustomError()) + _ = try recorder.prefix(2).inverted.get() + XCTFail("Expected error") + } catch is CustomError {} + } + + func testPrefixMaxLengthInvertedTimeout() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + recorder.onChange("foo") + } + try assertFailure("Fulfilled inverted expectation") { + _ = try wait(for: recorder.prefix(1).inverted, timeout: 0.5) } } - try assertFailure("Fulfilled inverted expectation") { - do { + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } + } + try assertFailure("Fulfilled inverted expectation") { _ = try wait(for: recorder.prefix(2).inverted, timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } + } } - } - } - - // MARK: - Prefix(maxLength) - - func testPrefixUntilSuccess() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) - recorder.onChange("bar") - recorder.onChange("baz") - try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) - try XCTAssertEqual(recorder.prefix(until: { $0 == "bar" }).get(), ["foo", "bar"]) - try XCTAssertEqual(recorder.prefix(until: { $0 == "baz" }).get(), ["foo", "bar", "baz"]) - } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) - } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("bar") + recorder.onError(CustomError()) + } + try assertFailure("Fulfilled inverted expectation") { + do { + _ = try wait(for: recorder.prefix(1).inverted, timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} } } - try XCTAssertEqual(wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5), ["foo", "bar"]) - } - } - - func testPrefixUntilError() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - _ = try recorder.prefix(until: { $0 == "bar" }).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - recorder.onError(CustomError()) - _ = try recorder.prefix(until: { $0 == "bar" }).get() - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) - } - _ = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - do { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onChange("foo") + do { + let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { - recorder.onError(CustomError()) + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + } + try assertFailure("Fulfilled inverted expectation") { + do { + _ = try wait(for: recorder.prefix(2).inverted, timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} } } - _ = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) - XCTFail("Expected error") - } catch is CustomError { } - } - - func testPrefixUntilTimeout() throws { - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.1) - XCTAssertEqual(values, []) - } - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.1) - XCTAssertEqual(values, ["foo"]) } - try assertFailure("Asynchronous wait failed") { - let recorder = ValueObservationRecorder() - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + + // MARK: - Prefix(maxLength) + + func testPrefixUntilSuccess() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() recorder.onChange("foo") + try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) + recorder.onChange("bar") + recorder.onChange("baz") + try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) + try XCTAssertEqual(recorder.prefix(until: { $0 == "bar" }).get(), ["foo", "bar"]) + try XCTAssertEqual( + recorder.prefix(until: { $0 == "baz" }).get(), ["foo", "bar", "baz"]) + } + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + recorder.onError(CustomError()) + try XCTAssertEqual(recorder.prefix(until: { $0 == "foo" }).get(), ["foo"]) + } + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("bar") + } + } + try XCTAssertEqual( + wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5), + ["foo", "bar"]) } - let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) - XCTAssertEqual(values, ["foo"]) - } - } - - // MARK: - Failure - - func testFailureSuccess() throws { - struct CustomError: Error { } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - let (elements, error) = try recorder.failure().get() - XCTAssertEqual(elements, []) - XCTAssert(error is CustomError) - } - do { - let recorder = ValueObservationRecorder() - recorder.onError(CustomError()) - let (elements, error) = try wait(for: recorder.failure(), timeout: 0.1) - XCTAssertEqual(elements, []) - XCTAssert(error is CustomError) } - } - - func testFailureTimeout() throws { - try assertFailure("Asynchronous wait failed") { + + func testPrefixUntilError() throws { + struct CustomError: Error {} do { let recorder = ValueObservationRecorder() - _ = try wait(for: recorder.failure(), timeout: 0.1) + recorder.onError(CustomError()) + _ = try recorder.prefix(until: { $0 == "bar" }).get() XCTFail("Expected error") - } catch ValueRecordingError.notFailed { } - } - try assertFailure("Asynchronous wait failed") { + } catch is CustomError {} do { let recorder = ValueObservationRecorder() recorder.onChange("foo") - _ = try wait(for: recorder.failure(), timeout: 0.1) + recorder.onError(CustomError()) + _ = try recorder.prefix(until: { $0 == "bar" }).get() XCTFail("Expected error") - } catch ValueRecordingError.notFailed { } - } - try assertFailure("Asynchronous wait failed") { + } catch is CustomError {} + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } + _ = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) + XCTFail("Expected error") + } catch is CustomError {} do { let recorder = ValueObservationRecorder() DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { recorder.onChange("foo") + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onError(CustomError()) + } } - _ = try wait(for: recorder.failure(), timeout: 0.5) + _ = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) XCTFail("Expected error") - } catch ValueRecordingError.notFailed { } + } catch is CustomError {} } - } - - func testFailureNotFailed() throws { - do { - let recorder = ValueObservationRecorder() - _ = try recorder.failure().get() - XCTFail("Expected error") - } catch ValueRecordingError.notFailed { } - do { - let recorder = ValueObservationRecorder() - recorder.onChange("foo") - _ = try recorder.failure().get() - XCTFail("Expected error") - } catch ValueRecordingError.notFailed { } - } - - // MARK: - assertValueObservationRecordingMatch - - func testAssertValueObservationRecordingMatch() { - do { - let expected = [3] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) - } - do { - let expected = [2, 3] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 2, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 2, 3, 3], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [3, 2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [1, 3], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) - } - do { - let expected = [3, 3] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3, 3], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2, 2, 3], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) + + func testPrefixUntilTimeout() throws { + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() + let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.1) + XCTAssertEqual(values, []) + } + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.1) + XCTAssertEqual(values, ["foo"]) + } + try assertFailure("Asynchronous wait failed") { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + } + let values = try wait(for: recorder.prefix(until: { $0 == "bar" }), timeout: 0.5) + XCTAssertEqual(values, ["foo"]) + } } - do { - let expected = [1, 2, 2, 3] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 1, 3, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 2, 3], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [3, 2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2, 1, 3], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [0, 3], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [0, 1, 2, 3], expected: expected)) + + // MARK: - Failure + + func testFailureSuccess() throws { + struct CustomError: Error {} + do { + let recorder = ValueObservationRecorder() + recorder.onError(CustomError()) + let (elements, error) = try recorder.failure().get() + XCTAssertEqual(elements, []) + XCTAssert(error is CustomError) + } + do { + let recorder = ValueObservationRecorder() + recorder.onError(CustomError()) + let (elements, error) = try wait(for: recorder.failure(), timeout: 0.1) + XCTAssertEqual(elements, []) + XCTAssert(error is CustomError) + } } - do { - let expected = [1, 2, 1] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 1, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 1, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 2, 1], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [1, 2], expected: expected)) - XCTAssertFalse(valueObservationRecordingMatch(recorded: [0, 1], expected: expected)) + + func testFailureTimeout() throws { + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + _ = try wait(for: recorder.failure(), timeout: 0.1) + XCTFail("Expected error") + } catch ValueRecordingError.notFailed {} + } + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + _ = try wait(for: recorder.failure(), timeout: 0.1) + XCTFail("Expected error") + } catch ValueRecordingError.notFailed {} + } + try assertFailure("Asynchronous wait failed") { + do { + let recorder = ValueObservationRecorder() + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + recorder.onChange("foo") + } + _ = try wait(for: recorder.failure(), timeout: 0.5) + XCTFail("Expected error") + } catch ValueRecordingError.notFailed {} + } } - do { - let expected = [1, 2, 3, 2, 1] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 3, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 1], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3, 1], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [3, 1, 2, 1], expected: expected)) + + func testFailureNotFailed() throws { + do { + let recorder = ValueObservationRecorder() + _ = try recorder.failure().get() + XCTFail("Expected error") + } catch ValueRecordingError.notFailed {} + do { + let recorder = ValueObservationRecorder() + recorder.onChange("foo") + _ = try recorder.failure().get() + XCTFail("Expected error") + } catch ValueRecordingError.notFailed {} } - do { - let expected = [1, 2, 1, 3, 1, 4] - XCTAssertTrue(valueObservationRecordingMatch(recorded: [4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 2, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 1, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 1, 1, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 1, 3, 4], expected: expected)) - XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3, 1, 4], expected: expected)) - - XCTAssertFalse(valueObservationRecordingMatch(recorded: [3, 2, 4], expected: expected)) + + // MARK: - assertValueObservationRecordingMatch + + func testAssertValueObservationRecordingMatch() { + do { + let expected = [3] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) + + XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) + XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) + } + do { + let expected = [2, 3] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [3, 3, 3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 2, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 2, 3, 3], expected: expected)) + + XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) + XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [3, 2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [1, 3], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) + } + do { + let expected = [3, 3] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [3, 3, 3], expected: expected)) + + XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) + XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [2, 3], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [2, 2, 3], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) + } + do { + let expected = [1, 2, 2, 3] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 3], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 3, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 1, 3, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 2, 3], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 3], expected: expected)) + + XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) + XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [3, 2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [2, 1, 3], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [0, 3], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [0, 1, 2, 3], expected: expected)) + } + do { + let expected = [1, 2, 1] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [1], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 1], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 1, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 1, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 2, 1], expected: expected)) + + XCTAssertFalse(valueObservationRecordingMatch(recorded: [], expected: expected)) + XCTAssertFalse(valueObservationRecordingMatch(recorded: [2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [1, 2], expected: expected)) + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [0, 1], expected: expected)) + } + do { + let expected = [1, 2, 3, 2, 1] + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 3, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 3, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 3, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [3, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 3, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 1], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 3, 1], expected: expected)) + + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [3, 1, 2, 1], expected: expected)) + } + do { + let expected = [1, 2, 1, 3, 1, 4] + XCTAssertTrue(valueObservationRecordingMatch(recorded: [4], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [1, 4], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [2, 4], expected: expected)) + XCTAssertTrue(valueObservationRecordingMatch(recorded: [3, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 3, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [3, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 3, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 2, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 1, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [1, 3, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [3, 1, 1, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 1, 3, 4], expected: expected)) + XCTAssertTrue( + valueObservationRecordingMatch(recorded: [2, 3, 1, 4], expected: expected)) + + XCTAssertFalse( + valueObservationRecordingMatch(recorded: [3, 2, 4], expected: expected)) + } } } -} +#endif diff --git a/Tests/GRDBTests/ValueObservationTests.swift b/Tests/GRDBTests/ValueObservationTests.swift index ae5c09ff83..34dd4b4071 100644 --- a/Tests/GRDBTests/ValueObservationTests.swift +++ b/Tests/GRDBTests/ValueObservationTests.swift @@ -1,24 +1,27 @@ -import XCTest import Dispatch +import XCTest + @testable import GRDB class ValueObservationTests: GRDBTestCase { // Test passes if it compiles. // See func testStartFromAnyDatabaseReader(reader: any DatabaseReader) { - _ = ValueObservation + _ = + ValueObservation .trackingConstantRegion { _ in } - .start(in: reader, onError: { _ in }, onChange: { }) + .start(in: reader, onError: { _ in }, onChange: {}) } - + // Test passes if it compiles. // See func testStartFromAnyDatabaseWriter(writer: any DatabaseWriter) { - _ = ValueObservation + _ = + ValueObservation .trackingConstantRegion { _ in } - .start(in: writer, onError: { _ in }, onChange: { }) + .start(in: writer, onError: { _ in }, onChange: {}) } - + // Test passes if it compiles. // See func testValuesFromAnyDatabaseWriter(writer: any DatabaseWriter) { @@ -28,14 +31,14 @@ class ValueObservationTests: GRDBTestCase { ValueObservation.tracking(fetch).values(in: writer) } } - + func testImmediateError() throws { - struct TestError: Error { } - + struct TestError: Error {} + func test(_ dbWriter: some DatabaseWriter) throws { // Create an observation let observation = ValueObservation.trackingConstantRegion { _ in throw TestError() } - + // Start observation let errorMutex: Mutex = Mutex(nil) _ = observation.start( @@ -47,32 +50,34 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) XCTAssertNotNil(errorMutex.load()) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func testErrorCompletesTheObservation() throws { - struct TestError: Error { } - + 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)") } - + 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 - - let nextErrorMutex: Mutex = Mutex(nil) // If not null, observation throws an error + + let nextErrorMutex: Mutex = Mutex(nil) // If not null, observation throws an error let observation = ValueObservation.trackingConstantRegion { _ = try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t") try nextErrorMutex.withLock { error in if let error { throw error } } } - + // Start observation let errorCaughtMutex = Mutex(false) let cancellable = observation.start( @@ -90,39 +95,41 @@ class ValueObservationTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t DEFAULT VALUES") } }) - + withExtendedLifetime(cancellable) { waitForExpectations(timeout: 2, handler: nil) XCTAssertTrue(errorCaughtMutex.load()) } } - + 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 - """) + try $0.execute( + sql: """ + CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); + CREATE VIEW v AS SELECT * FROM t + """) } - + // Test that view v is included in the request region let request = SQLRequest(sql: "SELECT name FROM v ORDER BY id") try dbQueue.inDatabase { db in let region = try request.databaseRegion(db) XCTAssertEqual(region.description, "t(id,name),v(id,name)") } - + // Test that view v is not included in the observed region. // This optimization helps observation of views that feed from a // single table. let regionMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "") - let observation = ValueObservation + let observation = + ValueObservation .trackingConstantRegion { _ = try request.fetchAll($0) } .handleEvents(willTrackRegion: { region in regionMutex.store(region) @@ -134,29 +141,31 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(regionMutex.load()!.description, "t(id,name)") // view is NOT tracked + XCTAssertEqual(regionMutex.load()!.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); - """) + try $0.execute( + sql: """ + CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); + """) } - - struct T: TableRecord { } - + + 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. let regionMutex: Mutex = Mutex(nil) let expectation = self.expectation(description: "") - let observation = ValueObservation - .trackingConstantRegion{ _ = try request.fetchAll($0) } + let observation = + ValueObservation + .trackingConstantRegion { _ = try request.fetchAll($0) } .handleEvents(willTrackRegion: { region in regionMutex.store(region) expectation.fulfill() @@ -167,12 +176,12 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) withExtendedLifetime(observer) { waitForExpectations(timeout: 2, handler: nil) - XCTAssertEqual(regionMutex.load()?.description, "t(id,name)[1]") // pragma_table_xinfo is NOT tracked + XCTAssertEqual(regionMutex.load()?.description, "t(id,name)[1]") // pragma_table_xinfo is NOT tracked } } - + // MARK: - Constant Explicit Region - + func testTrackingExplicitRegion() throws { class TestStream: TextOutputStream { private var stringsMutex: Mutex<[String]> = Mutex([]) @@ -181,7 +190,7 @@ class ValueObservationTests: GRDBTestCase { stringsMutex.withLock { $0.append(string) } } } - + // Test behavior with DatabaseQueue, DatabasePool, etc do { try assertValueObservation( @@ -189,9 +198,10 @@ class ValueObservationTests: GRDBTestCase { .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); - """) + try db.execute( + sql: """ + CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT); + """) }, recordedUpdates: { db in try db.execute(sql: "INSERT INTO t DEFAULT VALUES") @@ -206,23 +216,25 @@ class ValueObservationTests: GRDBTestCase { 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); - """) + 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) - + fetch: Table("t").fetchCount + ) + .print(to: logger) + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( @@ -233,40 +245,45 @@ class ValueObservationTests: GRDBTestCase { }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in - try db.execute(sql: """ - INSERT INTO other DEFAULT VALUES; - INSERT INTO t DEFAULT VALUES; - """) + 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", - ]) + 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); - """) + 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) - + fetch: Table("t").fetchCount + ) + .print(to: logger) + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 2 let cancellable = observation.start( @@ -277,40 +294,45 @@ class ValueObservationTests: GRDBTestCase { }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in - try db.execute(sql: """ - INSERT INTO other DEFAULT VALUES; - INSERT INTO t DEFAULT VALUES; - """) + 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", - ]) + 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); - """) + 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) - + fetch: Table("t").fetchCount + ) + .print(to: logger) + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = 3 let cancellable = observation.start( @@ -321,36 +343,39 @@ class ValueObservationTests: GRDBTestCase { }) try withExtendedLifetime(cancellable) { try dbQueue.writeWithoutTransaction { db in - try db.execute(sql: """ - INSERT INTO other DEFAULT VALUES; - INSERT INTO t DEFAULT VALUES; - """) + 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", - ]) + 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. @@ -363,15 +388,16 @@ class ValueObservationTests: GRDBTestCase { } if needsChange { try dbPool.write { db in - try db.execute(sql: """ - INSERT INTO t DEFAULT VALUES; - DELETE FROM t; - """) + 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 let observedCountsMutex: Mutex<[Int]> = Mutex([]) @@ -388,13 +414,13 @@ class ValueObservationTests: GRDBTestCase { XCTAssertEqual(observedCountsMutex.load(), [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. @@ -407,15 +433,16 @@ class ValueObservationTests: GRDBTestCase { } if needsChange { try dbPool.write { db in - try db.execute(sql: """ - INSERT INTO t DEFAULT VALUES; - DELETE FROM t; - """) + 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 let observedCountsMutex: Mutex<[Int]> = Mutex([]) @@ -432,13 +459,13 @@ class ValueObservationTests: GRDBTestCase { XCTAssertEqual(observedCountsMutex.load(), [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. @@ -452,24 +479,25 @@ class ValueObservationTests: GRDBTestCase { if needsChange { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in - try db.execute(sql: """ - INSERT INTO t DEFAULT VALUES; - """) + 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) - // Optimization available - expectedCounts = [0, 1] -#else - // Optimization not available - expectedCounts = [0, 0, 1] -#endif - + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // Optimization available + expectedCounts = [0, 1] + #else + // Optimization not available + expectedCounts = [0, 0, 1] + #endif + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count let observedCountsMutex: Mutex<[Int]> = Mutex([]) @@ -486,13 +514,13 @@ class ValueObservationTests: GRDBTestCase { XCTAssertEqual(observedCountsMutex.load(), 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. @@ -506,24 +534,25 @@ class ValueObservationTests: GRDBTestCase { if needsChange { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { try! dbPool.write { db in - try db.execute(sql: """ - INSERT INTO t DEFAULT VALUES; - """) + 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) - // Optimization available - expectedCounts = [0, 1] -#else - // Optimization not available - expectedCounts = [0, 0, 1] -#endif - + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + // Optimization available + expectedCounts = [0, 1] + #else + // Optimization not available + expectedCounts = [0, 0, 1] + #endif + let expectation = self.expectation(description: "") expectation.expectedFulfillmentCount = expectedCounts.count let observedCountsMutex: Mutex<[Int]> = Mutex([]) @@ -540,49 +569,53 @@ class ValueObservationTests: GRDBTestCase { XCTAssertEqual(observedCountsMutex.load(), expectedCounts) } } - + // MARK: - Snapshot Observation - -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - 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) + + #if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) && !os(Linux) + 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) } - - 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 - + #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.expectedFulfillmentCount = 2 // initial value and change undetectedExpectation.isInverted = true let detectedExpectation = expectation(description: "detected") - detectedExpectation.expectedFulfillmentCount = 2 // initial value and change - + detectedExpectation.expectedFulfillmentCount = 2 // initial value and change + let observation = ValueObservation.tracking { db in try Table("test").fetchCount(db) } @@ -602,7 +635,7 @@ class ValueObservationTests: GRDBTestCase { 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")) @@ -610,25 +643,27 @@ class ValueObservationTests: GRDBTestCase { 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)") } - + try dbQueue.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + // Track reducer process let changesCountMutex = Mutex(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 nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? cancellable = observation.start( @@ -640,40 +675,42 @@ class ValueObservationTests: GRDBTestCase { } 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(changesCountMutex.load(), 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)") } - + try dbQueue.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + // Track reducer process let changesCountMutex = Mutex(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 nonisolated(unsafe) var cancellable: (any DatabaseCancellable)! cancellable = observation.start( @@ -685,35 +722,37 @@ class ValueObservationTests: GRDBTestCase { } 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(changesCountMutex.load(), 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)") } - + 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 { nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? = nil - _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning + _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning let shouldStopObservationMutex = Mutex(false) let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, @@ -737,29 +776,31 @@ class ValueObservationTests: GRDBTestCase { 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)") } - + 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 { nonisolated(unsafe) var cancellable: (any DatabaseCancellable)? = nil - _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning + _ = cancellable // Avoid "Variable 'cancellable' was written to, but never read" warning let shouldStopObservationMutex = Mutex(false) let observation = ValueObservation( trackingMode: .nonConstantRegionRecordedFromSelection, @@ -769,7 +810,8 @@ class ValueObservationTests: GRDBTestCase { value: { _ in shouldStopObservationMutex.withLock { shouldStopObservation in if shouldStopObservation { - cancellable = nil /* deallocation right before notification */ + cancellable = + nil /* deallocation right before notification */ } shouldStopObservation = true } @@ -784,31 +826,34 @@ class ValueObservationTests: GRDBTestCase { 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)") } - + try writer.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + // Start observing let countsMutex: Mutex<[Int]> = Mutex([]) - let cancellable = ValueObservation + let cancellable = + ValueObservation .trackingConstantRegion { try Table("t").fetchCount($0) } .start(in: writer) { error in XCTFail("Unexpected error: \(error)") } onChange: { count in countsMutex.withLock { $0.append(count) } } - + // Perform a write after cancellation, but before the // observation could schedule the removal of its transaction // observer from the writer dispatch queue. @@ -823,7 +868,7 @@ class ValueObservationTests: GRDBTestCase { } cancellable.cancel() semaphore.signal() - + // Wait until a second write could run, after the observation // has removed its transaction observer from the writer // dispatch queue. @@ -832,33 +877,33 @@ class ValueObservationTests: GRDBTestCase { secondWriteExpectation.fulfill() } wait(for: [secondWriteExpectation], timeout: 5) - + // We should not have been notified of the first write, because // it was performed after cancellation. XCTAssertFalse(countsMutex.load().contains(1)) } - + try test(makeDatabaseQueue()) try test(makeDatabasePool()) } - + func testIssue1209() throws { func test(_ dbWriter: some DatabaseWriter) throws { try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } - + // We'll start N observations let N = 100 - + // We'll wait for initial value notification before modifying the database let initialValueExpectation = expectation(description: "") initialValueExpectation.expectedFulfillmentCount = N - + // The test will pass if we get change notifications let changeExpectation = expectation(description: "") changeExpectation.expectedFulfillmentCount = N - + // Observe N times let cancellables = (0.. [Int] in - var counts: [Int] = [] - let observation = ValueObservation - .trackingConstantRegion(Table("t").fetchCount) - .handleEvents(didCancel: { cancellationExpectation.fulfill() }) - - for try await count in try observation.values(in: writer).prefix(while: { $0 <= 3 }) { - counts.append(count) - try await writer.write { try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") } + #if canImport(Combine) + // MARK: - Async Await + + 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)") } - 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) } - } - - 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") } + + 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 } - 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 } - - 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) } } - - 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) } - } - - func testAsyncAwait_values_cancelled() async throws { - func test(_ writer: some DatabaseWriter) async throws { - // We need something to change - try await writer.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } - - let cancellationExpectation = expectation(description: "cancelled") - - // Launch a task that we'll cancel - let cancelledTask = Task { - // Loops until cancelled - let observation = ValueObservation.trackingConstantRegion(Table("t").fetchCount) - let cancelledObservation = observation.handleEvents(didCancel: { - cancellationExpectation.fulfill() - }) - for try await _ in cancelledObservation.values(in: writer) { } - return "cancelled loop" - } - - // Lanch the task that cancels - Task { - let observation = ValueObservation.trackingConstantRegion(Table("t").fetchCount) - for try await count in observation.values(in: writer) { - if count >= 3 { - cancelledTask.cancel() - break - } else { - try await writer.write { - try $0.execute(sql: "INSERT INTO t DEFAULT VALUES") + + 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 } - - // Make sure loop has ended in the cancelled task - let cancelledValue = try await cancelledTask.value - XCTAssertEqual(cancelledValue, "cancelled loop") - - // Make sure observation was cancelled as well -#if compiler(>=5.8) - await fulfillment(of: [cancellationExpectation], timeout: 2) -#else - wait(for: [cancellationExpectation], timeout: 2) -#endif - } - - try await AsyncTest(test).run { try DatabaseQueue() } - try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } - try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } - } - - // An attempt at finding a regression test for - func testManyObservations() throws { - // TODO: Fix flaky test with SQLCipher 3 - #if GRDBCIPHER - if Database.sqliteLibVersionNumber <= 3020001 { - throw XCTSkip("Skip flaky test with SQLCipher 3") + + 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) } } - #endif - - // 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: "initialValue") -#if SQLITE_ENABLE_SNAPSHOT || (!GRDBCUSTOMSQLITE && !GRDBCIPHER) - 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: "secondValue") - secondValueExpectation.expectedFulfillmentCount = observationCount - - var cancellables: [AnyDatabaseCancellable] = [] - for _ in 0.. { + // 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") + } + } } } - cancellables.append(cancellable) + + // 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 withExtendedLifetime(cancellables) { - wait(for: [initialValueExpectation], timeout: 2) + + try await AsyncTest(test).run { try DatabaseQueue() } + try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabaseQueue(path: $0) } + try await AsyncTest(test).runAtTemporaryDatabasePath { try DatabasePool(path: $0) } + } + + // An attempt at finding a regression test for + func testManyObservations() throws { + // TODO: Fix flaky test with SQLCipher 3 + #if GRDBCIPHER + if Database.sqliteLibVersionNumber <= 3_020_001 { + throw XCTSkip("Skip flaky test with SQLCipher 3") + } + #endif + + // 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: "INSERT INTO t DEFAULT VALUES") + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } - 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 - 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) - initialValueExpectation.assertForOverFulfill = true -#else - // ValueObservation on DatabasePool will notify the first value twice - initialValueExpectation.assertForOverFulfill = false -#endif - initialValueExpectation.expectedFulfillmentCount = observationCount - - let secondValueExpectation = self.expectation(description: "") - secondValueExpectation.expectedFulfillmentCount = observationCount - - let semaphore = DispatchSemaphore(value: 0) - writer.asyncWriteWithoutTransaction { db in - semaphore.signal() - Thread.sleep(forTimeInterval: 0.5) - } - semaphore.wait() - - var cancellables: [AnyDatabaseCancellable] = [] - for _ in 0.. - func testIssue1362() throws { - func test(_ writer: some DatabaseWriter) throws { - try writer.write { try $0.execute(sql: "CREATE TABLE s(id INTEGER PRIMARY KEY AUTOINCREMENT)") } - var cancellables = [AnyDatabaseCancellable]() - - // Start an observation and wait until it has installed its - // transaction observer. - let installedExpectation = expectation(description: "transaction observer installed") - let finalExpectation = expectation(description: "final value") - let initialObservation = ValueObservation.trackingConstantRegion(Table("s").fetchCount) - let cancellable = initialObservation.start( - in: writer, - // Immediate initial value so that the next value comes - // from the write access that installs the transaction observer. - scheduling: .immediate, - onError: { error in XCTFail("Unexpected error: \(error)") }, - onChange: { count in - if count == 1 { - installedExpectation.fulfill() + + // An attempt at finding a regression test for + 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) && !os(Linux) + initialValueExpectation.assertForOverFulfill = true + #else + // ValueObservation on DatabasePool will notify the first value twice + initialValueExpectation.assertForOverFulfill = false + #endif + initialValueExpectation.expectedFulfillmentCount = observationCount + + let secondValueExpectation = self.expectation(description: "") + secondValueExpectation.expectedFulfillmentCount = observationCount + + let semaphore = DispatchSemaphore(value: 0) + writer.asyncWriteWithoutTransaction { db in + semaphore.signal() + Thread.sleep(forTimeInterval: 0.5) + } + semaphore.wait() + + var cancellables: [AnyDatabaseCancellable] = [] + for _ in 0.. + func testIssue1362() throws { + func test(_ writer: some DatabaseWriter) throws { + try writer.write { + try $0.execute(sql: "CREATE TABLE s(id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + var cancellables = [AnyDatabaseCancellable]() + + // Start an observation and wait until it has installed its + // transaction observer. + let installedExpectation = expectation( + description: "transaction observer installed") + let finalExpectation = expectation(description: "final value") + let initialObservation = ValueObservation.trackingConstantRegion( + Table("s").fetchCount) + let cancellable = initialObservation.start( in: writer, + // Immediate initial value so that the next value comes + // from the write access that installs the transaction observer. + scheduling: .immediate, onError: { error in XCTFail("Unexpected error: \(error)") }, - onChange: { _ in }) + onChange: { count in + if count == 1 { + installedExpectation.fulfill() + } + if count == 2 { + finalExpectation.fulfill() + } + }) cancellables.append(cancellable) + try writer.write { try $0.execute(sql: "INSERT INTO s DEFAULT VALUES") } // count = 1 + wait(for: [installedExpectation], timeout: 2) + + // Start a write that will trigger initialObservation when we decide. + let semaphore = DispatchSemaphore(value: 0) + writer.asyncWriteWithoutTransaction { db in + semaphore.wait() + do { + try db.execute(sql: "INSERT INTO s DEFAULT VALUES") // count = 2 + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // Start as many observations as there are readers + for _ in 0.. func testIssue1383() throws { do { @@ -1261,7 +1340,7 @@ class ValueObservationTests: GRDBTestCase { try db.checkpoint(.truncate) } } - + do { let dbPool = try makeDatabasePool(filename: "test") let observation = ValueObservation.tracking(Table("t").fetchCount) @@ -1274,7 +1353,7 @@ class ValueObservationTests: GRDBTestCase { }) } } - + // Regression test for func testIssue1383_async() throws { do { @@ -1285,7 +1364,7 @@ class ValueObservationTests: GRDBTestCase { try db.checkpoint(.truncate) } } - + do { let dbPool = try makeDatabasePool(filename: "test") let observation = ValueObservation.tracking(Table("t").fetchCount) @@ -1305,14 +1384,16 @@ class ValueObservationTests: GRDBTestCase { } } } - + // Regression test for func testIssue1383_createWal() throws { let url = testBundle.url(forResource: "Issue1383", withExtension: "sqlite")! // Delete files created by previous test runs - try? FileManager.default.removeItem(at: url.deletingLastPathComponent().appendingPathComponent("Issue1383.sqlite-wal")) - try? FileManager.default.removeItem(at: url.deletingLastPathComponent().appendingPathComponent("Issue1383.sqlite-shm")) - + 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( @@ -1323,22 +1404,23 @@ class ValueObservationTests: GRDBTestCase { onChange: { _ in }) } - + // Regression test for func testIssue1500() throws { let pool = try makeDatabasePool() - + try pool.read { db in _ = try db.tableExists("t") } - + try pool.write { db in try db.create(table: "t") { t in t.column("a") } } - - _ = ValueObservation + + _ = + ValueObservation .trackingConstantRegion { db in try db.tableExists("t") } @@ -1352,24 +1434,24 @@ class ValueObservationTests: GRDBTestCase { XCTAssertEqual(value, true) }) } - + // Regression test for @MainActor func testIssue1746() async throws { let dbQueue = try makeDatabaseQueue() try await dbQueue.write { db in try db.execute(sql: "CREATE TABLE test (id INTEGER PRIMARY KEY)") } - + // Start a task that waits for writeContinuation before it writes. let (writeStream, writeContinuation) = AsyncStream.makeStream(of: Void.self) let task = Task { - for await _ in writeStream { } + for await _ in writeStream {} try? await dbQueue.write { db in - XCTAssertFalse(Task.isCancelled) // Required for the test to be meaningful + XCTAssertFalse(Task.isCancelled) // Required for the test to be meaningful try db.execute(sql: "INSERT INTO test DEFAULT VALUES") } } - + // A transaction observer that cancels a Task after commit. class CancelObserver: TransactionObserver { let task: Task @@ -1377,20 +1459,21 @@ class ValueObservationTests: GRDBTestCase { self.task = task } func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { true } - func databaseDidChange(with event: DatabaseEvent) { } - func databaseDidRollback(_ db: Database) { } + func databaseDidChange(with event: DatabaseEvent) {} + func databaseDidRollback(_ db: Database) {} func databaseDidCommit(_ db: Database) { task.cancel() } } - + // Register CancelObserver first, so no other observer could access // the database before the task is cancelled. dbQueue.add(transactionObserver: CancelObserver(task: task), extent: .databaseLifetime) // Start observing. // We expect to see 0, then 1. - let values = ValueObservation + let values = + ValueObservation .tracking(Table("test").fetchCount) .values(in: dbQueue) for try await value in values {