diff --git a/CHANGELOG.md b/CHANGELOG.md index d496a760cc..249c470bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,7 +56,7 @@ GRDB adheres to [Semantic Versioning](https://semver.org/), with one expection: - [#539](https://github.com/groue/GRDB.swift/pull/539): Expose joining methods of both requests and associations - [#542](https://github.com/groue/GRDB.swift/pull/542): Move eager loading of hasMany associations to FetchRequest - [#546](https://github.com/groue/GRDB.swift/pull/546) by [@robcas3](https://github.com/robcas3): Fix SPM errors with Xcode 11 beta -- You can now [combine](README.md#valueobservationcombine) up to eight ValueObservations in a single shot. +- [#549](https://github.com/groue/GRDB.swift/pull/549) Support for Combine ### Documentation Diff @@ -64,6 +64,9 @@ The [Define Record Requests](Documentation/GoodPracticesForDesigningRecordTypes. The [Examples of Record Definitions](README.md#examples-of-record-definitions) has been extended with a sample record optimized for fetching performance. +The [ValueObservation](README.md#valueobservation) chapter has been updated with the new APIs. + + ### API Diff ```diff diff --git a/Documentation/AssociationsBasics.md b/Documentation/AssociationsBasics.md index 3d9cdaf16d..802ac18529 100644 --- a/Documentation/AssociationsBasics.md +++ b/Documentation/AssociationsBasics.md @@ -827,8 +827,8 @@ Those requests can also turn out useful when you want to track their changes wit ```swift // Track changes in the author's books: let author: Author = ... -ValueObservation - .trackingAll(author.books) +author.books + .observationForAll() .start(in: dbQueue) { (books: [Book]) in print("Author's book have changed") } diff --git a/Documentation/GRDB3MigrationGuide.md b/Documentation/GRDB3MigrationGuide.md index b4274728d2..91a564f89a 100644 --- a/Documentation/GRDB3MigrationGuide.md +++ b/Documentation/GRDB3MigrationGuide.md @@ -44,7 +44,7 @@ To guarantee asynchronous notifications, and never ever block your main thread, ```swift // On main queue -var observation = ValueObservation.trackingAll(Player.all()) +var observation = Player.observationForAll() observation.scheduling = .async(onQueue: .main, startImmediately: true) let observer = try observation.start(in: dbQueue) { (players: [Player]) in // On main queue diff --git a/GRDB.xcodeproj/project.pbxproj b/GRDB.xcodeproj/project.pbxproj index 4f26da7a75..a0dd895ec6 100755 --- a/GRDB.xcodeproj/project.pbxproj +++ b/GRDB.xcodeproj/project.pbxproj @@ -610,9 +610,9 @@ 56A8C2331D1914540096E9D4 /* UUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A8C22F1D1914540096E9D4 /* UUID.swift */; }; 56A8C2471D1918F00096E9D4 /* FoundationNSUUIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A8C2361D1914790096E9D4 /* FoundationNSUUIDTests.swift */; }; 56A8C2481D1918F00096E9D4 /* FoundationUUIDTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56A8C21E1D1914110096E9D4 /* FoundationUUIDTests.swift */; }; - 56AACAA822ACED7100A40F2A /* Passthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Passthrough.swift */; }; - 56AACAA922ACED7100A40F2A /* Passthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Passthrough.swift */; }; - 56AACAAA22ACED7100A40F2A /* Passthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Passthrough.swift */; }; + 56AACAA822ACED7100A40F2A /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Fetch.swift */; }; + 56AACAA922ACED7100A40F2A /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Fetch.swift */; }; + 56AACAAA22ACED7100A40F2A /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AACAA722ACED7100A40F2A /* Fetch.swift */; }; 56AE64122229A53700AD1B0B /* HasOneThroughAssociation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AE64112229A53700AD1B0B /* HasOneThroughAssociation.swift */; }; 56AE64132229A53700AD1B0B /* HasOneThroughAssociation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AE64112229A53700AD1B0B /* HasOneThroughAssociation.swift */; }; 56AE64142229A53700AD1B0B /* HasOneThroughAssociation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56AE64112229A53700AD1B0B /* HasOneThroughAssociation.swift */; }; @@ -1222,7 +1222,7 @@ 56A8C21E1D1914110096E9D4 /* FoundationUUIDTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoundationUUIDTests.swift; sourceTree = ""; }; 56A8C22F1D1914540096E9D4 /* UUID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UUID.swift; sourceTree = ""; }; 56A8C2361D1914790096E9D4 /* FoundationNSUUIDTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FoundationNSUUIDTests.swift; sourceTree = ""; }; - 56AACAA722ACED7100A40F2A /* Passthrough.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Passthrough.swift; sourceTree = ""; }; + 56AACAA722ACED7100A40F2A /* Fetch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetch.swift; sourceTree = ""; }; 56AE64112229A53700AD1B0B /* HasOneThroughAssociation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HasOneThroughAssociation.swift; sourceTree = ""; }; 56AE6423222AAC9500AD1B0B /* AssociationHasOneThroughSQLTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociationHasOneThroughSQLTests.swift; sourceTree = ""; }; 56AF746A1D41FB9C005E9FF3 /* DatabaseValueConvertibleEscapingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatabaseValueConvertibleEscapingTests.swift; sourceTree = ""; }; @@ -1999,8 +1999,8 @@ children = ( 5613ED3E21A95A8B00DC7A68 /* Combine.swift */, 564CE4D521B2DEB500652B19 /* CompactMap.swift */, + 56AACAA722ACED7100A40F2A /* Fetch.swift */, 5613ED3421A95A5C00DC7A68 /* Map.swift */, - 56AACAA722ACED7100A40F2A /* Passthrough.swift */, 564CE59621B7A8B500652B19 /* RemoveDuplicates.swift */, 5613ED4321A95B2C00DC7A68 /* ValueReducer.swift */, ); @@ -2720,7 +2720,7 @@ 5695962B222C462D002CB7C9 /* HasManyThroughAssociation.swift in Sources */, 565490D31D5AE252005622CB /* (null) in Sources */, 566475D91D981D5E00FF74B8 /* SQLOperators.swift in Sources */, - 56AACAAA22ACED7100A40F2A /* Passthrough.swift in Sources */, + 56AACAAA22ACED7100A40F2A /* Fetch.swift in Sources */, 566B910F1FA4C3970012D5B0 /* Database+Statements.swift in Sources */, 56F5ABDD1D814330001F60CB /* UUID.swift in Sources */, 565490CB1D5AE252005622CB /* (null) in Sources */, @@ -2780,7 +2780,7 @@ 5613ED4921A95C1200DC7A68 /* ValueObservation+Row.swift in Sources */, 566B91361FA4D3810012D5B0 /* TransactionObserver.swift in Sources */, 564CE43221AA901800652B19 /* ValueObserver.swift in Sources */, - 56AACAA922ACED7100A40F2A /* Passthrough.swift in Sources */, + 56AACAA922ACED7100A40F2A /* Fetch.swift in Sources */, 5605F1721C672E4000235C62 /* DatabaseValueConvertible+RawRepresentable.swift in Sources */, 56CEB5141EAA324B00BFAF62 /* FTS3+QueryInterface.swift in Sources */, 566475CF1D981D5E00FF74B8 /* SQLFunctions.swift in Sources */, @@ -3310,7 +3310,7 @@ 56CEB4F11EAA2EFA00BFAF62 /* FetchableRecord.swift in Sources */, 56D91AA22205E03700770D8D /* SQLRelation.swift in Sources */, 56300B781C53F592005A543B /* QueryInterfaceRequest.swift in Sources */, - 56AACAA822ACED7100A40F2A /* Passthrough.swift in Sources */, + 56AACAA822ACED7100A40F2A /* Fetch.swift in Sources */, 5605F18D1C6B1A8700235C62 /* SQLCollatedExpression.swift in Sources */, 5613ED5421A95DD000DC7A68 /* ValueObservation+Count.swift in Sources */, 566B91331FA4D3810012D5B0 /* TransactionObserver.swift in Sources */, diff --git a/GRDB/Core/DatabasePool.swift b/GRDB/Core/DatabasePool.swift index 7a8bc43627..52310d7c58 100644 --- a/GRDB/Core/DatabasePool.swift +++ b/GRDB/Core/DatabasePool.swift @@ -34,6 +34,10 @@ public final class DatabasePool: DatabaseWriter { return writer.path } + public var configuration: Configuration { + return writer.configuration + } + // MARK: - Initializer /// Opens the SQLite database at path *path*. @@ -547,6 +551,14 @@ extension DatabasePool : DatabaseReader { return try writer.reentrantSync(block) } + /// Asynchronously executes an update block in a protected dispatch queue. + /// + /// Eventual concurrent reads may see changes performed in the block before + /// the block completes. + public func unsafeAsyncWrite(_ block: @escaping (Database) -> Void) { + writer.async(block) + } + // MARK: - Functions /// Add or redefine an SQL function. diff --git a/GRDB/Core/DatabaseQueue.swift b/GRDB/Core/DatabaseQueue.swift index 7d87f698af..a68f6ad539 100644 --- a/GRDB/Core/DatabaseQueue.swift +++ b/GRDB/Core/DatabaseQueue.swift @@ -320,6 +320,10 @@ extension DatabaseQueue { return try writer.reentrantSync(block) } + public func unsafeAsyncWrite(_ block: @escaping (Database) -> Void) { + writer.async(block) + } + // MARK: - Functions /// Add or redefine an SQL function. diff --git a/GRDB/Core/DatabaseReader.swift b/GRDB/Core/DatabaseReader.swift index 3f0a79b0f3..2dcbc5413e 100644 --- a/GRDB/Core/DatabaseReader.swift +++ b/GRDB/Core/DatabaseReader.swift @@ -166,9 +166,9 @@ public protocol DatabaseReader : class { /// - returns: a TransactionObserver func add( observation: ValueObservation, - onError: ((Error) -> Void)?, + onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void) - throws -> TransactionObserver + -> TransactionObserver /// Remove a transaction observer. func remove(transactionObserver: TransactionObserver) @@ -257,11 +257,11 @@ public final class AnyDatabaseReader : DatabaseReader { /// :nodoc: public func add( observation: ValueObservation, - onError: ((Error) -> Void)?, + onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void) - throws -> TransactionObserver + -> TransactionObserver { - return try base.add(observation: observation, onError: onError, onChange: onChange) + return base.add(observation: observation, onError: onError, onChange: onChange) } /// :nodoc: diff --git a/GRDB/Core/DatabaseSnapshot.swift b/GRDB/Core/DatabaseSnapshot.swift index bf27c0a336..a7fa39557d 100644 --- a/GRDB/Core/DatabaseSnapshot.swift +++ b/GRDB/Core/DatabaseSnapshot.swift @@ -100,36 +100,41 @@ extension DatabaseSnapshot { public func add( observation: ValueObservation, - onError: ((Error) -> Void)?, + onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void) - throws -> TransactionObserver + -> TransactionObserver { - // Deal with initial value - switch observation.scheduling { - case .mainQueue: - if let value = try unsafeReentrantRead(observation.fetchFirst) { - if DispatchQueue.isMain { - onChange(value) - } else { - DispatchQueue.main.async { + // TODO: fetch asynchronously when possible + do { + // Deal with initial value + switch observation.scheduling { + case .mainQueue: + if let value = try unsafeReentrantRead(observation.fetchFirst) { + if DispatchQueue.isMain { onChange(value) + } else { + DispatchQueue.main.async { + onChange(value) + } } } - } - case let .async(onQueue: queue, startImmediately: startImmediately): - if startImmediately { - if let value = try unsafeReentrantRead(observation.fetchFirst) { - queue.async { - onChange(value) + case let .async(onQueue: queue, startImmediately: startImmediately): + if startImmediately { + if let value = try unsafeReentrantRead(observation.fetchFirst) { + queue.async { + onChange(value) + } } } - } - case let .unsafe(startImmediately: startImmediately): - if startImmediately { - if let value = try unsafeReentrantRead(observation.fetchFirst) { - onChange(value) + case let .unsafe(startImmediately: startImmediately): + if startImmediately { + if let value = try unsafeReentrantRead(observation.fetchFirst) { + onChange(value) + } } } + } catch { + onError(error) } // Return a dummy observer, because snapshots never change diff --git a/GRDB/Core/DatabaseWriter.swift b/GRDB/Core/DatabaseWriter.swift index c19277c4bb..a237bc924e 100644 --- a/GRDB/Core/DatabaseWriter.swift +++ b/GRDB/Core/DatabaseWriter.swift @@ -17,6 +17,9 @@ import Dispatch /// [busy handler](https://www.sqlite.org/c3ref/busy_handler.html). public protocol DatabaseWriter : DatabaseReader { + /// The database configuration + var configuration: Configuration { get } + // MARK: - Writing in Database /// Synchronously executes a block that takes a database connection, and @@ -60,6 +63,13 @@ public protocol DatabaseWriter : DatabaseReader { /// dangerous concurrency practices. func unsafeReentrantWrite(_ block: (Database) throws -> T) rethrows -> T + /// Asynchronously executes a block that takes a database connection, and + /// returns its result. + /// + /// Eventual concurrent reads may see changes performed in the block before + /// the block completes. + func unsafeAsyncWrite(_ block: @escaping (Database) -> Void) + // MARK: - Reading from Database /// Concurrently executes a read-only block that takes a @@ -181,61 +191,179 @@ extension DatabaseWriter { /// :nodoc: public func add( observation: ValueObservation, - onError: ((Error) -> Void)?, + onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void) - throws -> TransactionObserver + -> TransactionObserver { - let calledOnMainQueue = DispatchQueue.isMain - var startValue: Reducer.Value? = nil - defer { - if let startValue = startValue { - onChange(startValue) - } - } + let requiresWriteAccess = observation.requiresWriteAccess + let observer = ValueObserver( + requiresWriteAccess: requiresWriteAccess, + writer: self, + reduceQueue: configuration.makeDispatchQueue(defaultLabel: "GRDB", purpose: "ValueObservation.reducer"), + onError: onError, + onChange: onChange) - // Use unsafeReentrantWrite so that observation can start from any - // dispatch queue. - return try unsafeReentrantWrite { db in - // Create the reducer - var reducer = try observation.makeReducer(db) - - // Take care of initial value. Make sure it is dispatched before - // any future transaction can trigger a change. - switch observation.scheduling { - case .mainQueue: - if let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: observation.requiresWriteAccess)) { - if calledOnMainQueue { - startValue = value - } else { - DispatchQueue.main.async { onChange(value) } + switch observation.scheduling { + case .mainQueue: + if DispatchQueue.isMain { + // Use case: observation starts on the main queue and wants + // a synchronous initial fetch. Typically, this helps avoiding + // flashes of missing content. + var startValue: Reducer.Value? = nil + defer { + if let startValue = startValue { + onChange(startValue) } } - case let .async(onQueue: queue, startImmediately: startImmediately): - if startImmediately { - if let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: observation.requiresWriteAccess)) { - queue.async { onChange(value) } + + do { + try unsafeReentrantWrite { db in + let region = try observation.observedRegion(db) + var reducer = try observation.makeReducer(db) + + // Fetch initial value + if let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: requiresWriteAccess)) { + startValue = value + } + + // Start observing the database + observer.region = region + observer.reducer = reducer + observer.notificationQueue = DispatchQueue.main + db.add(transactionObserver: observer, extent: .observerLifetime) } + } catch { + onError(error) } - case let .unsafe(startImmediately: startImmediately): - if startImmediately { - startValue = try reducer.value(reducer.fetch(db, requiringWriteAccess: observation.requiresWriteAccess)) + } else { + // Use case: observation does not start on the main queue, but + // has the default scheduling .mainQueue + unsafeAsyncWrite { db in + do { + let region = try observation.observedRegion(db) + var reducer = try observation.makeReducer(db) + + // Fetch initial value + if let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: requiresWriteAccess)) { + DispatchQueue.main.async { + onChange(value) + } + } + + // Start observing the database + observer.region = region + observer.reducer = reducer + observer.notificationQueue = DispatchQueue.main + db.add(transactionObserver: observer, extent: .observerLifetime) + } catch { + DispatchQueue.main.async { + onError(error) + } + } } } - // Start observing the database - let valueObserver = try ValueObserver( - region: observation.observedRegion(db), - reducer: reducer, - requiresWriteAccess: observation.requiresWriteAccess, - writer: self, - notificationQueue: observation.notificationQueue, - reduceQueue: db.configuration.makeDispatchQueue(defaultLabel: "GRDB", purpose: "ValueObservation.reducer"), - onError: onError, - onChange: onChange) - db.add(transactionObserver: valueObserver, extent: .observerLifetime) + case let .async(onQueue: queue, startImmediately: startImmediately): + // Use case: observation must not block the target queue + unsafeAsyncWrite { db in + do { + let region = try observation.observedRegion(db) + var reducer = try observation.makeReducer(db) + + // Fetch initial value + if startImmediately, + let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: requiresWriteAccess)) + { + queue.async { + onChange(value) + } + } + + // Start observing the database + observer.region = region + observer.reducer = reducer + observer.notificationQueue = queue + db.add(transactionObserver: observer, extent: .observerLifetime) + } catch { + queue.async { + onError(error) + } + } + } - return valueObserver + case let .unsafe(startImmediately: startImmediately): + if startImmediately { + // Use case: third-party integration (RxSwift, Combine, ...) that + // need a synchronous initial fetch. + // + // This is really super extra unsafe. + // + // If the observation is started on one dispatch queue, then + // the onChange and onError callbacks must be asynchronously + // dispatched on the *same* queue. + // + // A failure to follow this rule may mess with the ordering of + // initial values. + var startValue: Reducer.Value? = nil + defer { + if let startValue = startValue { + onChange(startValue) + } + } + + do { + try unsafeReentrantWrite { db in + let region = try observation.observedRegion(db) + var reducer = try observation.makeReducer(db) + + // Fetch initial value + if startImmediately, + let value = try reducer.value(reducer.fetch(db, requiringWriteAccess: requiresWriteAccess)) + { + startValue = value + } + + // Start observing the database + observer.region = region + observer.reducer = reducer + observer.notificationQueue = nil + db.add(transactionObserver: observer, extent: .observerLifetime) + } + } catch { + onError(error) + } + } else { + // Use case: ? + // + // This is unsafe because no promise is made on the dispatch + // queue on which the onChange and onError callbacks are called. + unsafeAsyncWrite { db in + do { + let region = try observation.observedRegion(db) + let reducer = try observation.makeReducer(db) + + // Start observing the database + observer.region = region + observer.reducer = reducer + observer.notificationQueue = nil + db.add(transactionObserver: observer, extent: .observerLifetime) + } catch { + onError(error) + } + } + } } + + // TODO + // + // We promise that observation stops when the returned observer is + // deallocated. But the real observer may have not started observing + // the database, because some observations start asynchronously. + // Well... This forces us to return a "token" that cancels any + // observation started asynchronously. + // + // We'll eventually return a proper Cancellable. In GRDB 5? + return ValueObserverToken(writer: self, observer: observer) } } @@ -290,6 +418,10 @@ public final class AnyDatabaseWriter : DatabaseWriter { self.base = base } + public var configuration: Configuration { + return base.configuration + } + // MARK: - Reading from Database /// :nodoc: @@ -329,6 +461,11 @@ public final class AnyDatabaseWriter : DatabaseWriter { return try base.unsafeReentrantWrite(block) } + /// :nodoc: + public func unsafeAsyncWrite(_ block: @escaping (Database) -> Void) { + base.unsafeAsyncWrite(block) + } + // MARK: - Functions /// :nodoc: diff --git a/GRDB/Fixit/GRDB-4.0.swift b/GRDB/Fixit/GRDB-4.0.swift index 4927a3315c..f468c06045 100644 --- a/GRDB/Fixit/GRDB-4.0.swift +++ b/GRDB/Fixit/GRDB-4.0.swift @@ -16,8 +16,8 @@ extension ValueObservation { @available(*, unavailable, message: "Provide the reducer in a (Database) -> Reducer closure") public static func tracking(_ regions: DatabaseRegionConvertible..., reducer: Reducer) -> ValueObservation { preconditionFailure() } - @available(*, unavailable, message: "Use distinctUntilChanged() instead") - public static func tracking(_ regions: DatabaseRegionConvertible..., fetchDistinct fetch: @escaping (Database) throws -> Value) -> ValueObservation>> where Value: Equatable { preconditionFailure() } + @available(*, unavailable, message: "Use removeDuplicates() instead") + public static func tracking(_ regions: DatabaseRegionConvertible..., fetchDistinct fetch: @escaping (Database) throws -> Value) -> ValueObservation>> where Value: Equatable { preconditionFailure() } } @available(*, unavailable, renamed: "FastDatabaseValueCursor") diff --git a/GRDB/ValueObservation/ValueObservation+Count.swift b/GRDB/ValueObservation/ValueObservation+Count.swift index 064d8cb61b..a53c64089d 100644 --- a/GRDB/ValueObservation/ValueObservation+Count.swift +++ b/GRDB/ValueObservation/ValueObservation+Count.swift @@ -1,3 +1,70 @@ +extension FetchRequest { + + // MARK: - Observation + + /// Creates a ValueObservation which observes *request*, and notifies its + /// count whenever it is modified by a database transaction. + /// + /// For example: + /// + /// let request = Player.all() + /// let observation = request.observationForCount() + /// + /// let observer = try observation.start(in: dbQueue) { count: Int in + /// print("Number of players has changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public func observationForCount() -> + ValueObservation>> + { + return ValueObservation + .tracking(self, fetch: fetchCount) + .removeDuplicates() + } +} + +extension TableRecord { + + // MARK: - Observation + + /// Creates a ValueObservation which observes the record table, and notifies + /// its count whenever it is modified by a database transaction. + /// + /// For example: + /// + /// let observation = Player.observationForCount() + /// + /// let observer = try observation.start(in: dbQueue) { count: Int in + /// print("Number of players has changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public static func observationForCount() -> + ValueObservation>> + { + return all().observationForCount() + } +} + extension ValueObservation where Reducer == Void { // MARK: - Count Observation @@ -25,9 +92,10 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. + @available(*, deprecated, message: "Use request.observationForCount() instead") public static func trackingCount(_ request: Request) - -> ValueObservation>> + -> ValueObservation>> { - return ValueObservation.tracking(request, fetch: request.fetchCount).removeDuplicates() + return request.observationForCount() } } diff --git a/GRDB/ValueObservation/ValueObservation+DatabaseValueConvertible.swift b/GRDB/ValueObservation/ValueObservation+DatabaseValueConvertible.swift index bc2017753c..78c0e02667 100644 --- a/GRDB/ValueObservation/ValueObservation+DatabaseValueConvertible.swift +++ b/GRDB/ValueObservation/ValueObservation+DatabaseValueConvertible.swift @@ -1,3 +1,129 @@ +extension FetchRequest where RowDecoder: DatabaseValueConvertible { + + // MARK: - Observation + + /// Creates a ValueObservation which observes *request*, and notifies + /// fresh values whenever the request is modified by a + /// database transaction. + /// + /// For example: + /// + /// let request = Player.select(Column("name"), as: String.self) + /// let observation = request.observationForAll() + /// + /// let observer = try observation.start(in: dbQueue) { names: [String] in + /// print("Player names have changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public func observationForAll() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.AllValues { try DatabaseValue.fetchAll($0, self) } + }) + } + + /// Creates a ValueObservation which observes *request*, and notifies a + /// fresh value whenever the request is modified by a database transaction. + /// + /// For example: + /// + /// let request = Player.select(max(Column("score")), as: Int.self) + /// let observation = request.observationForFirst() + /// + /// let observer = try observation.start(in: dbQueue) { maxScore: Int? in + /// print("Maximum score has changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - parameter request: the observed request. + /// - returns: a ValueObservation. + public func observationForFirst() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.OneValue { try DatabaseValue.fetchOne($0, self) } + }) + } +} + +extension FetchRequest where RowDecoder: _OptionalProtocol, RowDecoder._Wrapped: DatabaseValueConvertible { + + // MARK: - Observation + + /// Creates a ValueObservation which observes *request*, and notifies + /// fresh values whenever the request is modified by a + /// database transaction. + /// + /// For example: + /// + /// let request = Player.select(Column("name"), as: Optional.self) + /// let observation = request.observationForAll() + /// + /// let observer = try observation.start(in: dbQueue) { names: [String?] in + /// print("Player names have changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public func observationForAll() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.AllOptionalValues { try DatabaseValue.fetchAll($0, self) } + }) + } + + /// Creates a ValueObservation which observes *request*, and notifies + /// fresh values whenever the request is modified by a + /// database transaction. + /// + /// For example: + /// + /// let request = Player.select(Column("name"), as: Optional.self) + /// let observation = request.observationForAll() + /// + /// let observer = try observation.start(in: dbQueue) { names: [String?] in + /// print("Player names have changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public func observationForFirst() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.OneValue { try DatabaseValue.fetchOne($0, self) } + }) + } +} + extension ValueObservation where Reducer == Void { // MARK: - DatabaseValueConvertible Observation @@ -26,13 +152,12 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. + @available(*, deprecated, message: "Use request.observationForAll() instead") public static func trackingAll(_ request: Request) -> ValueObservation> where Request.RowDecoder: DatabaseValueConvertible { - return ValueObservation>.tracking(request, reducer: { _ in - DatabaseValuesReducer { try DatabaseValue.fetchAll($0, request) } - }) + return request.observationForAll() } /// Creates a ValueObservation which observes *request*, and notifies a @@ -58,13 +183,12 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. + @available(*, deprecated, message: "Use request.observationForFirst() instead") public static func trackingOne(_ request: Request) -> ValueObservation> where Request.RowDecoder: DatabaseValueConvertible { - return ValueObservation>.tracking(request, reducer: { _ in - DatabaseValueReducer { try DatabaseValue.fetchOne($0, request) } - }) + return request.observationForFirst() } /// Creates a ValueObservation which observes *request*, and notifies @@ -91,119 +215,132 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. + @available(*, deprecated, message: "Use request.observationForAll() instead") public static func trackingAll(_ request: Request) -> ValueObservation> where Request.RowDecoder: _OptionalProtocol, Request.RowDecoder._Wrapped: DatabaseValueConvertible { - return ValueObservation>.tracking(request, reducer: { _ in - OptionalDatabaseValuesReducer { try DatabaseValue.fetchAll($0, request) } - }) + return request.observationForAll() } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs arrays of values, filtering out consecutive -/// identical database values. -/// -/// :nodoc: -public struct DatabaseValuesReducer: ValueReducer - where RowDecoder: DatabaseValueConvertible -{ - private let _fetch: (Database) throws -> [DatabaseValue] - private var previousDbValues: [DatabaseValue]? - - init(fetch: @escaping (Database) throws -> [DatabaseValue]) { - self._fetch = fetch - } - - public func fetch(_ db: Database) throws -> [DatabaseValue] { - return try _fetch(db) - } - - public mutating func value(_ dbValues: [DatabaseValue]) -> [RowDecoder]? { - if let previousDbValues = previousDbValues, previousDbValues == dbValues { - // Don't notify consecutive identical dbValue arrays - return nil +extension ValueReducers { + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs arrays of values, filtering out consecutive + /// identical database values. + /// + /// :nodoc: + public struct AllValues: ValueReducer + where RowDecoder: DatabaseValueConvertible + { + private let _fetch: (Database) throws -> [DatabaseValue] + private var previousDbValues: [DatabaseValue]? + + init(fetch: @escaping (Database) throws -> [DatabaseValue]) { + self._fetch = fetch } - self.previousDbValues = dbValues - return dbValues.map { - RowDecoder.decode(from: $0, conversionContext: nil) + + public func fetch(_ db: Database) throws -> [DatabaseValue] { + return try _fetch(db) + } + + public mutating func value(_ dbValues: [DatabaseValue]) -> [RowDecoder]? { + if let previousDbValues = previousDbValues, previousDbValues == dbValues { + // Don't notify consecutive identical dbValue arrays + return nil + } + self.previousDbValues = dbValues + return dbValues.map { + RowDecoder.decode(from: $0, conversionContext: nil) + } } } -} - -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs optional values, filtering out consecutive -/// identical database values. -/// -/// :nodoc: -public struct DatabaseValueReducer: ValueReducer - where RowDecoder: DatabaseValueConvertible -{ - private let _fetch: (Database) throws -> DatabaseValue? - private var previousDbValue: DatabaseValue?? - private var previousValueWasNil = false - init(fetch: @escaping (Database) throws -> DatabaseValue?) { - self._fetch = fetch + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs optional values, filtering out consecutive + /// identical database values. + /// + /// :nodoc: + public struct OneValue: ValueReducer + where RowDecoder: DatabaseValueConvertible + { + private let _fetch: (Database) throws -> DatabaseValue? + private var previousDbValue: DatabaseValue?? + private var previousValueWasNil = false + + init(fetch: @escaping (Database) throws -> DatabaseValue?) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> DatabaseValue? { + return try _fetch(db) + } + + public mutating func value(_ dbValue: DatabaseValue?) -> RowDecoder?? { + if let previousDbValue = previousDbValue, previousDbValue == dbValue { + // Don't notify consecutive identical dbValue + return nil + } + self.previousDbValue = dbValue + if let dbValue = dbValue, + let value = RowDecoder.decodeIfPresent(from: dbValue, conversionContext: nil) + { + previousValueWasNil = false + return .some(value) + } else if previousValueWasNil { + // Don't notify consecutive nil values + return nil + } else { + previousValueWasNil = true + return .some(nil) + } + } } - public func fetch(_ db: Database) throws -> DatabaseValue? { - return try _fetch(db) - } - - public mutating func value(_ dbValue: DatabaseValue?) -> RowDecoder?? { - if let previousDbValue = previousDbValue, previousDbValue == dbValue { - // Don't notify consecutive identical dbValue - return nil + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs arrays of optional values, filtering out consecutive + /// identical database values. + /// + /// :nodoc: + public struct AllOptionalValues: ValueReducer + where RowDecoder: DatabaseValueConvertible + { + private let _fetch: (Database) throws -> [DatabaseValue] + private var previousDbValues: [DatabaseValue]? + + init(fetch: @escaping (Database) throws -> [DatabaseValue]) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> [DatabaseValue] { + return try _fetch(db) } - self.previousDbValue = dbValue - if let dbValue = dbValue, - let value = RowDecoder.decodeIfPresent(from: dbValue, conversionContext: nil) - { - previousValueWasNil = false - return .some(value) - } else if previousValueWasNil { - // Don't notify consecutive nil values - return nil - } else { - previousValueWasNil = true - return .some(nil) + + public mutating func value(_ dbValues: [DatabaseValue]) -> [RowDecoder?]? { + if let previousDbValues = previousDbValues, previousDbValues == dbValues { + // Don't notify consecutive identical dbValue arrays + return nil + } + self.previousDbValues = dbValues + return dbValues.map { + RowDecoder.decodeIfPresent(from: $0, conversionContext: nil) + } } } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs arrays of optional values, filtering out consecutive -/// identical database values. -/// /// :nodoc: -public struct OptionalDatabaseValuesReducer: ValueReducer - where RowDecoder: DatabaseValueConvertible -{ - private let _fetch: (Database) throws -> [DatabaseValue] - private var previousDbValues: [DatabaseValue]? - - init(fetch: @escaping (Database) throws -> [DatabaseValue]) { - self._fetch = fetch - } - - public func fetch(_ db: Database) throws -> [DatabaseValue] { - return try _fetch(db) - } +@available(*, deprecated, renamed: "ValueReducers.AllValues") +public typealias DatabaseValuesReducer = ValueReducers.AllValues where RowDecoder: DatabaseValueConvertible - public mutating func value(_ dbValues: [DatabaseValue]) -> [RowDecoder?]? { - if let previousDbValues = previousDbValues, previousDbValues == dbValues { - // Don't notify consecutive identical dbValue arrays - return nil - } - self.previousDbValues = dbValues - return dbValues.map { - RowDecoder.decodeIfPresent(from: $0, conversionContext: nil) - } - } -} +/// :nodoc: +@available(*, deprecated, renamed: "ValueReducers.OneValue") +public typealias DatabaseValueReducer = ValueReducers.OneValue where RowDecoder: DatabaseValueConvertible + +/// :nodoc: +@available(*, deprecated, renamed: "ValueReducers.AllOptionalValues") +public typealias OptionalDatabaseValuesReducer = ValueReducers.AllOptionalValues where RowDecoder: DatabaseValueConvertible diff --git a/GRDB/ValueObservation/ValueObservation+FetchableRecord.swift b/GRDB/ValueObservation/ValueObservation+FetchableRecord.swift index df28ced1d1..b588a62d07 100644 --- a/GRDB/ValueObservation/ValueObservation+FetchableRecord.swift +++ b/GRDB/ValueObservation/ValueObservation+FetchableRecord.swift @@ -1,6 +1,6 @@ -extension ValueObservation where Reducer == Void { +extension FetchRequest where RowDecoder: FetchableRecord { - // MARK: - FetchableRecord Observation + // MARK: - Observation /// Creates a ValueObservation which observes *request*, and notifies /// fresh records whenever the request is modified by a @@ -9,7 +9,7 @@ extension ValueObservation where Reducer == Void { /// For example: /// /// let request = Player.all() - /// let observation = ValueObservation.trackingAll(request) + /// let observation = request.observationForAll() /// /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("Players have changed") @@ -24,25 +24,53 @@ extension ValueObservation where Reducer == Void { /// - The observation lasts until the observer returned by /// `start` is deallocated. /// - /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingAll(_ request: Request) - -> ValueObservation> - where Request.RowDecoder: FetchableRecord - { - return ValueObservation>.tracking(request, reducer: { _ in - FetchableRecordsReducer { try Row.fetchAll($0, request) } + public func observationForAll() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.AllRecords { try Row.fetchAll($0, self) } }) } - /// Creates a ValueObservation which observes *request*, and notifies + /// Creates a ValueObservation which observes *request*, and notifies a + /// fresh record whenever the request is modified by a database transaction. + /// + /// For example: + /// + /// let request = Player.filter(key: 1) + /// let observation = request.observationForFirst() + /// + /// let observer = try observation.start(in: dbQueue) { player: Player? in + /// print("Player has changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// + /// - returns: a ValueObservation. + public func observationForFirst() -> ValueObservation> { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.OneRecord { try Row.fetchOne($0, self) } + }) + } +} + +extension TableRecord where Self: FetchableRecord { + + // MARK: - Observation + + /// Creates a ValueObservation which observes the record table, and notifies /// fresh records whenever the request is modified by a /// database transaction. /// /// For example: /// - /// let request = Player.all() - /// let observation = ValueObservation.trackingAll(request) + /// let observation = Player.observationForAll() /// /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("Players have changed") @@ -57,23 +85,18 @@ extension ValueObservation where Reducer == Void { /// - The observation lasts until the observer returned by /// `start` is deallocated. /// - /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingAll(_ request: QueryInterfaceRequest) - -> ValueObservation> - { - return ValueObservation>.tracking(request, reducer: { _ in - FetchableRecordsReducer { try Row.fetchAll($0, request) } - }) + public static func observationForAll() -> ValueObservation> { + return all().observationForAll() } - /// Creates a ValueObservation which observes *request*, and notifies a - /// fresh record whenever the request is modified by a database transaction. + /// Creates a ValueObservation which observes the table record, and notifies + /// a fresh record whenever the request is modified by a + /// database transaction. /// /// For example: /// - /// let request = Player.filter(key: 1) - /// let observation = ValueObservation.trackingOne(request) + /// let observation = Player.observationForFirst() /// /// let observer = try observation.start(in: dbQueue) { player: Player? in /// print("Player has changed") @@ -88,15 +111,47 @@ extension ValueObservation where Reducer == Void { /// - The observation lasts until the observer returned by /// `start` is deallocated. /// + /// - returns: a ValueObservation. + public static func observationForFirst() -> ValueObservation> { + // TODO: check that limit(1) has no impact on requests like filter(key:) + return limit(1).observationForFirst() + } +} + +extension ValueObservation where Reducer == Void { + + // MARK: - FetchableRecord Observation + + /// Creates a ValueObservation which observes *request*, and notifies + /// fresh records whenever the request is modified by a + /// database transaction. + /// + /// For example: + /// + /// let request = Player.all() + /// let observation = ValueObservation.trackingAll(request) + /// + /// let observer = try observation.start(in: dbQueue) { players: [Player] in + /// print("Players have changed") + /// } + /// + /// The returned observation has the default configuration: + /// + /// - When started with the `start(in:onError:onChange:)` method, a fresh + /// value is immediately notified on the main queue. + /// - Upon subsequent database changes, fresh values are notified on the + /// main queue. + /// - The observation lasts until the observer returned by + /// `start` is deallocated. + /// /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingOne(_ request: Request) -> - ValueObservation> + @available(*, deprecated, message: "Use request.observationForAll() instead") + public static func trackingAll(_ request: Request) + -> ValueObservation> where Request.RowDecoder: FetchableRecord { - return ValueObservation>.tracking(request, reducer: { _ in - FetchableRecordReducer { try Row.fetchOne($0, request) } - }) + return request.observationForAll() } /// Creates a ValueObservation which observes *request*, and notifies a @@ -122,71 +177,81 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingOne(_ request: QueryInterfaceRequest) - -> ValueObservation> + @available(*, deprecated, message: "Use request.observationForFirst() instead") + public static func trackingOne(_ request: Request) -> + ValueObservation> + where Request.RowDecoder: FetchableRecord { - return ValueObservation>.tracking(request, reducer: { _ in - FetchableRecordReducer { try Row.fetchOne($0, request) } - }) + return request.observationForFirst() } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs arrays of records, filtering out consecutive -/// identical database rows. -/// -/// :nodoc: -public struct FetchableRecordsReducer: ValueReducer - where RowDecoder: FetchableRecord -{ - private let _fetch: (Database) throws -> [Row] - private var previousRows: [Row]? - - init(fetch: @escaping (Database) throws -> [Row]) { - self._fetch = fetch - } - - public func fetch(_ db: Database) throws -> [Row] { - return try _fetch(db) +extension ValueReducers { + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs arrays of records, filtering out consecutive + /// identical database rows. + /// + /// :nodoc: + public struct AllRecords: ValueReducer + where RowDecoder: FetchableRecord + { + private let _fetch: (Database) throws -> [Row] + private var previousRows: [Row]? + + init(fetch: @escaping (Database) throws -> [Row]) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> [Row] { + return try _fetch(db) + } + + public mutating func value(_ rows: [Row]) -> [RowDecoder]? { + if let previousRows = previousRows, previousRows == rows { + // Don't notify consecutive identical row arrays + return nil + } + self.previousRows = rows + return rows.map(RowDecoder.init(row:)) + } } - public mutating func value(_ rows: [Row]) -> [RowDecoder]? { - if let previousRows = previousRows, previousRows == rows { - // Don't notify consecutive identical row arrays - return nil + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs optional records, filtering out consecutive + /// identical database rows. + /// + /// :nodoc: + public struct OneRecord: ValueReducer + where RowDecoder: FetchableRecord + { + private let _fetch: (Database) throws -> Row? + private var previousRow: Row?? + + init(fetch: @escaping (Database) throws -> Row?) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> Row? { + return try _fetch(db) + } + + public mutating func value(_ row: Row?) -> RowDecoder?? { + if let previousRow = previousRow, previousRow == row { + // Don't notify consecutive identical rows + return nil + } + self.previousRow = row + return .some(row.map(RowDecoder.init(row:))) } - self.previousRows = rows - return rows.map(RowDecoder.init(row:)) } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs optional records, filtering out consecutive -/// identical database rows. -/// /// :nodoc: -public struct FetchableRecordReducer: ValueReducer - where RowDecoder: FetchableRecord -{ - private let _fetch: (Database) throws -> Row? - private var previousRow: Row?? - - init(fetch: @escaping (Database) throws -> Row?) { - self._fetch = fetch - } +@available(*, deprecated, renamed: "ValueReducers.AllRecords") +public typealias FetchableRecordsReducer = ValueReducers.AllRecords where RowDecoder: FetchableRecord - public func fetch(_ db: Database) throws -> Row? { - return try _fetch(db) - } - - public mutating func value(_ row: Row?) -> RowDecoder?? { - if let previousRow = previousRow, previousRow == row { - // Don't notify consecutive identical rows - return nil - } - self.previousRow = row - return .some(row.map(RowDecoder.init(row:))) - } -} +/// :nodoc: +@available(*, deprecated, renamed: "ValueReducers.OneRecord") +public typealias FetchableRecordReducer = ValueReducers.OneRecord where RowDecoder: FetchableRecord diff --git a/GRDB/ValueObservation/ValueObservation+Row.swift b/GRDB/ValueObservation/ValueObservation+Row.swift index 32038ee655..fa0f1ae775 100644 --- a/GRDB/ValueObservation/ValueObservation+Row.swift +++ b/GRDB/ValueObservation/ValueObservation+Row.swift @@ -1,6 +1,6 @@ -extension ValueObservation where Reducer == Void { - - // MARK: - Row Observation +extension FetchRequest where RowDecoder == Row { + + // MARK: - Observation /// Creates a ValueObservation which observes *request*, and notifies /// fresh rows whenever the request is modified by a database transaction. @@ -8,7 +8,7 @@ extension ValueObservation where Reducer == Void { /// For example: /// /// let request = SQLRequest(sql: "SELECT * FROM player") - /// let observation = ValueObservation.trackingAll(request) + /// let observation = request.observationForAll() /// /// let observer = try observation.start(in: dbQueue) { rows: [Row] in /// print("Players have changed") @@ -23,26 +23,22 @@ extension ValueObservation where Reducer == Void { /// - The observation lasts until the observer returned by /// `start` is deallocated. /// - /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingAll(_ request: Request) - -> ValueObservation - where Request.RowDecoder == Row - { - return ValueObservation.tracking(request, reducer: { _ in - RowsReducer(fetch: request.fetchAll) + public func observationForAll() -> ValueObservation { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.AllRows(fetch: self.fetchAll) }) } - /// Creates a ValueObservation which observes *request*, and notifies - /// fresh rows whenever the request is modified by a database transaction. + /// Creates a ValueObservation which observes *request*, and notifies a + /// fresh row whenever the request is modified by a database transaction. /// /// For example: /// - /// let request = SQLRequest(sql: "SELECT * FROM player") - /// let observation = ValueObservation.trackingAll(request) + /// let request = SQLRequest(sql: "SELECT * FROM player WHERE id = ?", arguments: [1]) + /// let observation = request.observationForFirst() /// - /// let observer = try observation.start(in: dbQueue) { rows: [Row] in + /// let observer = try observation.start(in: dbQueue) { row: Row? in /// print("Players have changed") /// } /// @@ -55,25 +51,27 @@ extension ValueObservation where Reducer == Void { /// - The observation lasts until the observer returned by /// `start` is deallocated. /// - /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingAll(_ request: QueryInterfaceRequest) - -> ValueObservation - { - return ValueObservation.tracking(request, reducer: { _ in - RowsReducer(fetch: request.fetchAll) + public func observationForFirst() -> ValueObservation { + return ValueObservation.tracking(self, reducer: { _ in + ValueReducers.OneRow(fetch: self.fetchOne) }) } +} + +extension ValueObservation where Reducer == Void { + + // MARK: - Row Observation - /// Creates a ValueObservation which observes *request*, and notifies a - /// fresh row whenever the request is modified by a database transaction. + /// Creates a ValueObservation which observes *request*, and notifies + /// fresh rows whenever the request is modified by a database transaction. /// /// For example: /// - /// let request = SQLRequest(sql: "SELECT * FROM player WHERE id = ?", arguments: [1]) - /// let observation = ValueObservation.trackingOne(request) + /// let request = SQLRequest(sql: "SELECT * FROM player") + /// let observation = ValueObservation.trackingAll(request) /// - /// let observer = try observation.start(in: dbQueue) { row: Row? in + /// let observer = try observation.start(in: dbQueue) { rows: [Row] in /// print("Players have changed") /// } /// @@ -88,13 +86,12 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingOne(_ request: Request) - -> ValueObservation + @available(*, deprecated, message: "Use request.observationForAll() instead") + public static func trackingAll(_ request: Request) + -> ValueObservation where Request.RowDecoder == Row { - return ValueObservation.tracking(request, reducer: { _ in - RowReducer(fetch: request.fetchOne) - }) + return request.observationForAll() } /// Creates a ValueObservation which observes *request*, and notifies a @@ -120,67 +117,77 @@ extension ValueObservation where Reducer == Void { /// /// - parameter request: the observed request. /// - returns: a ValueObservation. - public static func trackingOne(_ request: QueryInterfaceRequest) + @available(*, deprecated, message: "Use request.observationForFirst() instead") + public static func trackingOne(_ request: Request) -> ValueObservation + where Request.RowDecoder == Row { - return ValueObservation.tracking(request, reducer: { _ in - RowReducer(fetch: request.fetchOne) - }) + return request.observationForFirst() } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs arrays of database rows, filtering out -/// consecutive identical arrays. -/// -/// :nodoc: -public struct RowsReducer: ValueReducer { - private let _fetch: (Database) throws -> [Row] - private var previousRows: [Row]? - - init(fetch: @escaping (Database) throws -> [Row]) { - self._fetch = fetch - } - - public func fetch(_ db: Database) throws -> [Row] { - return try _fetch(db) +extension ValueReducers { + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs arrays of database rows, filtering out + /// consecutive identical arrays. + /// + /// :nodoc: + public struct AllRows: ValueReducer { + private let _fetch: (Database) throws -> [Row] + private var previousRows: [Row]? + + init(fetch: @escaping (Database) throws -> [Row]) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> [Row] { + return try _fetch(db) + } + + public mutating func value(_ rows: [Row]) -> [Row]? { + if let previousRows = previousRows, previousRows == rows { + // Don't notify consecutive identical row arrays + return nil + } + self.previousRows = rows + return rows + } } - public mutating func value(_ rows: [Row]) -> [Row]? { - if let previousRows = previousRows, previousRows == rows { - // Don't notify consecutive identical row arrays - return nil + /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) + /// + /// A reducer which outputs optional records, filtering out consecutive + /// identical database rows. + /// + /// :nodoc: + public struct OneRow: ValueReducer { + private let _fetch: (Database) throws -> Row? + private var previousRow: Row?? + + init(fetch: @escaping (Database) throws -> Row?) { + self._fetch = fetch + } + + public func fetch(_ db: Database) throws -> Row? { + return try _fetch(db) + } + + public mutating func value(_ row: Row?) -> Row?? { + if let previousRow = previousRow, previousRow == row { + // Don't notify consecutive identical rows + return nil + } + self.previousRow = row + return row } - self.previousRows = rows - return rows } } -/// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) -/// -/// A reducer which outputs optional records, filtering out consecutive -/// identical database rows. -/// /// :nodoc: -public struct RowReducer: ValueReducer { - private let _fetch: (Database) throws -> Row? - private var previousRow: Row?? - - init(fetch: @escaping (Database) throws -> Row?) { - self._fetch = fetch - } - - public func fetch(_ db: Database) throws -> Row? { - return try _fetch(db) - } - - public mutating func value(_ row: Row?) -> Row?? { - if let previousRow = previousRow, previousRow == row { - // Don't notify consecutive identical rows - return nil - } - self.previousRow = row - return row - } -} +@available(*, deprecated, renamed: "ValueReducers.AllRows") +public typealias RowsReducer = ValueReducers.AllRows + +/// :nodoc: +@available(*, deprecated, renamed: "ValueReducers.OneRow") +public typealias RowReducer = ValueReducers.OneRow diff --git a/GRDB/ValueObservation/ValueObservation.swift b/GRDB/ValueObservation/ValueObservation.swift index e080b8f51d..8a152f1c23 100644 --- a/GRDB/ValueObservation/ValueObservation.swift +++ b/GRDB/ValueObservation/ValueObservation.swift @@ -11,7 +11,7 @@ public enum ValueScheduling { /// notified right upon subscription, synchronously: /// /// // On main queue - /// let observation = ValueObservation.trackingAll(Player.all()) + /// let observation = Player.observationForAll() /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") /// } @@ -22,7 +22,7 @@ public enum ValueScheduling { /// /// // Not on the main queue: "fresh players" is eventually printed /// // on the main queue. - /// let observation = ValueObservation.trackingAll(Player.all()) + /// let observation = Player.observationForAll() /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") /// } @@ -49,7 +49,7 @@ public enum ValueScheduling { /// the observation. /// /// // On any queue - /// var observation = ValueObservation.trackingAll(Player.all()) + /// var observation = Player.observationForAll() /// observation.scheduling = .unsafe(startImmediately: true) /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") @@ -68,7 +68,7 @@ public enum ValueScheduling { /// /// For example: /// -/// let observation = ValueObservation.trackingAll(Player.all) +/// let observation = Player.observationForAll() /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("Players have changed.") /// } @@ -98,7 +98,7 @@ public struct ValueObservation { /// notified right upon subscription, synchronously:: /// /// // On main queue - /// let observation = ValueObservation.trackingAll(Player.all()) + /// let observation = Player.observationForAll() /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") /// } @@ -109,7 +109,7 @@ public struct ValueObservation { /// /// // Not on the main queue: "fresh players" is eventually printed /// // on the main queue. - /// let observation = ValueObservation.trackingAll(Player.all()) + /// let observation = Player.observationForAll() /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") /// } @@ -135,7 +135,7 @@ public struct ValueObservation { /// the observation. /// /// // On any queue - /// var observation = ValueObservation.trackingAll(Player.all()) + /// var observation = Player.observationForAll() /// observation.scheduling = .unsafe(startImmediately: true) /// let observer = try observation.start(in: dbQueue) { players: [Player] in /// print("fresh players: \(players)") @@ -146,18 +146,6 @@ public struct ValueObservation { /// unspecified queues. public var scheduling: ValueScheduling = .mainQueue - /// The dispatch queue where change callbacks are called. - var notificationQueue: DispatchQueue? { - switch scheduling { - case .mainQueue: - return DispatchQueue.main - case let .async(onQueue: queue, startImmediately: _): - return queue - case .unsafe: - return nil - } - } - // Not public because we foster DatabaseRegionConvertible. // See ValueObservation.tracking(_:reducer:) init( @@ -185,18 +173,42 @@ extension ValueObservation where Reducer: ValueReducer { /// a database queue or database pool), and returns a transaction observer. /// /// - parameter reader: A DatabaseReader. - /// - parameter onError: A closure that is provided eventual errors that - /// happen during observation /// - parameter onChange: A closure that is provided fresh values /// - returns: a TransactionObserver public func start( in reader: DatabaseReader, - onError: ((Error) -> Void)? = nil, onChange: @escaping (Reducer.Value) -> Void) throws -> TransactionObserver { - return try reader.add(observation: self, onError: onError, onChange: onChange) + // ErrorCatcher is a workaround this aging API. + // We catch the eventual error synchronously sent to the onError + // handler and rethrow it. + let errorCatcher = ErrorCatcher() + let observer = reader.add( + observation: self, + onError: { [weak errorCatcher] in errorCatcher?.error = $0 }, + onChange: onChange) + if let error = errorCatcher.error { + throw error + } + return observer } + /// Starts the value observation in the provided database reader (such as + /// a database queue or database pool), and returns a transaction observer. + /// + /// - parameter reader: A DatabaseReader. + /// - parameter onError: A closure that is provided eventual errors that + /// happen during observation + /// - parameter onChange: A closure that is provided fresh values + /// - returns: a TransactionObserver + public func start( + in reader: DatabaseReader, + onError: @escaping (Error) -> Void, + onChange: @escaping (Reducer.Value) -> Void) -> TransactionObserver + { + return reader.add(observation: self, onError: onError, onChange: onChange) + } + // MARK: - Fetching Values /// Returns the observed value. @@ -207,9 +219,8 @@ extension ValueObservation where Reducer: ValueReducer { /// For example, the observation below notifies changes to a player if and /// only if it exists: /// - /// let request = Player.filter(key: 42) - /// let observation = ValueObservation - /// .trackingOne(request) + /// let observation = Player.filter(key: 42) + /// .observationForFirst() /// .compactMap { $0 } // filters out missing player /// /// The `fetchFirst` method thus returns nil if player does not exist: @@ -223,6 +234,11 @@ extension ValueObservation where Reducer: ValueReducer { } } +// TODO: remove when not needed any longer +private class ErrorCatcher { + var error: Error? +} + extension ValueObservation { // MARK: - Creating ValueObservation from ValueReducer @@ -344,7 +360,7 @@ extension ValueObservation where Reducer == Void { public static func tracking( _ regions: DatabaseRegionConvertible..., fetch: @escaping (Database) throws -> Value) - -> ValueObservation> + -> ValueObservation> { return ValueObservation.tracking(regions, fetch: fetch) } @@ -377,10 +393,10 @@ extension ValueObservation where Reducer == Void { public static func tracking( _ regions: [DatabaseRegionConvertible], fetch: @escaping (Database) throws -> Value) - -> ValueObservation> + -> ValueObservation> { - return ValueObservation>( + return ValueObservation>( tracking: DatabaseRegion.union(regions), - reducer: { _ in ValueReducers.Passthrough(fetch) }) + reducer: { _ in ValueReducers.Fetch(fetch) }) } } diff --git a/GRDB/ValueObservation/ValueObserver.swift b/GRDB/ValueObservation/ValueObserver.swift index 9a1260ab78..99754cb227 100644 --- a/GRDB/ValueObservation/ValueObserver.swift +++ b/GRDB/ValueObservation/ValueObserver.swift @@ -3,41 +3,45 @@ import Foundation /// Support for ValueObservation. /// See DatabaseWriter.add(observation:onError:onChange:) class ValueObserver: TransactionObserver { - /* private */ let region: DatabaseRegion // Internal for testability - private var reducer: Reducer + // Region, reducer, and notificationQueue must be set before observer is + // added to a database. + var region: DatabaseRegion! + var reducer: Reducer! + var notificationQueue: DispatchQueue? + private var requiresWriteAccess: Bool unowned private var writer: DatabaseWriter - private let notificationQueue: DispatchQueue? private let reduceQueue: DispatchQueue - private let onError: ((Error) -> Void)? + private let onError: (Error) -> Void private let onChange: (Reducer.Value) -> Void private var isChanged = false + private var isCancelled = false init( - region: DatabaseRegion, - reducer: Reducer, requiresWriteAccess: Bool, writer: DatabaseWriter, - notificationQueue: DispatchQueue?, reduceQueue: DispatchQueue, - onError: ((Error) -> Void)?, + onError: @escaping (Error) -> Void, onChange: @escaping (Reducer.Value) -> Void) { self.writer = writer - self.region = region - self.reducer = reducer self.requiresWriteAccess = requiresWriteAccess - self.notificationQueue = notificationQueue self.reduceQueue = reduceQueue self.onError = onError self.onChange = onChange } + func cancel() { + isCancelled = true + } + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { + if isCancelled { return false } return region.isModified(byEventsOfKind: eventKind) } func databaseDidChange(with event: DatabaseEvent) { + if isCancelled { return } if region.isModified(by: event) { isChanged = true stopObservingDatabaseChangesUntilNextTransaction() @@ -45,6 +49,7 @@ class ValueObserver: TransactionObserver { } func databaseDidCommit(_ db: Database) { + if isCancelled { return } guard isChanged else { return } isChanged = false @@ -56,31 +61,35 @@ class ValueObserver: TransactionObserver { // - that expensive reduce operations are computed without blocking // any database dispatch queue. reduceQueue.async { [weak self] in - // Never ever retain self so that notifications stop when self - // is deallocated by the user. - do { - if let value = try self?.reducer.value(future.wait()) { - if let queue = self?.notificationQueue { - queue.async { - self?.onChange(value) - } - } else { - self?.onChange(value) - } - } - } catch { - guard self?.onError != nil else { - // TODO: how can we let the user know about the error? - return - } - if let queue = self?.notificationQueue { - queue.async { - self?.onError?(error) + guard let self = self else { return } + if self.isCancelled { return } + self.reduce(future: future) + } + } + + func reduce(future: DatabaseFuture) { + do { + if let value = try reducer.value(future.wait()) { + if let queue = notificationQueue { + queue.async { [weak self] in + guard let self = self else { return } + if self.isCancelled { return } + self.onChange(value) } } else { - self?.onError?(error) + onChange(value) } } + } catch { + if let queue = notificationQueue { + queue.async { [weak self] in + guard let self = self else { return } + if self.isCancelled { return } + self.onError(error) + } + } else { + onError(error) + } } } @@ -88,3 +97,26 @@ class ValueObserver: TransactionObserver { isChanged = false } } + +class ValueObserverToken: TransactionObserver { + // Useless junk + func observes(eventsOfKind eventKind: DatabaseEventKind) -> Bool { return false } + func databaseDidChange(with event: DatabaseEvent) { } + func databaseDidCommit(_ db: Database) { } + func databaseDidRollback(_ db: Database) { } + + weak var writer: DatabaseWriter? + var observer: ValueObserver + + init(writer: DatabaseWriter, observer: ValueObserver) { + self.writer = writer + self.observer = observer + } + + // The most ugly stuff ever + deinit { + observer.cancel() + // TODO: have it not wait for the writer queue + writer?.remove(transactionObserver: observer) + } +} diff --git a/GRDB/ValueObservation/ValueReducer/Combine.swift b/GRDB/ValueObservation/ValueReducer/Combine.swift index a2259b5335..178cc06fbe 100644 --- a/GRDB/ValueObservation/ValueReducer/Combine.swift +++ b/GRDB/ValueObservation/ValueReducer/Combine.swift @@ -60,6 +60,18 @@ extension ValueObservation where Reducer == Void { } } +extension ValueObservation where Reducer: ValueReducer { + public func combine< + R1: ValueReducer, + Combined>( + _ other: ValueObservation, + _ transform: @escaping (Reducer.Value, R1.Value) -> Combined) + -> ValueObservation, Combined>> + { + return ValueObservation.combine(self, other).map(transform) + } +} + // MARK: - Combine 3 Reducers extension ValueReducers { @@ -134,6 +146,25 @@ extension ValueObservation where Reducer == Void { } } +extension ValueObservation where Reducer: ValueReducer { + public func combine< + R1: ValueReducer, + R2: ValueReducer, + Combined>( + _ observation1: ValueObservation, + _ observation2: ValueObservation, + _ transform: @escaping (Reducer.Value, R1.Value, R2.Value) -> Combined) + -> ValueObservation, Combined>> + { + return ValueObservation + .combine( + self, + observation1, + observation2) + .map(transform) + } +} + // MARK: - Combine 4 Reducers extension ValueReducers { @@ -220,6 +251,28 @@ extension ValueObservation where Reducer == Void { } } +extension ValueObservation where Reducer: ValueReducer { + public func combine< + R1: ValueReducer, + R2: ValueReducer, + R3: ValueReducer, + Combined>( + _ observation1: ValueObservation, + _ observation2: ValueObservation, + _ observation3: ValueObservation, + _ transform: @escaping (Reducer.Value, R1.Value, R2.Value, R3.Value) -> Combined) + -> ValueObservation, Combined>> + { + return ValueObservation + .combine( + self, + observation1, + observation2, + observation3) + .map(transform) + } +} + // MARK: - Combine 5 Reducers extension ValueReducers { @@ -318,6 +371,31 @@ extension ValueObservation where Reducer == Void { } } +extension ValueObservation where Reducer: ValueReducer { + public func combine< + R1: ValueReducer, + R2: ValueReducer, + R3: ValueReducer, + R4: ValueReducer, + Combined>( + _ observation1: ValueObservation, + _ observation2: ValueObservation, + _ observation3: ValueObservation, + _ observation4: ValueObservation, + _ transform: @escaping (Reducer.Value, R1.Value, R2.Value, R3.Value, R4.Value) -> Combined) + -> ValueObservation, Combined>> + { + return ValueObservation + .combine( + self, + observation1, + observation2, + observation3, + observation4) + .map(transform) + } +} + // MARK: - Combine 6 Reducers extension ValueReducers { diff --git a/GRDB/ValueObservation/ValueReducer/Passthrough.swift b/GRDB/ValueObservation/ValueReducer/Fetch.swift similarity index 76% rename from GRDB/ValueObservation/ValueReducer/Passthrough.swift rename to GRDB/ValueObservation/ValueReducer/Fetch.swift index 6964d8d3f4..95cef1e14a 100644 --- a/GRDB/ValueObservation/ValueReducer/Passthrough.swift +++ b/GRDB/ValueObservation/ValueReducer/Fetch.swift @@ -4,7 +4,7 @@ extension ValueReducers { /// A reducer which pass raw fetched values through. /// /// :nodoc: - public struct Passthrough: ValueReducer { + public struct Fetch: ValueReducer { private let _fetch: (Database) throws -> Value public init(_ fetch: @escaping (Database) throws -> Value) { @@ -22,5 +22,5 @@ extension ValueReducers { } /// :nodoc: -@available(*, deprecated, renamed: "ValueReducers.Passthrough") -public typealias RawValueReducer = ValueReducers.Passthrough +@available(*, deprecated, renamed: "ValueReducers.Fetch") +public typealias RawValueReducer = ValueReducers.Fetch diff --git a/GRDB/ValueObservation/ValueReducer/RemoveDuplicates.swift b/GRDB/ValueObservation/ValueReducer/RemoveDuplicates.swift index 1344b8cef2..0aec4ceffe 100644 --- a/GRDB/ValueObservation/ValueReducer/RemoveDuplicates.swift +++ b/GRDB/ValueObservation/ValueReducer/RemoveDuplicates.swift @@ -35,7 +35,7 @@ extension ValueReducer where Value: Equatable { extension ValueReducers { /// [**Experimental**](http://github.com/groue/GRDB.swift#what-are-experimental-features) /// - /// See ValueReducer.distinctUntilChanged() + /// See ValueReducer.removeDuplicates() /// /// :nodoc: public struct RemoveDuplicates: ValueReducer where Base.Value: Equatable { diff --git a/GRDBCustom.xcodeproj/project.pbxproj b/GRDBCustom.xcodeproj/project.pbxproj index fc820b35ba..5321dc83d8 100755 --- a/GRDBCustom.xcodeproj/project.pbxproj +++ b/GRDBCustom.xcodeproj/project.pbxproj @@ -405,8 +405,8 @@ 56BB6EAE1D3009B100A1CA52 /* SchedulingWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */; }; 56C0539122ACEECD0029D27D /* CompactMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538922ACEECD0029D27D /* CompactMap.swift */; }; 56C0539222ACEECD0029D27D /* CompactMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538922ACEECD0029D27D /* CompactMap.swift */; }; - 56C0539322ACEECD0029D27D /* Passthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538A22ACEECD0029D27D /* Passthrough.swift */; }; - 56C0539422ACEECD0029D27D /* Passthrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538A22ACEECD0029D27D /* Passthrough.swift */; }; + 56C0539322ACEECD0029D27D /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538A22ACEECD0029D27D /* Fetch.swift */; }; + 56C0539422ACEECD0029D27D /* Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538A22ACEECD0029D27D /* Fetch.swift */; }; 56C0539522ACEECD0029D27D /* ValueObservation+Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538B22ACEECD0029D27D /* ValueObservation+Row.swift */; }; 56C0539622ACEECD0029D27D /* ValueObservation+Row.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538B22ACEECD0029D27D /* ValueObservation+Row.swift */; }; 56C0539722ACEECD0029D27D /* ValueReducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56C0538C22ACEECD0029D27D /* ValueReducer.swift */; }; @@ -983,7 +983,7 @@ 56B964C21DA521450002DA19 /* FTS5TableBuilderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FTS5TableBuilderTests.swift; sourceTree = ""; }; 56BB6EA81D3009B100A1CA52 /* SchedulingWatchdog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchedulingWatchdog.swift; sourceTree = ""; }; 56C0538922ACEECD0029D27D /* CompactMap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompactMap.swift; sourceTree = ""; }; - 56C0538A22ACEECD0029D27D /* Passthrough.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Passthrough.swift; sourceTree = ""; }; + 56C0538A22ACEECD0029D27D /* Fetch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetch.swift; sourceTree = ""; }; 56C0538B22ACEECD0029D27D /* ValueObservation+Row.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ValueObservation+Row.swift"; sourceTree = ""; }; 56C0538C22ACEECD0029D27D /* ValueReducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValueReducer.swift; sourceTree = ""; }; 56C0538D22ACEECD0029D27D /* Combine.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Combine.swift; sourceTree = ""; }; @@ -1682,12 +1682,12 @@ 56C0538822ACEECD0029D27D /* ValueReducer */ = { isa = PBXGroup; children = ( - 56C0538922ACEECD0029D27D /* CompactMap.swift */, - 56C0538A22ACEECD0029D27D /* Passthrough.swift */, - 56C0538C22ACEECD0029D27D /* ValueReducer.swift */, 56C0538D22ACEECD0029D27D /* Combine.swift */, + 56C0538922ACEECD0029D27D /* CompactMap.swift */, + 56C0538A22ACEECD0029D27D /* Fetch.swift */, 56C0538E22ACEECD0029D27D /* Map.swift */, 56C0538F22ACEECD0029D27D /* RemoveDuplicates.swift */, + 56C0538C22ACEECD0029D27D /* ValueReducer.swift */, ); path = ValueReducer; sourceTree = ""; @@ -2118,7 +2118,7 @@ 5656A87C2295BD56001FF3FF /* QueryInterfaceRequest+Association.swift in Sources */, F3BA800B1CFB286D003DC1BA /* Database.swift in Sources */, F3BA80341CFB28A4003DC1BA /* Record.swift in Sources */, - 56C0539422ACEECD0029D27D /* Passthrough.swift in Sources */, + 56C0539422ACEECD0029D27D /* Fetch.swift in Sources */, 56E9FAC52210468500C703A8 /* SQLInterpolation.swift in Sources */, 563EF421215F8A76007DAACD /* OrderedDictionary.swift in Sources */, F3BA80161CFB2876003DC1BA /* Row.swift in Sources */, @@ -2449,7 +2449,7 @@ 5656A87B2295BD56001FF3FF /* QueryInterfaceRequest+Association.swift in Sources */, F3BA80671CFB2E55003DC1BA /* Database.swift in Sources */, F3BA80901CFB2E7A003DC1BA /* Record.swift in Sources */, - 56C0539322ACEECD0029D27D /* Passthrough.swift in Sources */, + 56C0539322ACEECD0029D27D /* Fetch.swift in Sources */, 56E9FAC42210468500C703A8 /* SQLInterpolation.swift in Sources */, 563EF420215F8A76007DAACD /* OrderedDictionary.swift in Sources */, F3BA80721CFB2E55003DC1BA /* Row.swift in Sources */, diff --git a/README.md b/README.md index b699020ad8..b3eaa92bf0 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,7 @@ See the [Query Interface](#the-query-interface) ```swift let request = Place.order(Column("title")) -try ValueObservation - .trackingAll(request) +try request.observationForAll() .start(in: dbQueue) { (places: [Place]) in print("Places have changed.") } @@ -2917,8 +2916,7 @@ try dbQueue.read { db in ```swift // Observe changes -try ValueObservation - .trackingOne(Player.maximumScore) +try Player.maximumScore.observationForFirst() .start(in: dbQueue) { (maxScore: Int?) in print("The maximum score has changed") } @@ -4484,8 +4482,7 @@ When you also want to use database observation tools such as [ValueObservation], } // Observe with ValueObservation - try ValueObservation - .trackingOne(request) + try request.observationForFirst() .start(in: dbQueue) { (maxScore: Int?) in print("The maximum score has changed") } @@ -4510,8 +4507,7 @@ When you also want to use database observation tools such as [ValueObservation], } // Observe with ValueObservation - try ValueObservation - .trackingAll(request) + try request.observationForAll() .start(in: dbQueue) { (bookInfos: [BookInfo]) in print("Books have changed") } @@ -4584,8 +4580,7 @@ let player = try request.fetchOne(db) // Player? Those requests can feed [ValueObservation]: ```swift -try ValueObservation. - .trackingOne(Player.filter(key: 1)) +try Player.filter(key: 1).observationForFirst() .start(in: dbQueue) { (player: Player?) in print("Player 1 has changed") } @@ -4704,8 +4699,7 @@ try Player.customRequest().fetchAll(db) // [Player] Custom requests can also feed [ValueObservation]: ```swift -try ValueObservation. - .trackingAll(Player.customRequest(...)) +try Player.customRequest(...).observationForAll() .start(in: dbQueue) { (players: [Player]) in print("Players have changed") } @@ -6119,7 +6113,7 @@ let request = Player.all() [ValueObservation] notifies your application with **fresh values** (this is what most applications need :+1:): ```swift -let observation = ValueObservation.trackingAll(request) +let observation = request.observationForAll() let observer = observation.start(in: dbQueue) { (players: [Player]) in let names = players.map { $0.name }.joined(separator: ", ") print("Fresh players: \(names)") @@ -6157,7 +6151,7 @@ Changes are only notified after they have been committed in the database. No ins - **[ValueObservation Usage](#valueobservation-usage)** -- [ValueObservation.trackingCount, trackingOne, trackingAll](#valueobservationtrackingcount-trackingone-trackingall) +- [observationForCount, observationForAll, observationForFirst](#observationforcount-observationforall-observationforfirst) - [ValueObservation.tracking(_:fetch:)](#valueobservationtracking_fetch) - [ValueObservation Transformations](#valueobservation-transformations): [map](#valueobservationmap), [compactMap](#valueobservationcompactmap), ... - [ValueObservation Error Handling](#valueobservation-error-handling) @@ -6179,7 +6173,7 @@ class PlayerViewController: UIViewController { // Define a ValueObservation which tracks a player let request = Player.filter(key: 42) - let observation = ValueObservation.trackingOne(request) + let observation = request.observationForFirst() // Start observing the database observer = try! observation.start( @@ -6215,7 +6209,7 @@ The observer returned by the `start` method is stored in a property of the view > > // Define a ValueObservation which tracks a player > let request = Player.filter(key: 42) -> var observation = ValueObservation.trackingOne(request) +> var observation = request.observationForFirst() > > // Observation is asynchronous > observation.scheduling = .async(onQueue: .main, startImmediately: true) @@ -6237,68 +6231,63 @@ The observer returned by the `start` method is stored in a property of the view > > See [ValueObservation.scheduling](#valueobservationscheduling) for more information. -### ValueObservation.trackingCount, trackingOne, trackingAll +### observationForCount, observationForAll, observationForFirst -Given a [request](#requests), you can track its number of results, the first one, or all of them: +Given a [request](#requests), you can observe its number of results, all results, or the first one: ```swift -ValueObservation.trackingCount(request) -ValueObservation.trackingOne(request) -ValueObservation.trackingAll(request) +request.observationForCount() +request.observationForAll() +request.observationForFirst() ``` -Those observations match the `fetchCount`, `fetchOne`, and `fetchAll` request methods: +Those observations match the `fetchCount`, `fetchAll`, and `fetchOne` request methods: -- `trackingCount` notifies counts: +- `observationForCount()` notifies counts: ```swift // Observe number of players - let observer = ValueObservation - .trackingCount(Player.all()) + let observer = Player.observationForCount() .start(in: dbQueue) { (count: Int) in print("Number of players have changed: \(count)") } ``` -- `trackingOne` notifies optional values, built from a single database row (if any): +- `observationForAll()` notifies arrays: + + ```swift + // Observe all players + let observer = Player.observationForAll() + .start(in: dbQueue) { (players: [Player]) in + print("Players have changed: \(players)") + } + + // Observe all player names + let request = SQLRequest(sql: "SELECT name FROM player") + let observer = request.observationForAll() + .start(in: dbQueue) { (names: [String]) in + print("Player names have changed: \(names)") + } + ``` + +- `observationForFirst()` notifies optional values, built from a single database row (if any): ```swift // Observe a single player - let observer = ValueObservation - .trackingOne(Player.filter(key: 1)) + let observer = Player.filter(key: 1).observationForFirst() .start(in: dbQueue) { (player: Player?) in print("Player has changed: \(player)") } // Observe the maximum score let request = Player.select(max(Column("score")), as: Int.self) - let observer = ValueObservation - .trackingOne(request) + let observer = request.observationForFirst() .start(in: dbQueue) { (maximumScore: Int?) in print("Maximum score has changed: \(maximumScore)") } ``` -- `trackingAll` notifies arrays: - - ```swift - // Observe all players - let observer = ValueObservation - .trackingAll(Player.all()) - .start(in: dbQueue) { (players: [Player]) in - print("Players have changed: \(players)") - } - - // Observe all player names - let request = SQLRequest(sql: "SELECT name FROM player") - let observer = ValueObservation - .trackingAll(request) - .start(in: dbQueue) { (names: [String]) in - print("Player names have changed: \(names)") - } - ``` - -> :point_up: **Note**: the observations returned by the [ValueObservation.trackingCount, trackingOne, and trackingAll](#valueobservationtrackingcount-trackingone-trackingall) methods perform a filtering of consecutive identical values, based on raw database values. +> :point_up: **Note**: observations returned by those methods perform a filtering of consecutive identical values, based on raw database values. ### ValueObservation.tracking(_:fetch:) @@ -6359,14 +6348,14 @@ It may happen that a database change does not modify the observed values. The Ha When such a database change happens, `ValueObservation.tracking(_:fetch:)` is triggered, just in case the best players would be modified, and ends up notifying identical consecutive values. -You can filter out those duplicates with the [ValueObservation.distinctUntilChanged](#valueobservationdistinctuntilchanged) method. It requires the observed value to adopt the Equatable protocol: +You can filter out those duplicates with the [ValueObservation.removeDuplicates](#valueobservationremoveduplicates) method. It requires the observed value to adopt the Equatable protocol: ```swift extension HallOfFame: Equatable { ... } let observation = ValueObservation .tracking(Player.all(), fetch: HallOfFame.fetch) - .distinctUntilChanged() + .removeDuplicates() let observer = observation.start(in: dbQueue) { (hallOfFame: HallOfFame) in print(""" @@ -6442,7 +6431,7 @@ let observer = ValueObservation - [ValueObservation.map](#valueobservationmap) - [ValueObservation.compactMap](#valueobservationcompactmap) -- [ValueObservation.distinctUntilChanged](#valueobservationdistinctuntilchanged) +- [ValueObservation.removeDuplicates](#valueobservationremoveduplicates) - [ValueObservation.combine(...)](#valueobservationcombine) @@ -6454,9 +6443,8 @@ For example: ```swift // Observe a player's profile image -let observation = ValueObservation - .trackingOne(Player.filter(key: 42)) - .map { player in player?.loadBigProfileImage() } +let observation = Player.filter(key: 42).observationForFirst() + .map { player in player?.image } let observer = observation.start(in: dbQueue) { (image: UIImage?) in print("Player picture has changed") @@ -6474,8 +6462,7 @@ For example: ```swift // Observe a player -let observation = ValueObservation - .trackingOne(Player.filter(key: 42)) +let observation = Player.filter(key: 42).observationForFirst() .compactMap { $0 } let observer = observation.start(in: dbQueue) { (player: Player) in @@ -6486,17 +6473,16 @@ let observer = observation.start(in: dbQueue) { (player: Player) in The transformation closure does not run on the main queue, and is suitable for heavy computations. -#### ValueObservation.distinctUntilChanged +#### ValueObservation.removeDuplicates -The `distinctUntilChanged` method filters out the consecutive equal values notified by a ValueObservation. The observed values must adopt the standard Equatable protocol. +The `removeDuplicates` method filters out the consecutive equal values notified by a ValueObservation. The observed values must adopt the standard Equatable protocol. For example: ```swift -let observation = ValueObservation - .trackingOne(Player.filter(key: 42)) +let observation = Player.filter(key: 42).observationForFirst() .map { player in player != nil } // existence test - .distinctUntilChanged() + .removeDuplicates() let observer = observation.start(in: dbQueue) { (exists: Bool) in if exists { @@ -6507,38 +6493,55 @@ let observer = observation.start(in: dbQueue) { (exists: Bool) in } ``` -> :point_up: **Note**: the observations returned by the [ValueObservation.trackingCount, trackingOne, and trackingAll](#valueobservationtrackingcount-trackingone-trackingall) methods already perform a similar filtering, based on raw database values. +> :point_up: **Note**: the observations returned by the [observationForCount, observationForAll, observationForFirst](#observationforcount-observationforall-observationforfirst) methods already perform a similar filtering, based on raw database values. #### ValueObservation.combine(...) -Sometimes you need to observe several requests at the same time. For example, you need to observe changes in both a team and its players. +Sometimes you need to observe several requests at the same time. When this happens, **combine** several observations together with the `ValueObservation.combine(...)` method: ```swift -// The two observed requests (the team and its players) -let teamRequest = Team.filter(key: 1) -let playersRequest = Player.filter(Column("teamId") == 1) +struct HallOfFame { + /// Total number of players + var playerCount: Int + + /// The best ones + var bestPlayers: [Player] +} -// Two observations -let teamObservation = ValueObservation.trackingOne(teamRequest) -let playersObservation = ValueObservation.trackingAll(playersRequest) +// The two base observations +let playerCountObservation = Player.observationForCount() +let bestPlayersObservation = Player + .limit(10) + .order(Column("score").desc) + .observationForAll() // The combined observation -let observation = ValueObservation.combine(teamObservation, playersObservation) +let observation = ValueObservation + .combine(playerCountObservation, bestPlayersObservation) + .map { HallOfFame(playerCount: $0, bestPlayers: $1) } -// Start tracking players and teams -let observer = observation.start(in: dbQueue) { (team: Team?, players: [Player]) in - print("Team or players have changed.") +// Start tracking the hall of fame +let observer = observation.start(in: dbQueue) { (hallOfFame: HallOfFame) in + print("The hall of fame has changed.") } ``` +`combine` also exists as an instance method: + +```swift +let observation = playerCountObservation.combine(bestPlayersObservation) { + HallOfFame(playerCount: $0, bestPlayers: $1) +} +``` + +You can combine up to eight observations together. They can feed from as many database tables as needed. + Combining observations provides the guarantee that notified values are [**consistent**](https://en.wikipedia.org/wiki/Consistency_(database_systems)). -> :point_up: **Note**: you can combine up to eight observations together. If you need more, combine several combined observations, or please submit a pull request. -> -> :point_up: **Note**: readers who are familiar with Reactive Programming will recognize the [CombineLatest](http://reactivex.io/documentation/operators/combinelatest.html) operator in the `ValueObservation.combine` method. The reactive operator does not care about data consistency, though: if you use a Reactive layer such as [RxGRDB], compose observations with `ValueObservation.combine`, not with the CombineLatest operator. +> :point_up: **Note**: readers who are familiar with Reactive Programming will recognize the [CombineLatest](http://reactivex.io/documentation/operators/combinelatest.html) operator in the ValueObservation `combine` method. The reactive operator does not care about data consistency, though: if you use a Reactive layer such as [RxGRDB] or [Combine], make sure you compose observations with `ValueObservation.combine`, not with the CombineLatest operator. ### ValueObservation Error Handling @@ -6575,8 +6578,7 @@ The `scheduling` property lets you control how fresh values are notified: ```swift // On main queue - let observer = ValueObservation - .trackingAll(Player.all()) + let observer = Player.observationForAll() .start(in: dbQueue) { (players: [Player]) in // On main queue print("fresh players: \(players)") @@ -6588,8 +6590,7 @@ The `scheduling` property lets you control how fresh values are notified: ```swift // Not on the main queue - let observer = ValueObservation - .trackingAll(Player.all()) + let observer = Player.observationForAll() .start(in: dbQueue) { (players: [Player]) in // On main queue print("fresh players: \(players)") @@ -6613,7 +6614,7 @@ The `scheduling` property lets you control how fresh values are notified: ```swift // On main queue - var observation = ValueObservation.trackingAll(Player.all()) + var observation = Player.observationForAll() observation.scheduling = .async(onQueue: .main, startImmediately: true) let observer = try observation.start(in: dbQueue) { (players: [Player]) in // On main queue @@ -6628,7 +6629,7 @@ The `scheduling` property lets you control how fresh values are notified: ```swift // On any queue - var observation = ValueObservation.trackingAll(Player.all()) + var observation = Player.observationForAll() observation.scheduling = .unsafe(startImmediately: true) let observer = try observation.start(in: dbQueue) { (players: [Player]) in print("fresh players: \(players)") @@ -8856,3 +8857,4 @@ This chapter has been renamed [Beyond FetchableRecord]. [DatabaseRegion]: #databaseregion [SQL Interpolation]: Documentation/SQLInterpolation.md [custom SQLite build]: Documentation/CustomSQLiteBuilds.md +[Combine]: https://developer.apple.com/documentation/combine diff --git a/Tests/GRDBTests/ValueObservationCombineTests.swift b/Tests/GRDBTests/ValueObservationCombineTests.swift index 34e827196d..a4e5e2e42c 100644 --- a/Tests/GRDBTests/ValueObservationCombineTests.swift +++ b/Tests/GRDBTests/ValueObservationCombineTests.swift @@ -27,8 +27,8 @@ class ValueObservationCombineTests: GRDBTestCase { struct T1: TableRecord { } struct T2: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() let observation = ValueObservation.combine(observation1, observation2) let observer = try observation.start(in: dbQueue) { v0, v1 in values.append([v0, v1]) @@ -86,9 +86,9 @@ class ValueObservationCombineTests: GRDBTestCase { struct T1: TableRecord { } struct T2: TableRecord { } struct T3: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3) let observer = try observation.start(in: dbQueue) { v0, v1, v2 in values.append([v0, v1, v2]) @@ -159,10 +159,10 @@ class ValueObservationCombineTests: GRDBTestCase { struct T2: TableRecord { } struct T3: TableRecord { } struct T4: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) - let observation4 = ValueObservation.trackingCount(T4.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() + let observation4 = T4.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3, observation4) let observer = try observation.start(in: dbQueue) { v0, v1, v2, v3 in values.append([v0, v1, v2, v3]) @@ -246,11 +246,11 @@ class ValueObservationCombineTests: GRDBTestCase { struct T3: TableRecord { } struct T4: TableRecord { } struct T5: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) - let observation4 = ValueObservation.trackingCount(T4.all()) - let observation5 = ValueObservation.trackingCount(T5.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() + let observation4 = T4.observationForCount() + let observation5 = T5.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3, observation4, observation5) let observer = try observation.start(in: dbQueue) { v0, v1, v2, v3, v4 in values.append([v0, v1, v2, v3, v4]) @@ -347,12 +347,12 @@ class ValueObservationCombineTests: GRDBTestCase { struct T4: TableRecord { } struct T5: TableRecord { } struct T6: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) - let observation4 = ValueObservation.trackingCount(T4.all()) - let observation5 = ValueObservation.trackingCount(T5.all()) - let observation6 = ValueObservation.trackingCount(T6.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() + let observation4 = T4.observationForCount() + let observation5 = T5.observationForCount() + let observation6 = T6.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3, observation4, observation5, observation6) let observer = try observation.start(in: dbQueue) { v0, v1, v2, v3, v4, v5 in values.append([v0, v1, v2, v3, v4, v5]) @@ -462,13 +462,13 @@ class ValueObservationCombineTests: GRDBTestCase { struct T5: TableRecord { } struct T6: TableRecord { } struct T7: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) - let observation4 = ValueObservation.trackingCount(T4.all()) - let observation5 = ValueObservation.trackingCount(T5.all()) - let observation6 = ValueObservation.trackingCount(T6.all()) - let observation7 = ValueObservation.trackingCount(T7.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() + let observation4 = T4.observationForCount() + let observation5 = T5.observationForCount() + let observation6 = T6.observationForCount() + let observation7 = T7.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3, observation4, observation5, observation6, observation7) let observer = try observation.start(in: dbQueue) { v0, v1, v2, v3, v4, v5, v6 in values.append([v0, v1, v2, v3, v4, v5, v6]) @@ -591,14 +591,14 @@ class ValueObservationCombineTests: GRDBTestCase { struct T6: TableRecord { } struct T7: TableRecord { } struct T8: TableRecord { } - let observation1 = ValueObservation.trackingCount(T1.all()) - let observation2 = ValueObservation.trackingCount(T2.all()) - let observation3 = ValueObservation.trackingCount(T3.all()) - let observation4 = ValueObservation.trackingCount(T4.all()) - let observation5 = ValueObservation.trackingCount(T5.all()) - let observation6 = ValueObservation.trackingCount(T6.all()) - let observation7 = ValueObservation.trackingCount(T7.all()) - let observation8 = ValueObservation.trackingCount(T8.all()) + let observation1 = T1.observationForCount() + let observation2 = T2.observationForCount() + let observation3 = T3.observationForCount() + let observation4 = T4.observationForCount() + let observation5 = T5.observationForCount() + let observation6 = T6.observationForCount() + let observation7 = T7.observationForCount() + let observation8 = T8.observationForCount() let observation = ValueObservation.combine(observation1, observation2, observation3, observation4, observation5, observation6, observation7, observation8) let observer = try observation.start(in: dbQueue) { v0, v1, v2, v3, v4, v5, v6, v7 in values.append([v0, v1, v2, v3, v4, v5, v6, v7]) @@ -822,4 +822,64 @@ class ValueObservationCombineTests: GRDBTestCase { _ = try observation.start(in: makeDatabaseQueue()) { value = $0 } XCTAssertNotNil(value) } + + func testHeterogeneusCombined2() throws { + struct V1 { } + struct V2 { } + struct Combined { } + let observation1 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V1() }) + let observation2 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V2() }) + let observation = observation1.combine(observation2) { (v1: V1, v2: V2) -> Combined in Combined() } + var value: Combined? + _ = try observation.start(in: makeDatabaseQueue()) { value = $0 } + XCTAssertNotNil(value) + } + + func testHeterogeneusCombined3() throws { + struct V1 { } + struct V2 { } + struct V3 { } + struct Combined { } + let observation1 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V1() }) + let observation2 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V2() }) + let observation3 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V3() }) + let observation = observation1.combine(observation2, observation3) { (v1: V1, v2: V2, v3: V3) -> Combined in Combined() } + var value: Combined? + _ = try observation.start(in: makeDatabaseQueue()) { value = $0 } + XCTAssertNotNil(value) + } + + func testHeterogeneusCombined4() throws { + struct V1 { } + struct V2 { } + struct V3 { } + struct V4 { } + struct Combined { } + let observation1 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V1() }) + let observation2 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V2() }) + let observation3 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V3() }) + let observation4 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V4() }) + let observation = observation1.combine(observation2, observation3, observation4) { (v1: V1, v2: V2, v3: V3, v4: V4) -> Combined in Combined() } + var value: Combined? + _ = try observation.start(in: makeDatabaseQueue()) { value = $0 } + XCTAssertNotNil(value) + } + + func testHeterogeneusCombined5() throws { + struct V1 { } + struct V2 { } + struct V3 { } + struct V4 { } + struct V5 { } + struct Combined { } + let observation1 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V1() }) + let observation2 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V2() }) + let observation3 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V3() }) + let observation4 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V4() }) + let observation5 = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { _ in V5() }) + let observation = observation1.combine(observation2, observation3, observation4, observation5) { (v1: V1, v2: V2, v3: V3, v4: V4, v5: V5) -> Combined in Combined() } + var value: Combined? + _ = try observation.start(in: makeDatabaseQueue()) { value = $0 } + XCTAssertNotNil(value) + } } diff --git a/Tests/GRDBTests/ValueObservationCountTests.swift b/Tests/GRDBTests/ValueObservationCountTests.swift index 6b45ca1a30..8d64788b63 100644 --- a/Tests/GRDBTests/ValueObservationCountTests.swift +++ b/Tests/GRDBTests/ValueObservationCountTests.swift @@ -11,7 +11,7 @@ import XCTest #endif class ValueObservationCountTests: GRDBTestCase { - func testCount() throws { + func testCountDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } @@ -44,4 +44,72 @@ class ValueObservationCountTests: GRDBTestCase { XCTAssertEqual(counts, [0, 1, 2, 3, 2]) } } + + func testCount() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } + + var counts: [Int] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 5 + + struct T: TableRecord { } + let observation = T.all().observationForCount() + let observer = try observation.start(in: dbQueue) { count in + counts.append(count) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") // +1 + try db.execute(sql: "UPDATE t SET id = id") // = + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") // +1 + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + try db.execute(sql: "DELETE FROM t WHERE id = 1") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 2") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(counts, [0, 1, 2, 3, 2]) + } + } + + func testTableRecordStaticCount() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } + + var counts: [Int] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 5 + + struct T: TableRecord { } + let observation = T.observationForCount() + let observer = try observation.start(in: dbQueue) { count in + counts.append(count) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") // +1 + try db.execute(sql: "UPDATE t SET id = id") // = + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") // +1 + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + try db.execute(sql: "DELETE FROM t WHERE id = 1") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 2") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(counts, [0, 1, 2, 3, 2]) + } + } } diff --git a/Tests/GRDBTests/ValueObservationDatabaseValueConvertibleTests.swift b/Tests/GRDBTests/ValueObservationDatabaseValueConvertibleTests.swift index 4e735a7ab9..eb42f82abf 100644 --- a/Tests/GRDBTests/ValueObservationDatabaseValueConvertibleTests.swift +++ b/Tests/GRDBTests/ValueObservationDatabaseValueConvertibleTests.swift @@ -28,7 +28,7 @@ private struct Name: DatabaseValueConvertible { } class ValueObservationDatabaseValueConvertibleTests: GRDBTestCase { - func testAll() throws { + func testAllDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -64,7 +64,43 @@ class ValueObservationDatabaseValueConvertibleTests: GRDBTestCase { } } - func testOne() throws { + func testAll() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [[Name]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT name FROM t ORDER BY id").observationForAll() + let observer = try observation.start(in: dbQueue) { names in + results.append(names) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 1") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.rawValue }}, [ + [], + ["foo"], + ["foo", "bar"], + ["bar"]]) + } + } + + func testOneDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -108,7 +144,51 @@ class ValueObservationDatabaseValueConvertibleTests: GRDBTestCase { } } - func testAllOptional() throws { + func testOne() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [Name?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 7 + + let observation = SQLRequest(sql: "SELECT name FROM t ORDER BY id DESC").observationForFirst() + let observer = try observation.start(in: dbQueue) { name in + results.append(name) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") + try db.inTransaction { + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'baz')") + try db.execute(sql: "UPDATE t SET name = NULL") + try db.execute(sql: "DELETE FROM t") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, NULL)") + try db.execute(sql: "UPDATE t SET name = 'qux'") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.rawValue }}, [ + nil, + "foo", + "bar", + nil, + "baz", + nil, + "qux"]) + } + } + + func testAllOptionalDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -144,6 +224,86 @@ class ValueObservationDatabaseValueConvertibleTests: GRDBTestCase { } } + func testAllOptional() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [[Name?]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT name FROM t ORDER BY id").observationForAll() + let observer = try observation.start(in: dbQueue) { names in + results.append(names) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, NULL)") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 1") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0?.rawValue }}, [ + [], + ["foo"], + ["foo", nil], + [nil]]) + } + } + + func testOneOptional() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [Name?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 7 + + let observation = SQLRequest(sql: "SELECT name FROM t ORDER BY id DESC").observationForFirst() + let observer = try observation.start(in: dbQueue) { name in + results.append(name) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") + try db.inTransaction { + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'baz')") + try db.execute(sql: "UPDATE t SET name = NULL") + try db.execute(sql: "DELETE FROM t") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, NULL)") + try db.execute(sql: "UPDATE t SET name = 'qux'") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.rawValue }}, [ + nil, + "foo", + "bar", + nil, + "baz", + nil, + "qux"]) + } + } + func testViewOptimization() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { @@ -168,13 +328,13 @@ class ValueObservationDatabaseValueConvertibleTests: GRDBTestCase { // Test that view v is not included in the observed region. // This optimization helps observation of views that feed from a // single table. - let observation = ValueObservation.trackingAll(request) + let observation = request.observationForAll() let observer = try observation.start(in: dbQueue) { names in results.append(names) notificationExpectation.fulfill() } - let valueObserver = observer as! ValueObserver> - XCTAssertEqual(valueObserver.region.description, "t(id,name)") // view is not tracked + let token = observer as! ValueObserverToken> // Non-public implementation detail + XCTAssertEqual(token.observer.region.description, "t(id,name)") // view is not tracked try withExtendedLifetime(observer) { // Test view observation try dbQueue.inDatabase { db in diff --git a/Tests/GRDBTests/ValueObservationFetchTests.swift b/Tests/GRDBTests/ValueObservationFetchTests.swift index 0a33aed555..b53bb44f53 100644 --- a/Tests/GRDBTests/ValueObservationFetchTests.swift +++ b/Tests/GRDBTests/ValueObservationFetchTests.swift @@ -52,7 +52,39 @@ class ValueObservationFetchTests: GRDBTestCase { try test(makeDatabasePool()) } - func testDistinctUntilChanged() throws { + func testDistinctUntilChangedDeprecated() throws { + func test(_ dbWriter: DatabaseWriter) throws { + try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } + + var counts: [Int] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 3 + + let observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { + try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t")! + }).distinctUntilChanged() + let observer = try observation.start(in: dbWriter) { count in + counts.append(count) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbWriter.writeWithoutTransaction { db in + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + try db.execute(sql: "UPDATE t SET id = id") + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(counts, [0, 1, 2]) + } + } + + try test(makeDatabaseQueue()) + try test(makeDatabasePool()) + } + + func testRemoveDuplicated() throws { func test(_ dbWriter: DatabaseWriter) throws { try dbWriter.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } diff --git a/Tests/GRDBTests/ValueObservationQueryInterfaceRequestTests.swift b/Tests/GRDBTests/ValueObservationQueryInterfaceRequestTests.swift index cc0bb7a703..8deae9f202 100644 --- a/Tests/GRDBTests/ValueObservationQueryInterfaceRequestTests.swift +++ b/Tests/GRDBTests/ValueObservationQueryInterfaceRequestTests.swift @@ -49,7 +49,7 @@ class ValueObservationQueryInterfaceRequestTests: GRDBTestCase { } } - func testOneRowWithPrefetchedRows() throws { + func testOneRowWithPrefetchedRowsDeprecated() throws { let dbQueue = try makeDatabaseQueue() var results: [Row?] = [] @@ -84,7 +84,42 @@ class ValueObservationQueryInterfaceRequestTests: GRDBTestCase { } } - func testAllRowsWithPrefetchedRows() throws { + func testOneRowWithPrefetchedRows() throws { + let dbQueue = try makeDatabaseQueue() + + var results: [Row?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 2 + + let request = Parent + .including(all: Parent.children.orderByPrimaryKey()) + .orderByPrimaryKey() + .asRequest(of: Row.self) + let observation = request.observationForFirst() + let observer = try observation.start(in: dbQueue) { row in + results.append(row) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "DELETE FROM child") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.count, 2) + + XCTAssertEqual(results[0]!.unscoped, ["id": 1, "name": "foo"]) + XCTAssertEqual(results[0]!.prefetchedRows["children"], [ + ["id": 1, "parentId": 1, "name": "fooA", "grdb_parentId": 1], + ["id": 2, "parentId": 1, "name": "fooB", "grdb_parentId": 1]]) + + XCTAssertEqual(results[1]!.unscoped, ["id": 1, "name": "foo"]) + XCTAssertEqual(results[1]!.prefetchedRows["children"], []) + } + } + + func testAllRowsWithPrefetchedRowsDeprecated() throws { let dbQueue = try makeDatabaseQueue() var results: [[Row]] = [] @@ -125,8 +160,50 @@ class ValueObservationQueryInterfaceRequestTests: GRDBTestCase { XCTAssertEqual(results[1][1].prefetchedRows["children"], []) } } - - func testOneRecordWithPrefetchedRows() throws { + + func testAllRowsWithPrefetchedRows() throws { + let dbQueue = try makeDatabaseQueue() + + var results: [[Row]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 2 + + let request = Parent + .including(all: Parent.children.orderByPrimaryKey()) + .orderByPrimaryKey() + .asRequest(of: Row.self) + let observation = request.observationForAll() + let observer = try observation.start(in: dbQueue) { rows in + results.append(rows) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "DELETE FROM child") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.count, 2) + + XCTAssertEqual(results[0].count, 2) + XCTAssertEqual(results[0][0].unscoped, ["id": 1, "name": "foo"]) + XCTAssertEqual(results[0][0].prefetchedRows["children"], [ + ["id": 1, "parentId": 1, "name": "fooA", "grdb_parentId": 1], + ["id": 2, "parentId": 1, "name": "fooB", "grdb_parentId": 1]]) + XCTAssertEqual(results[0][1].unscoped, ["id": 2, "name": "bar"]) + XCTAssertEqual(results[0][1].prefetchedRows["children"], [ + ["id": 3, "parentId": 2, "name": "barA", "grdb_parentId": 2]]) + + XCTAssertEqual(results[1].count, 2) + XCTAssertEqual(results[1][0].unscoped, ["id": 1, "name": "foo"]) + XCTAssertEqual(results[1][0].prefetchedRows["children"], []) + XCTAssertEqual(results[1][1].unscoped, ["id": 2, "name": "bar"]) + XCTAssertEqual(results[1][1].prefetchedRows["children"], []) + } + } + + func testOneRecordWithPrefetchedRowsDeprecated() throws { let dbQueue = try makeDatabaseQueue() var results: [ParentInfo?] = [] @@ -163,6 +240,92 @@ class ValueObservationQueryInterfaceRequestTests: GRDBTestCase { } } + func testOneRecordWithPrefetchedRows() throws { + let dbQueue = try makeDatabaseQueue() + + var results: [ParentInfo?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 2 + + let request = Parent + .including(all: Parent.children.orderByPrimaryKey()) + .orderByPrimaryKey() + .asRequest(of: ParentInfo.self) + let observation = request.observationForFirst() + let observer = try observation.start(in: dbQueue) { parentInfo in + results.append(parentInfo) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "DELETE FROM child") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results, [ + ParentInfo( + parent: Parent(id: 1, name: "foo"), + children: [ + Child(id: 1, parentId: 1, name: "fooA"), + Child(id: 2, parentId: 1, name: "fooB"), + ]), + ParentInfo( + parent: Parent(id: 1, name: "foo"), + children: []), + ]) + } + } + + func testAllRecordsWithPrefetchedRowsDeprecated() throws { + let dbQueue = try makeDatabaseQueue() + + var results: [[ParentInfo]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 2 + + let request = Parent + .including(all: Parent.children.orderByPrimaryKey()) + .orderByPrimaryKey() + .asRequest(of: ParentInfo.self) + let observation = ValueObservation.trackingAll(request) + let observer = try observation.start(in: dbQueue) { parentInfos in + results.append(parentInfos) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "DELETE FROM child") + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results, [ + [ + ParentInfo( + parent: Parent(id: 1, name: "foo"), + children: [ + Child(id: 1, parentId: 1, name: "fooA"), + Child(id: 2, parentId: 1, name: "fooB"), + ]), + ParentInfo( + parent: Parent(id: 2, name: "bar"), + children: [ + Child(id: 3, parentId: 2, name: "barA"), + ]), + ], + [ + ParentInfo( + parent: Parent(id: 1, name: "foo"), + children: []), + ParentInfo( + parent: Parent(id: 2, name: "bar"), + children: []), + ], + ]) + } + } + func testAllRecordsWithPrefetchedRows() throws { let dbQueue = try makeDatabaseQueue() @@ -175,7 +338,7 @@ class ValueObservationQueryInterfaceRequestTests: GRDBTestCase { .including(all: Parent.children.orderByPrimaryKey()) .orderByPrimaryKey() .asRequest(of: ParentInfo.self) - let observation = ValueObservation.trackingAll(request) + let observation = request.observationForAll() let observer = try observation.start(in: dbQueue) { parentInfos in results.append(parentInfos) notificationExpectation.fulfill() diff --git a/Tests/GRDBTests/ValueObservationReadonlyTests.swift b/Tests/GRDBTests/ValueObservationReadonlyTests.swift index 4698dc541a..a77fbb3a5e 100644 --- a/Tests/GRDBTests/ValueObservationReadonlyTests.swift +++ b/Tests/GRDBTests/ValueObservationReadonlyTests.swift @@ -38,7 +38,7 @@ class ValueObservationReadonlyTests: GRDBTestCase { } } - func testWriteObservationFailsByDefault() throws { + func testWriteObservationFailsByDefaultWithoutErrorHandling() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } @@ -46,11 +46,10 @@ class ValueObservationReadonlyTests: GRDBTestCase { try db.execute(sql: "INSERT INTO t DEFAULT VALUES") return 0 }) - + do { _ = try observation.start( in: dbQueue, - onError: { _ in fatalError() }, onChange: { _ in fatalError() }) XCTFail("Expected error") } catch let error as DatabaseError { @@ -61,6 +60,27 @@ class ValueObservationReadonlyTests: GRDBTestCase { } } + func testWriteObservationFailsByDefaultWithErrorHandling() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } + + let observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { db -> Int in + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + return 0 + }) + + var error: DatabaseError! + _ = observation.start( + in: dbQueue, + onError: { error = $0 as? DatabaseError }, + onChange: { _ in fatalError() }) + + XCTAssertEqual(error.resultCode, .SQLITE_READONLY) + XCTAssertEqual(error.message, "attempt to write a readonly database") + XCTAssertEqual(error.sql!, "INSERT INTO t DEFAULT VALUES") + XCTAssertEqual(error.description, "SQLite error 8 with statement `INSERT INTO t DEFAULT VALUES`: attempt to write a readonly database") + } + func testWriteObservation() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") } @@ -92,7 +112,7 @@ class ValueObservationReadonlyTests: GRDBTestCase { } } - func testWriteObservationIsWrappedInSavepoint() throws { + func testWriteObservationIsWrappedInSavepointWithoutErrorHandling() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") @@ -104,11 +124,10 @@ class ValueObservationReadonlyTests: GRDBTestCase { throw TestError() }) observation.requiresWriteAccess = true - + do { _ = try observation.start( in: dbQueue, - onError: { _ in fatalError() }, onChange: { _ in fatalError() }) XCTFail("Expected error") } catch is TestError { @@ -117,4 +136,31 @@ class ValueObservationReadonlyTests: GRDBTestCase { let count = try dbQueue.read { try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t")! } XCTAssertEqual(count, 0) } + + func testWriteObservationIsWrappedInSavepointWithErrorHandling() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { + try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT)") + } + + struct TestError: Error { } + var observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, fetch: { db in + try db.execute(sql: "INSERT INTO t DEFAULT VALUES") + throw TestError() + }) + observation.requiresWriteAccess = true + + var error: Error? + _ = observation.start( + in: dbQueue, + onError: { error = $0 }, + onChange: { _ in fatalError() }) + guard error is TestError else { + XCTFail("Expected TestError") + return + } + + let count = try dbQueue.read { try Int.fetchOne($0, sql: "SELECT COUNT(*) FROM t")! } + XCTAssertEqual(count, 0) + } } diff --git a/Tests/GRDBTests/ValueObservationRecordTests.swift b/Tests/GRDBTests/ValueObservationRecordTests.swift index 304a8c50c5..a5e9b6e80d 100644 --- a/Tests/GRDBTests/ValueObservationRecordTests.swift +++ b/Tests/GRDBTests/ValueObservationRecordTests.swift @@ -10,7 +10,8 @@ import XCTest import GRDB #endif -private struct Player: FetchableRecord { +private struct Player: TableRecord, FetchableRecord { + static let databaseTableName = "t" var id: Int64 var name: String @@ -25,7 +26,7 @@ private struct Player: FetchableRecord { } class ValueObservationRecordTests: GRDBTestCase { - func testAll() throws { + func testAllDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -61,7 +62,79 @@ class ValueObservationRecordTests: GRDBTestCase { } } - func testOne() throws { + func testAll() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [[Player]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT * FROM t ORDER BY id").observationForAll() + let observer = try observation.start(in: dbQueue) { players in + results.append(players) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 1") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.row }}, [ + [], + [["id":1, "name":"foo"]], + [["id":1, "name":"foo"], ["id":2, "name":"bar"]], + [["id":2, "name":"bar"]]]) + } + } + + func testTableRecordStaticAll() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [[Player]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = Player.observationForAll() + let observer = try observation.start(in: dbQueue) { players in + results.append(players) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 1") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.row }}, [ + [], + [["id":1, "name":"foo"]], + [["id":1, "name":"foo"], ["id":2, "name":"bar"]], + [["id":2, "name":"bar"]]]) + } + } + + func testOneDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -96,4 +169,40 @@ class ValueObservationRecordTests: GRDBTestCase { nil]) } } + + func testOne() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [Player?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT * FROM t ORDER BY id DESC").observationForFirst() + let observer = try observation.start(in: dbQueue) { player in + results.append(player) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results.map { $0.map { $0.row }}, [ + nil, + ["id":1, "name":"foo"], + ["id":2, "name":"bar"], + nil]) + } + } } diff --git a/Tests/GRDBTests/ValueObservationReducerTests.swift b/Tests/GRDBTests/ValueObservationReducerTests.swift index 5621fb251e..ad649ad892 100644 --- a/Tests/GRDBTests/ValueObservationReducerTests.swift +++ b/Tests/GRDBTests/ValueObservationReducerTests.swift @@ -59,7 +59,7 @@ class ValueObservationReducerTests: GRDBTestCase { let observation = ValueObservation.tracking(request, reducer: { _ in reducer }) // Start observation - let observer = try observation.start( + let observer = observation.start( in: dbWriter, onError: { errors.append($0) @@ -122,7 +122,7 @@ class ValueObservationReducerTests: GRDBTestCase { try test(makeDatabasePool()) } - func testInitialError() throws { + func testInitialErrorWithoutErrorHandling() throws { func test(_ dbWriter: DatabaseWriter) throws { struct TestError: Error { } let reducer = AnyValueReducer( @@ -136,8 +136,7 @@ class ValueObservationReducerTests: GRDBTestCase { do { _ = try observation.start( in: dbWriter, - onError: { _ in fatalError() }, - onChange: { _ in fatalError() }) + onChange: { _ in }) XCTFail("Expected error") } catch is TestError { } @@ -147,6 +146,29 @@ class ValueObservationReducerTests: GRDBTestCase { try test(makeDatabasePool()) } + func testInitialErrorWithErrorHandling() throws { + func test(_ dbWriter: DatabaseWriter) throws { + struct TestError: Error { } + let reducer = AnyValueReducer( + fetch: { _ in throw TestError() }, + value: { _ in fatalError() }) + + // Create an observation + let observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, reducer: { _ in reducer }) + + // Start observation + var error: TestError? + _ = observation.start( + in: dbWriter, + onError: { error = $0 as? TestError }, + onChange: { _ in }) + XCTAssertNotNil(error) + } + + try test(makeDatabaseQueue()) + try test(makeDatabasePool()) + } + func testSuccessThenErrorThenSuccess() throws { func test(_ dbWriter: DatabaseWriter) throws { // We need something to change @@ -174,7 +196,7 @@ class ValueObservationReducerTests: GRDBTestCase { let observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, reducer: { _ in reducer }) // Start observation - let observer = try observation.start( + let observer = observation.start( in: dbWriter, onError: { errors.append($0) @@ -351,9 +373,7 @@ class ValueObservationReducerTests: GRDBTestCase { notificationExpectation.expectedFulfillmentCount = 3 struct T: TableRecord { } - let observation = ValueObservation - .trackingCount(T.all()) - .map { "\($0)" } + let observation = T.observationForCount().map { "\($0)" } let observer = try observation.start(in: dbWriter) { count in counts.append(count) notificationExpectation.fulfill() diff --git a/Tests/GRDBTests/ValueObservationRowTests.swift b/Tests/GRDBTests/ValueObservationRowTests.swift index 25380deaba..be03c5d80a 100644 --- a/Tests/GRDBTests/ValueObservationRowTests.swift +++ b/Tests/GRDBTests/ValueObservationRowTests.swift @@ -11,7 +11,7 @@ import XCTest #endif class ValueObservationRowTests: GRDBTestCase { - func testAll() throws { + func testAllDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -47,7 +47,43 @@ class ValueObservationRowTests: GRDBTestCase { } } - func testOne() throws { + func testAll() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [[Row]] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT * FROM t ORDER BY id").observationForAll() + let observer = try observation.start(in: dbQueue) { rows in + results.append(rows) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t WHERE id = 1") // -1 + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results, [ + [], + [["id":1, "name":"foo"]], + [["id":1, "name":"foo"], ["id":2, "name":"bar"]], + [["id":2, "name":"bar"]]]) + } + } + + func testOneDeprecated() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } @@ -82,4 +118,40 @@ class ValueObservationRowTests: GRDBTestCase { ["id":2, "name":"bar"], nil]) } + + func testOne() throws { + let dbQueue = try makeDatabaseQueue() + try dbQueue.write { try $0.execute(sql: "CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)") } + + var results: [Row?] = [] + let notificationExpectation = expectation(description: "notification") + notificationExpectation.assertForOverFulfill = true + notificationExpectation.expectedFulfillmentCount = 4 + + let observation = SQLRequest(sql: "SELECT * FROM t ORDER BY id DESC").observationForFirst() + let observer = try observation.start(in: dbQueue) { row in + results.append(row) + notificationExpectation.fulfill() + } + try withExtendedLifetime(observer) { + try dbQueue.inDatabase { db in + try db.execute(sql: "INSERT INTO t (id, name) VALUES (1, 'foo')") // +1 + try db.execute(sql: "UPDATE t SET name = 'foo' WHERE id = 1") // = + try db.inTransaction { // +1 + try db.execute(sql: "INSERT INTO t (id, name) VALUES (2, 'bar')") + try db.execute(sql: "INSERT INTO t (id, name) VALUES (3, 'baz')") + try db.execute(sql: "DELETE FROM t WHERE id = 3") + return .commit + } + try db.execute(sql: "DELETE FROM t") // -1 + } + } + + waitForExpectations(timeout: 1, handler: nil) + XCTAssertEqual(results, [ + nil, + ["id":1, "name":"foo"], + ["id":2, "name":"bar"], + nil]) + } } diff --git a/Tests/GRDBTests/ValueObservationSchedulingTests.swift b/Tests/GRDBTests/ValueObservationSchedulingTests.swift index 65ee381b48..af898fbe86 100644 --- a/Tests/GRDBTests/ValueObservationSchedulingTests.swift +++ b/Tests/GRDBTests/ValueObservationSchedulingTests.swift @@ -113,7 +113,7 @@ class ValueObservationSchedulingTests: GRDBTestCase { value: { $0 }) let observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, reducer: { _ in reducer }) - let observer = try observation.start( + let observer = observation.start( in: dbWriter, onError: { error in XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) @@ -237,7 +237,7 @@ class ValueObservationSchedulingTests: GRDBTestCase { var observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, reducer: { _ in reducer }) observation.scheduling = .async(onQueue: queue, startImmediately: false) - let observer = try observation.start( + let observer = observation.start( in: dbWriter, onError: { error in XCTAssertNotNil(DispatchQueue.getSpecific(key: key)) @@ -392,7 +392,7 @@ class ValueObservationSchedulingTests: GRDBTestCase { var observation = ValueObservation.tracking(DatabaseRegion.fullDatabase, reducer: { _ in reducer }) observation.scheduling = .unsafe(startImmediately: false) - let observer = try observation.start( + let observer = observation.start( in: dbWriter, onError: { error in errorCount += 1